diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..fd1d202080 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +*/example/ +*/build/ +*/.dart_tool/ + +# We dont need git history inside the image. +.git + +# Ignore the golden failure directories because they will be mapped. +**/failures/ \ No newline at end of file diff --git a/.github/workflows/build_clones.yaml b/.github/workflows/build_clones.yaml new file mode 100644 index 0000000000..c86e52c01a --- /dev/null +++ b/.github/workflows/build_clones.yaml @@ -0,0 +1,128 @@ +name: Build Clones +on: [pull_request] +jobs: + build_bear: + runs-on: macos-latest + defaults: + run: + # Run everything from within the bear project directory + working-directory: ./super_clones/bear + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Build the clone + - run: flutter build macos --debug + + build_google_docs: + runs-on: macos-latest + defaults: + run: + # Run everything from within the google_docs project directory + working-directory: ./super_clones/google_docs + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Build the clone + - run: flutter build macos --debug + + build_medium: + runs-on: macos-latest + defaults: + run: + # Run everything from within the medium project directory + working-directory: ./super_clones/medium + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Build the clone + - run: flutter build web --debug + + build_obsidian: + runs-on: macos-latest + defaults: + run: + # Run everything from within the obsidian project directory + working-directory: ./super_clones/obsidian + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Build the clone + - run: flutter build macos --debug + + build_slack: + runs-on: macos-latest + defaults: + run: + # Run everything from within the slack project directory + working-directory: ./super_clones/slack + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Build the clone + - run: flutter build macos --debug + + build_quill: + runs-on: macos-latest + defaults: + run: + # Run everything from within the quill project directory + working-directory: ./super_clones/quill + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Build the clone + - run: flutter build macos --debug diff --git a/.github/workflows/cherry_pick_to_stable.yaml b/.github/workflows/cherry_pick_to_stable.yaml new file mode 100644 index 0000000000..2a22607826 --- /dev/null +++ b/.github/workflows/cherry_pick_to_stable.yaml @@ -0,0 +1,57 @@ +# In general, whenever a change is merged to "main" we want to replicate that change on "stable". +# This GitHub action watches for merges to "main", creates a new branch off of "stable", cherry +# picks the latest commit from "main" to the new branch, and then puts up a PR with the cherry +# pick. At that point, a reviewer waits until all tests pass, and then merges in the cherry pick +# PR. +# +# Sometimes, a change to "main" shouldn't be merged to "stable". In that case, tag the original PR +# with a "no-cherry-pick" label, and this action won't cherry pick that merge. +name: Cherry pick to stable +on: + pull_request: + branches: + - main + types: ["closed"] + +jobs: + cherry-pick-to-stable: + runs-on: ubuntu-latest + name: Cherry pick from main to stable + + # Only cherry pick merged PRs. Don't merge PRs marked with "no-cherry-pick" label. + if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'no-cherry-pick') }} + + # These ENV variables can be accessed directly when they're used in shell commands, e.g.: + # + # echo "My name is $NAME" + # + # When referencing ENV variables in declarative YAML, they must be accessed via "env.", e.g.: + # + # someProperty: "My name is ${{ env.NAME }}" + # + # GitHub pull request data: https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request + env: + CHERRY_PICK_BRANCH_NAME: "cherry-pick_${{ github.event.pull_request.head.ref }}_${{ github.event.pull_request.merge_commit_sha }}" + CHERRY_PICK_PR_TITLE: "Cherry Pick: ${{ github.event.pull_request.title }}" + CHERRY_PICK_PR_BODY: "Cherry Pick: Original PR - #${{ github.event.pull_request.number }}" + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Cherry pick into stable + uses: carloscastrojumo/github-cherry-pick-action@v1.0.9 + with: + branch: stable + cherry-pick-branch: "${{ env.CHERRY_PICK_BRANCH_NAME }}" + title: "${{ env.CHERRY_PICK_PR_TITLE }}" + body: "${{ env.CHERRY_PICK_PR_BODY }}" + labels: | + cherry-pick + reviewers: | + matthew-carroll + "${{ gitHub.event.pull_request.user.login }}" +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr_validation.yaml b/.github/workflows/pr_validation.yaml index 4a7813a888..c9eed8202c 100644 --- a/.github/workflows/pr_validation.yaml +++ b/.github/workflows/pr_validation.yaml @@ -71,11 +71,12 @@ jobs: # Run all tests - run: flutter test - test_super_editor_markdown: + test_goldens_super_editor: + if: ${{ github.base_ref == 'main' }} runs-on: ubuntu-latest defaults: run: - working-directory: ./super_editor_markdown + working-directory: ./super_editor steps: # Checkout the PR branch - uses: actions/checkout@v3 @@ -84,16 +85,180 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: "stable" + architecture: x64 # Download all the packages that the app uses - run: flutter pub get - # TODO: Enforce static analysis + # Run all golden tests + - run: flutter test test_goldens + + # Archive golden failures + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: golden-failures + path: "**/failures/**/*.png" + + test_super_editor_spellcheck: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./super_editor_spellcheck + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get # Run all tests - run: flutter test + + test_super_editor_spellcheck_goldens: + if: ${{ github.base_ref == 'main' }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./super_editor_spellcheck + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 - test_super_text_layout: + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + architecture: x64 + + # Download all the packages that the app uses + - run: flutter pub get + + # Run all golden tests + - run: flutter test test_goldens + + # Archive golden failures + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: golden-failures + path: "**/failures/**/*.png" + + analyze_super_editor_clipboard: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./super_editor_clipboard + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Enforce static analysis + - run: flutter analyze + + test_super_editor_clipboard: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./super_editor_clipboard + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Run all tests + - run: flutter test + + analyze_super_keyboard: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./super_keyboard + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Enforce static analysis + - run: flutter analyze + + test_super_keyboard: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./super_keyboard + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + + # Run all tests + - run: flutter test + + test_goldens_super_keyboard: + if: ${{ github.base_ref == 'main' }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./super_keyboard + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + architecture: x64 + + # Download all the packages that the app uses + - run: flutter pub get + + # Run all golden tests + - run: flutter test test_goldens + + # Archive golden failures + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: golden-failures + path: "**/failures/**/*.png" + + analyze_super_text_layout: runs-on: ubuntu-latest defaults: run: @@ -113,11 +278,29 @@ jobs: # Enforce static analysis - run: flutter analyze + test_super_text_layout: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./super_text_layout + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + # Download all the packages that the app uses + - run: flutter pub get + # Run all tests - run: flutter test test_goldens_super_text_layout: - runs-on: macos-latest + if: ${{ github.base_ref == 'main' }} + runs-on: ubuntu-latest defaults: run: working-directory: ./super_text_layout @@ -137,7 +320,14 @@ jobs: # Run all golden tests - run: flutter test test_goldens - test_attributed_text: + # Archive golden failures + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: golden-failures + path: "**/failures/**/*.png" + + analyze_attributed_text: runs-on: ubuntu-latest defaults: run: @@ -155,5 +345,20 @@ jobs: # Enforce static analysis - run: dart analyze + test_attributed_text: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./attributed_text + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Dart + - uses: dart-lang/setup-dart@v1 + + # Install app dependencies + - run: dart pub get + # Run all tests - run: dart test diff --git a/.gitignore b/.gitignore index 2f2eff8c11..44de409c06 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ ios_text_example/ android_text_example/ +.vscode \ No newline at end of file diff --git a/README.md b/README.md index afe1db5eb6..aa40f8117f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@

Super Editor was initiated by Superlist and is implemented and maintained by the Flutter Bounty Hunters, Superlist, and the contributors.

-

Feel free to join us on Slack and say hello 👋.

+
+ +

Do you use Flutter's stable branch? Be sure to checkout super_editor's stable branch, for compatibility.
Do you use Flutter's master branch? Be sure to checkout super_editor's main branch, for compabitility.


@@ -15,9 +17,9 @@

Super Editor & Super Text Field

-Please see the [SuperEditor README](super_editor/README.md) about how to use the packages, or run the [sample editor](super_editor/example/README.md). +Please see the [SuperEditor README](super_editor/README.md) about how to use the packages, or run the [sample editor](super_editor/example/README.md). -A web demo is accessible at [https://superlist.com/SuperEditor](https://superlist.com/SuperEditor/). +A web demo is accessible at [https://superlist.com/SuperEditor](https://superlist.com/SuperEditor/).
@@ -30,3 +32,31 @@ You might notice that this is a mono-repo, which includes multiple projects. Tha Attributed Text

+

Mono-repo Versioning

+If you have compilation errors when using the GitHub version of super_editor, try overriding dependencies for the other packages in this mono-repo, e.g., super_editor_markdown, super_text_layout, and attributed_text. This project often makes changes to multiple packages within the mono-repo, which requires that you use the latest main or stable version of every package. + +You can override your dependencies as follows: + +```yaml +dependency_overrides: + super_editor: + git: + url: https://github.com/superlistapp/super_editor + path: super_editor + ref: stable # or "main" + super_editor_markdown: + git: + url: https://github.com/superlistapp/super_editor + path: super_editor_markdown + ref: stable + super_text_layout: + git: + url: https://github.com/superlistapp/super_editor + path: super_text_layout + ref: stable + attributed_text: + git: + url: https://github.com/superlistapp/super_editor + path: attributed_text + ref: stable +``` diff --git a/attributed_text/CHANGELOG.md b/attributed_text/CHANGELOG.md index c6adead31b..cccb71b117 100644 --- a/attributed_text/CHANGELOG.md +++ b/attributed_text/CHANGELOG.md @@ -1,3 +1,29 @@ +## [0.3.2] - June, 2024 + * Fix crash when adding attributions that overlap others - you can now control whether a new attribution overwrites conflicting spans when you add it. + +## [0.3.1] - June, 2024 + * Added query `getAllAttributionsThroughout()` to `AttributedText`. + * Added `copy()` to `AttributedText()`. + * Added ability to insert an attribution that splits an existing attribution. + +## [0.3.0] - Feb, 2024 + * [BREAKING] - `AttributedText` and `SpanRange` constructors now use positional parameters istead of named parameters. + * [FIX] - `AttributedText` now supports differents links for different URLs in the same text blob - previously all links were sent to the same URL withing a single `AttributedText`. + * [FIX] - `collapseSpans` now reports the correct ending index, which was previously off by one. + * `AttributedText` now has a substring method and length property to avoid needing to access the inner `text` string. + * `AttributionSpan` now has a `range` property to get a non-directional span of text. + * `AttributedText` can visit and report attribution spans instead of just visiting individual attribution markers. + * Added query methods: + * `getAttributionSpans()` + * `getAttributionSpansByFilter()` + * `AttributedText` now allows you to `addAttribution()` without auto-merging with preceding and following attributions (#1198) + +## [0.2.2] - May, 2023 +Upgrade Dart constraints to explicitly include Dart 3. Make `markers` public on `AttributedSpans`. + +## [0.2.1] - January, 2023 +Add `getAttributedRange()`, which returns a range that includes a given set of attributions. + ## [0.2.0] - July, 2022 BREAKING - Attributions in an `AttributedText` are now visited by a `AttributionVisitor` instead of a callback, and the visitor receives span markers in a more useful way. diff --git a/attributed_text/analysis_options.yaml b/attributed_text/analysis_options.yaml new file mode 100644 index 0000000000..31d6dd1061 --- /dev/null +++ b/attributed_text/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: [build/**] + +linter: + rules: + omit_local_variable_types: false + avoid_renaming_method_parameters: false diff --git a/attributed_text/lib/src/attributed_spans.dart b/attributed_text/lib/src/attributed_spans.dart index db80172634..3e121011c0 100644 --- a/attributed_text/lib/src/attributed_spans.dart +++ b/attributed_text/lib/src/attributed_spans.dart @@ -1,7 +1,6 @@ import 'dart:math'; import 'package:collection/collection.dart'; -import 'package:meta/meta.dart'; import 'attribution.dart'; import 'logging.dart'; @@ -44,16 +43,16 @@ class AttributedSpans { /// with no spans. AttributedSpans({ List? attributions, - }) : markers = [...?attributions] { + }) : _markers = [...?attributions] { _sortAttributions(); } // markers must always be in order from lowest marker offset to highest marker offset. - @visibleForTesting - final List markers; + Iterable get markers => _markers; + final List _markers; void _sortAttributions() { - markers.sort(); + _markers.sort(); } /// Returns `true` if this [AttributedSpans] contains at least one @@ -66,15 +65,21 @@ class AttributedSpans { }) { final attributionsToFind = Set.from(attributions); for (int i = start; i <= end; ++i) { + final foundAttributions = {}; for (final attribution in attributionsToFind) { if (hasAttributionAt(i, attribution: attribution)) { - attributionsToFind.remove(attribution); - } - - if (attributionsToFind.isEmpty) { - return true; + // Store the attributions we found so far to remove them + // from attributionsToFind after the loop. + // + // Removing from the set while iterating throws an exception. + foundAttributions.add(attribution); } } + attributionsToFind.removeAll(foundAttributions); + if (attributionsToFind.isEmpty) { + // We found all attributions. + return true; + } } return false; } @@ -91,7 +96,7 @@ class AttributedSpans { final matchingAttributions = {}; for (int i = start; i <= end; ++i) { for (final attribution in attributions) { - final otherAttributions = getAllAttributionsAt(start); + final otherAttributions = getAllAttributionsAt(i); for (final otherAttribution in otherAttributions) { if (otherAttribution.id == attribution.id) { matchingAttributions.add(otherAttribution); @@ -154,7 +159,7 @@ class AttributedSpans { /// Returns all attributions for spans that cover the given [offset]. Set getAllAttributionsAt(int offset) { final allAttributions = {}; - for (final marker in markers) { + for (final marker in _markers) { allAttributions.add(marker.attribution); } @@ -248,8 +253,8 @@ class AttributedSpans { // Note: maxStartMarkerOffset and minEndMarkerOffset are non-null because we verified // that all desired attributions are present at the given offset. return SpanRange( - start: maxStartMarkerOffset!, - end: minEndMarkerOffset!, + maxStartMarkerOffset!, + minEndMarkerOffset!, ); } @@ -257,39 +262,66 @@ class AttributedSpans { /// given [offset], optionally looking specifically for a marker with /// the given [attribution]. SpanMarker? _getStartingMarkerAtOrBefore(int offset, {Attribution? attribution}) { - return markers // + return _markers // .reversed // search from the end so its the nearest start marker .where((marker) { - return attribution == null || - (marker.attribution.id == attribution.id && marker.attribution.canMergeWith(attribution)); - }) - // .where((marker) => attribution == null || marker.attribution.id == attribution.id) - .firstWhereOrNull((marker) => marker.isStart && marker.offset <= offset); + return attribution == null || (marker.attribution == attribution); + }).firstWhereOrNull((marker) => marker.isStart && marker.offset <= offset); } /// Finds and returns the nearest [end] marker that appears at or after the /// given [offset], optionally looking specifically for a marker with /// the given [attribution]. SpanMarker? _getEndingMarkerAtOrAfter(int offset, {Attribution? attribution}) { - return markers - .where((marker) => - attribution == null || - (marker.attribution.id == attribution.id && marker.attribution.canMergeWith(attribution))) + return _markers + .where((marker) => attribution == null || (marker.attribution.id == attribution.id)) .firstWhereOrNull((marker) => marker.isEnd && marker.offset >= offset); } /// Applies the [newAttribution] from [start] to [end], inclusive. /// - /// If [newAttribution] spans already exist at [start] or [end], and those - /// spans are compatible, the spans are expanded to include the new region - /// between [start] and [end]. + /// If [start] is less than `0`, nothing happens. + /// + /// [AttributedSpans] doesn't have any knowledge about content length, so [end] can + /// take any value that's desired. However, users of [AttributedSpans] should take + /// care to avoid values for [end] that exceed the content length. + /// + /// The effect of adding an attribution is straight forward when the text doesn't + /// contain any other attributions with the same ID. However, there are various + /// situations where [newAttribution] can't necessarily co-exist with other + /// attribution spans that already exist in the text. + /// + /// Attribution overlaps can take one of two forms: mergeable or conflicting. + /// + /// ## Mergeable Attribution Spans + /// An example of a mergeable overlap is where two bold spans overlap each + /// other. All bold attributions are interchangeable, so when two bold spans + /// overlap, those spans can be merged together into a single span. + /// + /// However, mergeable overlapping spans are not automatically merged. Instead, + /// this decision is left to the user of this class. If you want [AttributedSpans] to + /// merge overlapping mergeable spans, pass `true` for [autoMerge]. Otherwise, + /// if [autoMerge] is `false`, an exception is thrown when two mergeable spans + /// overlap each other. + /// /// - /// It [newAttribution] overlaps a conflicting span, a - /// [IncompatibleOverlappingAttributionsException] is thrown. + /// ## Conflicting Attribution Spans + /// An example of a conflicting overlap is where a black text color overlaps a red + /// text color. Text is either black, OR red, but never both. Therefore, the black + /// attribution cannot co-exist with the red attribution. Something must be done + /// to resolve this. + /// + /// There are two possible ways to handle conflicting overlaps. The new attribution + /// can overwrite the existing attribution where they overlap. Or, an exception can be + /// thrown. To overwrite the existing attribution with the new attribution, pass `true` + /// for [overwriteConflictingSpans]. Otherwise, if [overwriteConflictingSpans] + /// is `false`, an exception is thrown. void addAttribution({ required Attribution newAttribution, required int start, required int end, + bool autoMerge = true, + bool overwriteConflictingSpans = true, }) { if (start < 0 || start > end) { _log.warning("Tried to add an attribution ($newAttribution) at an invalid start/end: $start -> $end"); @@ -297,29 +329,110 @@ class AttributedSpans { } _log.info("Adding attribution ($newAttribution) from $start to $end"); - _log.finer("Has ${markers.length} markers before addition"); + _log.finer("Has ${_markers.length} markers before addition"); + + final conflicts = <_AttributionConflict>[]; - // Ensure that no conflicting attribution overlaps the new attribution. - // If a conflict exists, throw an exception. + // Check if conflicting attributions overlap the new attribution. final matchingAttributions = getMatchingAttributionsWithin(attributions: {newAttribution}, start: start, end: end); if (matchingAttributions.isNotEmpty) { for (final matchingAttribution in matchingAttributions) { - if (!newAttribution.canMergeWith(matchingAttribution)) { - late int conflictStart; + bool areAttributionsMergeable = newAttribution.canMergeWith(matchingAttribution); + if (!areAttributionsMergeable || !autoMerge) { + int? conflictStart; + int? conflictEnd; + for (int i = start; i <= end; ++i) { if (hasAttributionAt(i, attribution: matchingAttribution)) { - conflictStart = i; - break; + conflictStart ??= i; + conflictEnd = i; + + if (areAttributionsMergeable) { + // Both attributions are mergeable, but the caller doesn't want to merge them. + throw IncompatibleOverlappingAttributionsException( + existingAttribution: matchingAttribution, + newAttribution: newAttribution, + conflictStart: conflictStart, + ); + } + } else if (conflictStart != null) { + // We found the end of the conflict. + conflicts.add(_AttributionConflict( + newAttribution: newAttribution, + existingAttribution: matchingAttribution, + conflictStart: conflictStart, + conflictEnd: conflictEnd!, + )); + + // Reset so we can find the next conflict. + conflictStart = null; + conflictEnd = null; } } - throw IncompatibleOverlappingAttributionsException( - existingAttribution: matchingAttribution, - newAttribution: newAttribution, - conflictStart: conflictStart, - ); + if (conflictStart != null && conflictEnd != null) { + // We found a conflict that extends to the end of the range. + conflicts.add(_AttributionConflict( + newAttribution: newAttribution, + existingAttribution: matchingAttribution, + conflictStart: conflictStart, + conflictEnd: conflictEnd, + )); + } } } + + if (conflicts.isNotEmpty && !overwriteConflictingSpans) { + // We found conflicting attributions and we are configured not to overwrite them. + // For example, the user tried to apply a blue color attribution to a range of text + // that already has another color attribution. + throw IncompatibleOverlappingAttributionsException( + existingAttribution: conflicts.first.existingAttribution, + newAttribution: newAttribution, + conflictStart: conflicts.first.conflictStart, + ); + } + } + + // Removes any conflicting attributions. For example, consider the following text, + // with a blue color attribution that spans the entire text: + // + // one two three + // |bbbbbbbbbbbbb| + // + // We can't apply a green color attribution to the word "two", because it's already + // attributed with blue. So, we need to remove the blue attribution from the word "two", + // which results in the following text: + // + // one two three + // |bbbb---bbbbbb| + // + // After that, we can apply the desired attribution, because there isn't a conflicting attribution + // in this range anymore. + for (final conflict in conflicts) { + removeAttribution( + attributionToRemove: conflict.existingAttribution, + start: conflict.conflictStart, + end: conflict.conflictEnd, + ); + } + + if (!autoMerge) { + // We don't want to merge this new attribution with any other nearby attribution. + // Therefore, we can blindly create the new attribution range without any + // further adjustments, and then be done. + _insertMarker(SpanMarker( + attribution: newAttribution, + offset: start, + markerType: SpanMarkerType.start, + )); + _insertMarker(SpanMarker( + attribution: newAttribution, + offset: end, + markerType: SpanMarkerType.end, + )); + + return; } // Start the new span, either by expanding an existing span, or by @@ -327,11 +440,11 @@ class AttributedSpans { final endMarkerJustBefore = SpanMarker(attribution: newAttribution, offset: start - 1, markerType: SpanMarkerType.end); final endMarkerAtNewStart = SpanMarker(attribution: newAttribution, offset: start, markerType: SpanMarkerType.end); - if (markers.contains(endMarkerJustBefore)) { + if (_markers.contains(endMarkerJustBefore)) { // A compatible span ends immediately before this new span begins. // Remove the end marker so that the existing span flows into the new span. _log.fine('A compatible span already exists immediately before the new span range. Combining the spans.'); - markers.remove(endMarkerJustBefore); + _markers.remove(endMarkerJustBefore); } else if (!hasAttributionAt(start, attribution: newAttribution)) { // The desired attribution does not yet exist at `start`, and no compatible // span sits immediately upstream. Therefore, we need to start a new span @@ -342,23 +455,23 @@ class AttributedSpans { offset: start, markerType: SpanMarkerType.start, )); - } else if (markers.contains(endMarkerAtNewStart)) { + } else if (_markers.contains(endMarkerAtNewStart)) { // There's an end marker for this span at the same place where // the new span wants to begin. Remove the end marker so that the // existing span flows into the new span. _log.fine('Removing existing end marker at $start because the new span should merge with an existing span'); - markers.remove(endMarkerAtNewStart); + _markers.remove(endMarkerAtNewStart); } // Delete all markers of the same type between `range.start` // and `range.end`. - final markersToDelete = markers + final markersToDelete = _markers .where((attribution) => attribution.attribution == newAttribution) .where((attribution) => attribution.offset > start) .where((attribution) => attribution.offset <= end) .toList(); _log.fine('Removing ${markersToDelete.length} markers between $start and $end'); - markers.removeWhere((element) => markersToDelete.contains(element)); + _markers.removeWhere((element) => markersToDelete.contains(element)); final lastDeletedMarker = markersToDelete.isNotEmpty ? markersToDelete.last : null; @@ -383,7 +496,7 @@ class AttributedSpans { // Only run this loop in debug mode to avoid unnecessary iteration // in a release build (when logging should be turned off, anyway). _log.fine('All attributions after:'); - markers.where((element) => element.attribution == newAttribution).forEach((element) { + _markers.where((element) => element.attribution == newAttribution).forEach((element) { _log.fine('$element'); }); return true; @@ -465,16 +578,16 @@ class AttributedSpans { // Now that the end caps have been handled, remove all // relevant attribution markers between [start, end]. - final markersToDelete = markers + final markersToDelete = _markers .where((attribution) => attribution.attribution == attributionToRemove) .where((attribution) => attribution.offset >= start) .where((attribution) => attribution.offset <= end) .toList(); _log.finer('removing ${markersToDelete.length} markers between $start and $end'); - markers.removeWhere((element) => markersToDelete.contains(element)); + _markers.removeWhere((element) => markersToDelete.contains(element)); _log.finer('all attributions after:'); - markers.where((element) => element.attribution == attributionToRemove).forEach((element) { + _markers.where((element) => element.attribution == attributionToRemove).forEach((element) { _log.finer(' - $element'); }); } @@ -512,8 +625,8 @@ class AttributedSpans { return false; } - final indexBefore = markers.indexOf(markerBefore); - final nextMarker = markers.sublist(indexBefore).firstWhereOrNull((marker) { + final indexBefore = _markers.indexOf(markerBefore); + final nextMarker = _markers.sublist(indexBefore).firstWhereOrNull((marker) { _log.finest('Comparing start marker $markerBefore to another marker $marker'); return marker.attribution == attribution && marker.offset >= markerBefore.offset && marker != markerBefore; }); @@ -545,7 +658,7 @@ class AttributedSpans { SpanMarkerType? type, }) { SpanMarker? markerBefore; - final markerCandidates = markers + final markerCandidates = _markers .where((marker) => attribution == null || marker.attribution == attribution) .where((marker) => type == null || marker.markerType == type); @@ -563,7 +676,7 @@ class AttributedSpans { /// Returns the markers at the given [offset] with the given [attribution].. Set _getMarkerAt(Attribution attribution, int offset, [SpanMarkerType? type]) { - return markers + return _markers .where((marker) => marker.attribution == attribution) .where((marker) => marker.offset == offset) .where((marker) => type == null || marker.markerType == type) @@ -576,15 +689,15 @@ class AttributedSpans { /// the same attribution at the same offset. void _insertMarker(SpanMarker newMarker) { int indexOfFirstMarkerAfterInsertionPoint = - markers.indexWhere((existingMarker) => existingMarker.compareTo(newMarker) > 0); + _markers.indexWhere((existingMarker) => existingMarker.compareTo(newMarker) > 0); // [indexWhere] returns -1 if no matching element is found. final foundMarkerToInsertBefore = indexOfFirstMarkerAfterInsertionPoint >= 0; if (foundMarkerToInsertBefore) { - markers.insert(indexOfFirstMarkerAfterInsertionPoint, newMarker); + _markers.insert(indexOfFirstMarkerAfterInsertionPoint, newMarker); } else { // Insert the new marker at the end. - markers.add(newMarker); + _markers.add(newMarker); } } @@ -597,9 +710,9 @@ class AttributedSpans { required AttributedSpans other, required int index, }) { - if (markers.isNotEmpty && markers.last.offset >= index) { + if (_markers.isNotEmpty && _markers.last.offset >= index) { throw Exception( - 'Another AttributedSpans can only be appended after the final marker in this AttributedSpans. Final marker: ${markers.last}'); + 'Another AttributedSpans can only be appended after the final marker in this AttributedSpans. Final marker: ${_markers.last}'); } _log.fine('attributions before pushing them:'); @@ -615,7 +728,7 @@ class AttributedSpans { final pushedSpans = other.copy()..pushAttributionsBack(pushDistance); // Combine `this` and `other` attributions into one list. - final List combinedAttributions = List.from(markers)..addAll(pushedSpans.markers); + final List combinedAttributions = List.from(_markers)..addAll(pushedSpans._markers); _log.fine('combined attributions before merge:'); for (final marker in combinedAttributions) { _log.fine(' - $marker'); @@ -630,7 +743,7 @@ class AttributedSpans { _log.fine(' - $marker'); } - markers + _markers ..clear() ..addAll(combinedAttributions); } @@ -673,7 +786,7 @@ class AttributedSpans { /// If no [endOffset] is provided, a copy is made from [startOffset] /// to the [offset] of the last marker in this [AttributedSpans]. AttributedSpans copyAttributionRegion(int startOffset, [int? endOffset]) { - endOffset = endOffset ?? markers.lastOrNull?.offset ?? 0; + endOffset = endOffset ?? _markers.lastOrNull?.offset ?? 0; _log.fine('start: $startOffset, end: $endOffset'); final List cutAttributions = []; @@ -685,7 +798,7 @@ class AttributedSpans { // Analyze all markers that appear before the start of // the copy range so that we can insert any appropriate // `start` markers at the beginning of the copy range. - markers // + _markers // .where((marker) => marker.offset < startOffset) // .forEach((marker) { _log.fine('marker before the copy region: $marker'); @@ -716,13 +829,13 @@ class AttributedSpans { )); } else if (count < 0 || count > 1) { throw Exception( - 'Found an unbalanced number of `start` and `end` markers before offset: $startOffset - $markers'); + 'Found an unbalanced number of `start` and `end` markers before offset: $startOffset - $_markers'); } }); // Directly copy every marker that appears within the cut // region. - markers // + _markers // .where((marker) => startOffset <= marker.offset && marker.offset <= endOffset!) // .forEach((marker) { _log.fine('copying "${marker.attribution}" at ${marker.offset} from original AttributionSpans to copy region.'); @@ -734,7 +847,7 @@ class AttributedSpans { // Analyze all markers that appear after the end of // the copy range so that we can insert any appropriate // `end` markers at the end of the copy range. - markers // + _markers // .reversed // .where((marker) => marker.offset > endOffset!) // .forEach((marker) { @@ -765,7 +878,7 @@ class AttributedSpans { markerType: SpanMarkerType.end, )); } else if (count < 0 || count > 1) { - throw Exception('Found an unbalanced number of `start` and `end` markers after offset: $endOffset - $markers'); + throw Exception('Found an unbalanced number of `start` and `end` markers after offset: $endOffset - $_markers'); } }); @@ -780,8 +893,8 @@ class AttributedSpans { /// Changes all spans in this [AttributedSpans] by pushing /// them back by [offset] amount. void pushAttributionsBack(int offset) { - final pushedAttributions = markers.map((marker) => marker.copyWith(offset: marker.offset + offset)).toList(); - markers + final pushedAttributions = _markers.map((marker) => marker.copyWith(offset: marker.offset + offset)).toList(); + _markers ..clear() ..addAll(pushedAttributions); } @@ -795,12 +908,12 @@ class AttributedSpans { final contractedAttributions = []; // Add all the markers that are unchanged. - contractedAttributions.addAll(markers.where((marker) => marker.offset < startOffset)); + contractedAttributions.addAll(_markers.where((marker) => marker.offset < startOffset)); _log.fine('removing $count characters starting at $startOffset'); final needToEndAttributions = {}; final needToStartAttributions = {}; - markers + _markers .where((marker) => (startOffset <= marker.offset) && (marker.offset < startOffset + count)) .forEach((marker) { // Get rid of this marker and keep track of @@ -854,12 +967,12 @@ class AttributedSpans { // Add all remaining markers but with an `offset` // that is less by `count`. contractedAttributions.addAll( - markers + _markers .where((marker) => marker.offset >= startOffset + count) .map((marker) => marker.copyWith(offset: marker.offset - count)), ); - markers + _markers ..clear() ..addAll(contractedAttributions); } @@ -867,7 +980,7 @@ class AttributedSpans { /// Returns a copy of this [AttributedSpans]. AttributedSpans copy() { return AttributedSpans( - attributions: List.from(markers), + attributions: List.from(_markers), ); } @@ -880,7 +993,7 @@ class AttributedSpans { }) { _log.fine('content length: $contentLength'); _log.fine('attributions used to compute spans:'); - for (final marker in markers) { + for (final marker in _markers) { _log.fine(' - $marker'); } @@ -890,7 +1003,7 @@ class AttributedSpans { return []; } - if (markers.isEmpty || markers.first.offset > contentLength - 1) { + if (_markers.isEmpty || _markers.first.offset > contentLength - 1) { // There is content but no attributions that apply to it. return [MultiAttributionSpan(attributions: {}, start: 0, end: contentLength - 1)]; } @@ -899,8 +1012,8 @@ class AttributedSpans { var currentSpan = MultiAttributionSpan(attributions: {}, start: 0, end: contentLength - 1); _log.fine('walking list of markers to determine collapsed spans.'); - for (final marker in markers) { - if (marker.offset > contentLength) { + for (final marker in _markers) { + if (marker.offset > contentLength - 1) { // There are markers to process but we ran off the end of the requested content. Break early and handle // committing the last span if necessary below. _log.fine('ran out of markers within the requested contentLength, breaking early.'); @@ -965,15 +1078,15 @@ class AttributedSpans { identical(this, other) || other is AttributedSpans && runtimeType == other.runtimeType && - const DeepCollectionEquality.unordered().equals(markers, other.markers); + const DeepCollectionEquality.unordered().equals(_markers, other._markers); @override - int get hashCode => markers.hashCode; + int get hashCode => _markers.hashCode; @override String toString() { - final buffer = StringBuffer('[AttributedSpans] (${(markers.length / 2).round()} spans):'); - for (final marker in markers) { + final buffer = StringBuffer('[AttributedSpans] (${(_markers.length / 2).round()} spans):'); + for (final marker in _markers) { buffer.write('\n - $marker'); } return buffer.toString(); @@ -1069,6 +1182,12 @@ class AttributionSpan { final int start; final int end; + /// Returns a [SpanRange] from [start] to [end]. + SpanRange get range => SpanRange(start, end); + + /// Create a returns a copy of this [AttributionSpan], which is constrained + /// by the given [start] and [end], i.e., the returned value's `start` is + /// >= [start], and its `end` is <= [end]. AttributionSpan constrain({ required int start, required int end, @@ -1137,6 +1256,18 @@ class MultiAttributionSpan { @override String toString() => '[MultiAttributionSpan] - attributions: $attributions, start: $start, end: $end'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MultiAttributionSpan && + runtimeType == other.runtimeType && + start == other.start && + end == other.end && + const DeepCollectionEquality().equals(attributions, other.attributions); + + @override + int get hashCode => attributions.hashCode ^ start.hashCode ^ end.hashCode; } /// Returns `true` when the given [candidate] [Attribution] matches the desired condition. @@ -1158,3 +1289,45 @@ class IncompatibleOverlappingAttributionsException implements Exception { return 'Tried to insert attribution ($newAttribution) over a conflicting existing attribution ($existingAttribution). The overlap began at index $conflictStart'; } } + +/// A conflict between the [newAttribution] and [existingAttribution] between [conflictStart] and [conflictEnd] (inclusive). +/// +/// This means [newAttribution] and [existingAttribution] have the same id, but they can't be merged. +class _AttributionConflict { + _AttributionConflict({ + required this.newAttribution, + required this.existingAttribution, + required this.conflictStart, + required this.conflictEnd, + }); + + /// The new attribution that conflicts with the existing attribution. + final Attribution newAttribution; + + /// The conflicting attribution. + final Attribution existingAttribution; + + /// The first conflicting index. + final int conflictStart; + + /// The last conflicting index (inclusive). + final int conflictEnd; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _AttributionConflict && + runtimeType == other.runtimeType && + newAttribution == other.newAttribution && + existingAttribution == other.existingAttribution && + conflictStart == other.conflictStart && + conflictEnd == other.conflictEnd; + + @override + int get hashCode => + newAttribution.hashCode ^ existingAttribution.hashCode ^ conflictStart.hashCode ^ conflictEnd.hashCode; + + @override + String toString() => + '[AttributionConflict] - newAttribution: $newAttribution existingAttribution: $existingAttribution, conflictStart: $conflictStart, conflictEnd: $conflictEnd'; +} diff --git a/attributed_text/lib/src/attributed_text.dart b/attributed_text/lib/src/attributed_text.dart index 9eebed3446..048b879b6c 100644 --- a/attributed_text/lib/src/attributed_text.dart +++ b/attributed_text/lib/src/attributed_text.dart @@ -1,3 +1,5 @@ +import 'package:collection/collection.dart'; + import 'attributed_spans.dart'; import 'attribution.dart'; import 'logging.dart'; @@ -17,19 +19,177 @@ final _log = attributionsLog; // TODO: there is a mixture of mutable and immutable behavior in this class. // Pick one or the other, or offer 2 classes: mutable and immutable (#113) class AttributedText { - AttributedText({ - this.text = '', + /// The default character that's inserted in place of placeholders when converting + /// an [AttributedText] to plain text. + /// + /// `\uFFFC` is the unicode character for "object replacement" and it looks + /// like a regular space. + /// + /// `\uFFFD` is a similar character - it's the unicode character for replacing + /// unknown characters, and looks like: � + static const placeholderCharacter = '\uFFFC'; + + /// Constructs an [AttributedText] whose content is comprised by a combination + /// of [text] and [placeholders], covered by the given attributed [spans]. + /// + /// [placeholders] is a map from character indices to desired placeholder objects. + /// The character indices in [placeholders] refer to the final indices when the + /// placeholders have been combined with the [text]. + /// + /// Example: + /// - Full text: "�Hello � World!�" + /// - text: "Hello World!" + /// - placeholders: + /// - 0: MyPlaceholder + /// - 7: MyPlaceholder + /// - 15: MyPlaceholder + /// + /// Notice in the example above that the final placeholder index is greater + /// than the total length of the [text] `String`. + AttributedText([ + String? text, AttributedSpans? spans, - }) : spans = spans ?? AttributedSpans(); + Map? placeholders, + ]) : _text = text ?? "", + spans = spans ?? AttributedSpans() { + // Sort the placeholders map so that we can always assume the entries + // iterate in the placeholder content order. This knowledge is relied upon + // in multiple places in this class. + this.placeholders = Map.fromEntries( + placeholders?.entries.sorted((a, b) => a.key - b.key) ?? [], + ); + + assert(() { + // ^ Run this in an assert with a callback so that the validation doesn't run in + // production and cost processor cycles. + _validatePlaceholderIndices(); + return true; + }()); + + if (this.placeholders.isEmpty) { + // There aren't any placeholders, so text with placeholders is the same as + // text without placeholders. + _textWithPlaceholders = _text; + } else { + // Create a 2nd plain text representation that includes stand-in characters + // for placeholders. + final buffer = StringBuffer(); + int start = 0; + int insertedPlaceholders = 0; + + for (final entry in this.placeholders.entries) { + final textSegment = _text.substring(start - insertedPlaceholders, entry.key - insertedPlaceholders); + buffer.write(textSegment); + start += textSegment.length; + + buffer.write(placeholderCharacter); + start += 1; + + insertedPlaceholders += 1; + } + if (start - insertedPlaceholders < _text.length) { + buffer.write(_text.substring(start - insertedPlaceholders, _text.length)); + } + + _textWithPlaceholders = buffer.toString(); + } + } + + void _validatePlaceholderIndices() { + // Ensure that none of the placeholders have negative indices. + assert( + placeholders.entries.where((entry) => entry.key < 0).isEmpty, + "All placeholders must have indices >= 0", + ); + + // Ensure that none of the placeholders sit beyond the end of the text and other + // placeholders. + int maxAllowableIndex = _text.length; + for (final entry in placeholders.entries) { + if (entry.key > maxAllowableIndex) { + throw AssertionError("Invalid placeholder index. The index is too large. ${entry.key} -> ${entry.value}."); + } + + maxAllowableIndex += 1; + } + } void dispose() { _listeners.clear(); } /// The text that this [AttributedText] attributes. - final String text; + @Deprecated("Use toPlainText() instead, so you can choose whether to include placeholder characters") + String get text => _text; + final String _text; + + late final String _textWithPlaceholders; + + /// Returns the character or placeholder at offset zero. + Object get first => placeholders[0] ?? _textWithPlaceholders[0]; - /// The attributes applied to [text]. + /// Returns the character or placeholder at the given [offset]. + Object operator [](int offset) => placeholders[offset] ?? _textWithPlaceholders[offset]; + + /// Returns the character or placeholder at the end of this `AttributedText`. + Object get last => placeholders[length - 1] ?? _textWithPlaceholders[length - 1]; + + /// Returns a plain-text version of this `AttributedText`. + /// + /// Plain text has no attributions or placeholder objects. + /// + /// If [includePlaceholders] is `true`, special characters will be inserted + /// at every text offset where there is currently a placeholder object. By + /// default, the special character is [placeholderCharacter]. To use a different + /// character, provide a [replacementCharacter]. + /// + /// if [includePlaceholders] is `false`, placeholders will be replaced + /// with nothing. In that case, the returned `String` will be shorter than + /// [length] with a difference equal to the number of placeholders in + /// this [AttributedText]. + String toPlainText({ + bool includePlaceholders = true, + String replacementCharacter = placeholderCharacter, + }) { + if (includePlaceholders) { + if (replacementCharacter != placeholderCharacter) { + // The caller wants to use a non-standard character to represent + // placeholders. Do a replace-all and return the result. + return _textWithPlaceholders.replaceAll(placeholderCharacter, replacementCharacter); + } + + return _textWithPlaceholders; + } + + return _text; + } + + /// Placeholders that represent non-text content, e.g., inline images, that + /// should appear in the rendered text. + /// + /// The entries in this map are in content-order, e.g., the entry for a placeholder + /// at index 3 comes before an entry whose placeholder is at index 5. + /// + /// In terms of [length], each placeholder is treated as a single character. + late final Map placeholders; + + /// Returns the `length` of this [AttributedText], which includes the length + /// of the plain text `String`, and the number of [placeholders]. + int get length => _text.length + placeholders.length; + + /// Returns `true` if the [length] of this [AttributedText] is zero. + /// + /// `isEmpty` is `true` if and only if both the plain text and the + /// placeholders are empty. + bool get isEmpty => _text.isEmpty && placeholders.isEmpty; + + /// Returns `true` if the [length] of this [AttributedText] is greater than zero. + /// + /// `isNotEmpty` is `true` if the plain text is non-empty, or if the + /// placeholders are non-empty, or both. + bool get isNotEmpty => _text.isNotEmpty || placeholders.isNotEmpty; + + /// The attributes applied across the plain text and [placeholders]. final AttributedSpans spans; final _listeners = {}; @@ -106,7 +266,7 @@ class AttributedText { while (index <= range.end && attributionsThroughout.isNotEmpty) { final missingAttributions = {}; for (final attribution in attributionsThroughout) { - if (!hasAttributionAt(index)) { + if (!hasAttributionAt(index, attribution: attribution)) { missingAttributions.add(attribution); } } @@ -117,6 +277,19 @@ class AttributedText { return attributionsThroughout; } + /// Returns all spans in this [AttributedText] for the given [attributions]. + Set getAttributionSpans(Set attributions) => getAttributionSpansInRange( + attributionFilter: (a) => attributions.contains(a), + range: SpanRange(0, length), + ); + + /// Returns all spans in this [AttributedText], for attributions that are + /// selected by the given [filter]. + Set getAttributionSpansByFilter(AttributionFilter filter) => getAttributionSpansInRange( + attributionFilter: filter, + range: SpanRange(0, length), + ); + /// Returns spans for each attribution that (at least partially) appear /// within the given [range], as selected by [attributionFilter]. /// @@ -126,7 +299,7 @@ class AttributedText { /// returned, including the area that sits outside the given [range]. /// /// To obtain attribution spans that are cut down and limited to the - /// given [range], pass [true] for [resizeSpansToFitInRange]. This setting + /// given [range], pass `true` for [resizeSpansToFitInRange]. This setting /// only effects the returned spans, it does not alter the attributions /// within this [AttributedText]. Set getAttributionSpansInRange({ @@ -151,8 +324,50 @@ class AttributedText { /// Adds the given [attribution] to all characters within the given /// [range], inclusive. - void addAttribution(Attribution attribution, SpanRange range) { - spans.addAttribution(newAttribution: attribution, start: range.start, end: range.end); + /// + /// The effect of adding an attribution is straight forward when the text doesn't + /// contain any other attributions with the same ID. However, there are various + /// situations where the [attribution] can't necessarily co-exist with other + /// attribution spans that already exist in the text. + /// + /// Attribution overlaps can take one of two forms: mergeable or conflicting. + /// + /// ## Mergeable Attribution Spans + /// An example of a mergeable overlap is where two bold spans overlap each + /// other. All bold attributions are interchangeable, so when two bold spans + /// overlap, those spans can be merged together into a single span. + /// + /// However, mergeable overlapping spans are not automatically merged. Instead, + /// this decision is left to the user of this class. If you want [AttributedText] to + /// merge overlapping mergeable spans, pass `true` for [autoMerge]. Otherwise, + /// if [autoMerge] is `false`, an exception is thrown when two mergeable spans + /// overlap each other. + /// + /// + /// ## Conflicting Attribution Spans + /// An example of a conflicting overlap is where a black text color overlaps a red + /// text color. Text is either black, OR red, but never both. Therefore, the black + /// attribution cannot co-exist with the red attribution. Something must be done + /// to resolve this. + /// + /// There are two possible ways to handle conflicting overlaps. The new attribution + /// can overwrite the existing attribution where they overlap. Or, an exception can be + /// thrown. To overwrite the existing attribution with the new attribution, pass `true` + /// for [overwriteConflictingSpans]. Otherwise, if [overwriteConflictingSpans] + /// is `false`, an exception is thrown. + void addAttribution( + Attribution attribution, + SpanRange range, { + bool autoMerge = true, + bool overwriteConflictingSpans = false, + }) { + spans.addAttribution( + newAttribution: attribution, + start: range.start, + end: range.end, + autoMerge: autoMerge, + overwriteConflictingSpans: overwriteConflictingSpans, + ); _notifyListeners(); } @@ -163,6 +378,16 @@ class AttributedText { _notifyListeners(); } + /// Returns a copy of this [AttributedText], replacing the existing + /// [AttributedSpans] with the given [newSpans]. + AttributedText replaceAttributions(AttributedSpans newSpans) { + return AttributedText( + _text, + newSpans, + Map.from(placeholders), + ); + } + /// Removes all attributions within the given [range]. void clearAttributions(SpanRange range) { // TODO: implement this capability within AttributedSpans @@ -188,28 +413,81 @@ class AttributedText { _notifyListeners(); } - /// Copies all text and attributions from [startOffset] to - /// [endOffset], inclusive, and returns them as a new [AttributedText]. + /// Copies all text and attributions from [range.start] to [range.end] (exclusive), + /// and returns them as a new [AttributedText]. + AttributedText copyTextInRange(SpanRange range) => copyText(range.start, range.end); + + /// Copies all text, attributions, and placeholders from [startOffset] to + /// [endOffset], exclusive, and returns them as a new [AttributedText]. AttributedText copyText(int startOffset, [int? endOffset]) { _log.fine('start: $startOffset, end: $endOffset'); + final placeholdersBeforeStartOffset = placeholders.entries.where((entry) => entry.key < startOffset); + final textStartCopyOffset = startOffset - placeholdersBeforeStartOffset.length; + + final placeholdersAfterStartBeforeEndOffset = placeholders.entries.where( + (entry) => startOffset <= entry.key && entry.key < (endOffset ?? length), + ); + final textEndCopyOffset = + (endOffset ?? length) - placeholdersBeforeStartOffset.length - placeholdersAfterStartBeforeEndOffset.length; + + // The span marker offsets are based on the text with placeholders, so we need + // to copy the text with placeholders to ensure the span markers are correct. + final textWithPlaceholders = toPlainText(); + // Note: -1 because copyText() uses an exclusive `start` and `end` but // _copyAttributionRegion() uses an inclusive `start` and `end`. - final startCopyOffset = startOffset < text.length ? startOffset : text.length - 1; + final startCopyOffset = startOffset < textWithPlaceholders.length ? startOffset : textWithPlaceholders.length - 1; int endCopyOffset; if (endOffset == startOffset) { endCopyOffset = startCopyOffset; } else if (endOffset != null) { endCopyOffset = endOffset - 1; } else { - endCopyOffset = text.length - 1; + endCopyOffset = textWithPlaceholders.length - 1; } _log.fine('offsets, start: $startCopyOffset, end: $endCopyOffset'); + // Create placeholders for the copied region. The indices of the placeholders + // need to be reduced based on the text/placeholders cut out from the + // beginning of this AttributedText. + final copiedPlaceholders = {}; + for (final existingPlaceholder in placeholdersAfterStartBeforeEndOffset) { + copiedPlaceholders[existingPlaceholder.key - startOffset] = existingPlaceholder.value; + } + return AttributedText( - text: text.substring(startOffset, endOffset), - spans: spans.copyAttributionRegion(startCopyOffset, endCopyOffset), + _text.substring(textStartCopyOffset, textEndCopyOffset), + spans.copyAttributionRegion(startCopyOffset, endCopyOffset), + copiedPlaceholders, + ); + } + + /// Returns a plain-text substring, from [range.start] to [range.end] (exclusive). + /// + /// {@macro attributed_text_substring_range} + String substringInRange(SpanRange range) => substring(range.start, range.end); + + /// Returns a plain-text substring, from [start] to [end] (exclusive), or the end of + /// this [AttributedText] if [end] isn't provided. + /// + /// {@template attributed_text_substring_range} + /// [AttributedText] can contain placeholders, each of which take up one character of length. + /// The given [range] is interpreted as a range within this [AttributedText]. If placeholders + /// appear within that range, then the length of the returned `String` will be less than the + /// length of the range. + /// {@endtemplate} + String substring(int start, [int? end]) { + final placeholdersBeforeStartOffset = placeholders.entries.where((entry) => entry.key < start); + final textStartCopyOffset = start - placeholdersBeforeStartOffset.length; + + final placeholdersAfterStartBeforeEndOffset = placeholders.entries.where( + (entry) => start <= entry.key && entry.key < (end ?? length), ); + final textEndCopyOffset = + (end ?? length) - placeholdersBeforeStartOffset.length - placeholdersAfterStartBeforeEndOffset.length; + + return _text.substring(textStartCopyOffset, textEndCopyOffset); } /// Returns a copy of this [AttributedText] with the [other] text @@ -217,25 +495,32 @@ class AttributedText { AttributedText copyAndAppend(AttributedText other) { _log.fine('our attributions before pushing them:'); _log.fine(spans.toString()); - if (other.text.isEmpty) { + + if (other.isEmpty) { _log.fine('`other` has no text. Returning a direct copy of ourselves.'); return AttributedText( - text: text, - spans: spans.copy(), + _text, + spans.copy(), + Map.from(placeholders), ); } - if (text.isEmpty) { + + if (isEmpty) { _log.fine('our `text` is empty. Returning a direct copy of the `other` text.'); return AttributedText( - text: other.text, - spans: other.spans.copy(), + other._text, + other.spans.copy(), + Map.from(other.placeholders), ); } - final newSpans = spans.copy()..addAt(other: other.spans, index: text.length); return AttributedText( - text: text + other.text, - spans: newSpans, + _text + other._text, + spans.copy()..addAt(other: other.spans, index: length), + { + ...placeholders, + ...other.placeholders.map((offset, placeholder) => MapEntry(offset + length, placeholder)), + }, ); } @@ -273,10 +558,8 @@ class AttributedText { _log.fine('endText: $endText'); _log.fine('creating new attributed text for insertion'); - final insertedText = AttributedText( - text: textToInsert, - ); - final insertTextRange = SpanRange(start: 0, end: textToInsert.length - 1); + final insertedText = AttributedText(textToInsert); + final insertTextRange = SpanRange(0, textToInsert.length - 1); for (dynamic attribution in applyAttributions) { insertedText.addAttribution(attribution, insertTextRange); } @@ -286,9 +569,29 @@ class AttributedText { return startText.copyAndAppend(insertedText).copyAndAppend(endText); } - /// Copies this [AttributedText] and removes a region of text - /// and attributions from [startOffset], inclusive, - /// to [endOffset], exclusive. + AttributedText insertPlaceholders(Map placeholders) { + var finalText = this; + for (final entry in placeholders.entries) { + finalText = finalText.insertPlaceholder(entry.key, entry.value); + } + return finalText; + } + + AttributedText insertPlaceholder(int index, Object placeholder) { + return AttributedText(_text, spans.copy(), { + // Insert existing placeholders that come before the new placeholder. + ...Map.fromEntries(placeholders.entries.where((entry) => entry.key < index)), + // Insert the new placeholder. + index: placeholder, + // Push back all later placeholders by 1 unit, because of the new placeholder. + ...Map.fromEntries( + placeholders.entries.where((entry) => entry.key >= index).map((entry) => MapEntry(entry.key + 1, entry.value)), + ), + }); + } + + /// Copies this [AttributedText] and removes a region of text and attributions + /// from [startOffset], inclusive, to [endOffset], exclusive. AttributedText removeRegion({ required int startOffset, required int endOffset, @@ -296,8 +599,7 @@ class AttributedText { _log.fine('Removing text region from $startOffset to $endOffset'); _log.fine('initial attributions:'); _log.fine(spans.toString()); - final reducedText = (startOffset > 0 ? text.substring(0, startOffset) : '') + - (endOffset < text.length ? text.substring(endOffset) : ''); + final reducedText = substring(0, startOffset) + substring(endOffset, length); AttributedSpans contractedAttributions = spans.copy() ..contractAttributions( @@ -309,14 +611,42 @@ class AttributedText { _log.fine(contractedAttributions.toString()); return AttributedText( - text: reducedText, - spans: contractedAttributions, + reducedText, + contractedAttributions, + Map.fromEntries( + placeholders.entries + .where((entry) => entry.key < startOffset || endOffset <= entry.key) // + .map( + (entry) => entry.key >= endOffset // + ? MapEntry(entry.key - (endOffset - startOffset), entry.value) + : entry, + ), + ), ); } + /// Visits all attributions in this [AttributedText] by calling [visitor] whenever + /// an attribution begins or ends. + /// + /// If multiple attributions begin or end at the same index, then all of those attributions + /// are reported together. + /// + /// **Only Reports Beginnings and Endings:** + /// + /// This visitation method does not report all applied attributions at a given index. It + /// only reports attributions that begin or end at a specific index. + /// + /// For example: + /// + /// Bold: |xxxxxxxxxxxx| + /// Italics: |------xxxxxx| + /// + /// Bold is attributed throughout the range. Italics begins at index `6`. When [visitor] + /// is notified about italics beginning at `6`, visitor is NOT notified that bold applies + /// at that same index. void visitAttributions(AttributionVisitor visitor) { - final startingAttributions = Set(); - final endingAttributions = Set(); + final startingAttributions = {}; + final endingAttributions = {}; int currentIndex = -1; visitor.onVisitBegin(); @@ -350,21 +680,80 @@ class AttributedText { visitor.onVisitEnd(); } + /// Visits attributions in this [AttributedText], reporting every changing group of + /// attributions to the given [visitor]. + /// + /// See [computeAttributionSpans] for an example. + /// + /// See also: + /// + /// * [visitAttributions], to visit attributions markers instead of attribution groups. + /// * [computeAttributionSpans], to work with a list of [MultiAttributionSpan]s instead + /// of visiting each span with a callback. + void visitAttributionSpans(AttributionSpanVisitor visitor) { + final collapsedSpans = computeAttributionSpans(); + for (final span in collapsedSpans) { + visitor(span); + } + } + + /// Collapses all attribution markers down into a series of attribution groups, + /// starting at the beginning of this [AttributedText], until the end. + /// + /// A new group of attributions begin wherever an attribution begins or ends. + /// + /// For example: + /// + /// Bold: |----xxxxxxxxxxxx------------| + /// Italics: |-------xxxxxxxxxxxxx--------| + /// Strikethru: |-----------xxxxxxxxxxxxx----| + /// + /// Given the above attributions, the given [visitor] would be notified of the following + /// groups: + /// + /// 1. [0, 4] - No attributions + /// 2. [5, 8] - Bold + /// 3. [9, 12] - Bold, Italics + /// 4. [13, 16] - Bold, Italics, Strikethru + /// 5. [17, 20] - Italics, Strikethru + /// 6. [21, 24] - Strikethru + /// 7. [25, 28] - No attributions + /// + /// Attribution groups are useful when computing all style variations for [AttributedText]. + Iterable computeAttributionSpans() { + return spans.collapseSpans(contentLength: length); + } + + /// Returns a copy of this [AttributedText]. + AttributedText copy() { + return AttributedText( + _text, + spans.copy(), + Map.from(placeholders), + ); + } + @override bool operator ==(Object other) { return identical(this, other) || - other is AttributedText && runtimeType == other.runtimeType && text == other.text && spans == other.spans; + other is AttributedText && + runtimeType == other.runtimeType && + _text == other._text && + spans == other.spans && + (const DeepCollectionEquality()).equals(placeholders, other.placeholders); } @override - int get hashCode => text.hashCode ^ spans.hashCode; + int get hashCode => _text.hashCode ^ spans.hashCode ^ placeholders.hashCode; @override String toString() { - return '[AttributedText] - "$text"\n' + spans.toString(); + return '[AttributedText] - "$_text"\n$spans\n$placeholders'; } } +typedef AttributionSpanVisitor = void Function(MultiAttributionSpan span); + /// Visits every [index] in the the given [AttributedText] which has at least /// one start or end marker, passing the attributions that start or end at the [index]. /// @@ -422,7 +811,12 @@ class CallbackAttributionVisitor implements AttributionVisitor { } @override - void visitAttributions(AttributedText fullText, int index, Set startingAttributions, Set endingAttributions) { + void visitAttributions( + AttributedText fullText, + int index, + Set startingAttributions, + Set endingAttributions, + ) { _onVisitAttributions(fullText, index, startingAttributions, endingAttributions); } diff --git a/attributed_text/lib/src/logging.dart b/attributed_text/lib/src/logging.dart index 20c59305a2..d5af7fff75 100644 --- a/attributed_text/lib/src/logging.dart +++ b/attributed_text/lib/src/logging.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'package:logging/logging.dart' as logging; class LogNames { @@ -16,7 +18,7 @@ void initLoggers(logging.Level level, Set loggers) { if (level == logging.Level.OFF) { return; } - + logging.hierarchicalLoggingEnabled = true; for (final logger in loggers) { diff --git a/attributed_text/lib/src/span_range.dart b/attributed_text/lib/src/span_range.dart index 57e526588d..74e87f6c14 100644 --- a/attributed_text/lib/src/span_range.dart +++ b/attributed_text/lib/src/span_range.dart @@ -3,7 +3,7 @@ /// This was copied from Flutter so that attributed_text could be distributed /// as a Dart package, instead of a Flutter package. class SpanRange { - /// Creates a text range. + /// Creates a span range. /// /// The [start] and [end] arguments must not be null. Both the [start] and /// [end] must either be greater than or equal to zero or both exactly -1. @@ -11,15 +11,15 @@ class SpanRange { /// The text included in the range includes the character at [start], but not /// the one at [end]. /// - /// Instead of creating an empty text range, consider using the [empty] + /// Instead of creating an empty span range, consider using the [empty] /// constant. - const SpanRange({ - required this.start, - required this.end, - }) : assert(start >= -1), + const SpanRange( + this.start, + this.end, + ) : assert(start >= -1), assert(end >= -1); - /// A text range that starts and ends at offset. + /// A span range that starts and ends at offset. /// /// The [offset] argument must be non-null and greater than or equal to -1. const SpanRange.collapsed(int offset) @@ -27,8 +27,8 @@ class SpanRange { start = offset, end = offset; - /// A text range that contains nothing and is not in the text. - static const SpanRange empty = SpanRange(start: -1, end: -1); + /// A span range that contains nothing and is not in the text. + static const SpanRange empty = SpanRange(-1, -1); /// The index of the first character in the range. /// @@ -77,5 +77,5 @@ class SpanRange { int get hashCode => start.hashCode ^ end.hashCode; @override - String toString() => 'TextRange(start: $start, end: $end)'; + String toString() => 'SpanRange(start: $start, end: $end)'; } diff --git a/attributed_text/lib/src/test_tools.dart b/attributed_text/lib/src/test_tools.dart index b65de393a8..7887c277ba 100644 --- a/attributed_text/lib/src/test_tools.dart +++ b/attributed_text/lib/src/test_tools.dart @@ -6,7 +6,9 @@ import 'attribution.dart'; class ExpectedSpans { static const bold = NamedAttribution('bold'); static const italics = NamedAttribution('italics'); + static const underline = NamedAttribution('underline'); static const strikethrough = NamedAttribution('strikethrough'); + static const hashTag = NamedAttribution('hashTag'); ExpectedSpans( List spanTemplates, @@ -60,6 +62,7 @@ class ExpectedSpans { } if (!spans.hasAttributionAt(characterIndex, attribution: namedAttribution)) { + // ignore: avoid_print print("SPAN MISMATCH: missing $namedAttribution at $characterIndex"); } expect(spans.hasAttributionAt(characterIndex, attribution: namedAttribution), true); diff --git a/attributed_text/pubspec.yaml b/attributed_text/pubspec.yaml index 29a11e764f..e4eca427b1 100644 --- a/attributed_text/pubspec.yaml +++ b/attributed_text/pubspec.yaml @@ -1,15 +1,15 @@ name: attributed_text description: Text with metadata spans for easy text editing and styling. -version: 0.2.0 +version: 0.3.2 homepage: https://github.com/superlistapp/super_editor environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: characters: ^1.2.0 collection: ^1.15.0 - logging: ^1.0.1 + logging: ^1.3.0 meta: ^1.7.0 test: ^1.19.4 diff --git a/attributed_text/test/attributed_spans_test.dart b/attributed_text/test/attributed_spans_test.dart index adc5ce286d..3a8de3f95b 100644 --- a/attributed_text/test/attributed_spans_test.dart +++ b/attributed_text/test/attributed_spans_test.dart @@ -157,95 +157,142 @@ void main() { ); }); + test('hasAttributionsWithin can look for multiple attributions at the same time', () { + final spans = AttributedSpans() + ..addAttribution(newAttribution: ExpectedSpans.bold, start: 0, end: 8) + ..addAttribution(newAttribution: ExpectedSpans.italics, start: 1, end: 5) + ..addAttribution(newAttribution: ExpectedSpans.strikethrough, start: 5, end: 9); + + ExpectedSpans( + [ + 'bbbbbbbbb_', + '_iiiii____', + '_____sssss', + ], + ).expectSpans(spans); + + expect( + spans.hasAttributionsWithin(attributions: { + ExpectedSpans.bold, + ExpectedSpans.italics, + ExpectedSpans.strikethrough, + }, start: 0, end: 9), + true, + ); + + expect( + spans.hasAttributionsWithin(attributions: { + ExpectedSpans.bold, + ExpectedSpans.italics, + }, start: 0, end: 9), + true, + ); + + expect( + spans.hasAttributionsWithin(attributions: { + ExpectedSpans.bold, + ExpectedSpans.italics, + }, start: 0, end: 4), + true); + + expect( + spans.hasAttributionsWithin(attributions: { + ExpectedSpans.bold, + ExpectedSpans.strikethrough, + }, start: 0, end: 4), + false, + ); + }); + group('getAttributedRange', () { test('returns the range of a single attribution for an offset in the middle of a span', () { final spans = AttributedSpans( attributions: [ - SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end), ], ); final range = spans.getAttributedRange({ExpectedSpans.bold}, 5); - expect(range, SpanRange(start: 4, end: 9)); + expect(range, const SpanRange(4, 9)); }); test('returns the range of a single attribution for an offset at the beginning of a span', () { final spans = AttributedSpans( attributions: [ - SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end), ], ); final range = spans.getAttributedRange({ExpectedSpans.bold}, 4); - expect(range, SpanRange(start: 4, end: 9)); + expect(range, const SpanRange(4, 9)); }); test('returns the range of a single attribution for an offset at the end of a span', () { final spans = AttributedSpans( attributions: [ - SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end), ], ); final range = spans.getAttributedRange({ExpectedSpans.bold}, 9); - expect(range, SpanRange(start: 4, end: 9)); + expect(range, const SpanRange(4, 9)); }); test('returns the range for multiple attributions for an offset in the middle of the overlapping range', () { final spans = AttributedSpans( attributions: [ - SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end), ], ); final range = spans.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics}, 5); - expect(range, SpanRange(start: 4, end: 7)); + expect(range, const SpanRange(4, 7)); }); test('returns the range for multiple attributions for an offset at the beginning of the overlapping range', () { final spans = AttributedSpans( attributions: [ - SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end), ], ); final range = spans.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics}, 4); - expect(range, SpanRange(start: 4, end: 7)); + expect(range, const SpanRange(4, 7)); }); test('returns the range for multiple attributions for an offset at the end of the overlapping range', () { final spans = AttributedSpans( attributions: [ - SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end), ], ); final range = spans.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics}, 7); - expect(range, SpanRange(start: 4, end: 7)); + expect(range, const SpanRange(4, 7)); }); test('throws when given an empty attribution set', () { @@ -257,8 +304,8 @@ void main() { test('throws when any attribution is not present at the given offset', () { final spans = AttributedSpans( attributions: [ - SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.bold, offset: 10, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 10, markerType: SpanMarkerType.end), ], ); @@ -554,10 +601,211 @@ void main() { newAttribution: _LinkAttribution(url: 'https://pub.dev'), start: 4, end: 12, + overwriteConflictingSpans: false, ); }, throwsA(isA())); }); + test('overwrites incompatible attributions at the beginning of the span', () { + // Starting value: + // |aaaaaaa| + // + // Ending value: + // |-----aa| + // |bbbbb--| + + final spans = AttributedSpans( + attributions: _createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://flutter.dev'), + startOffset: 0, + endOffset: 6, + ), + ); + + // Add an overlapping link at the beginning. + spans.addAttribution( + newAttribution: _LinkAttribution(url: 'https://pub.dev'), + start: 0, + end: 4, + ); + + expect( + spans, + AttributedSpans( + attributions: [ + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://pub.dev'), + startOffset: 0, + endOffset: 4, + ), + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://flutter.dev'), + startOffset: 5, + endOffset: 6, + ), + ], + ), + ); + }); + + test('splits incompatible attributions at the middle of the text', () { + // Starting value: + // |aaaaaaa----------| + // |----------bbbbbbb| + // + // Ending value: + // |aaaa-------------| + // |-------------bbbb| + // |----ccccccccc----| + + final spans = AttributedSpans(attributions: [ + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://flutter.dev'), + startOffset: 0, + endOffset: 6, + ), + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://pub.dev'), + startOffset: 10, + endOffset: 16, + ), + ]); + + // Add an overlapping at the middle. + spans.addAttribution( + newAttribution: _LinkAttribution(url: 'https://google.com'), + start: 4, + end: 12, + ); + + expect( + spans, + AttributedSpans( + attributions: [ + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://flutter.dev'), + startOffset: 0, + endOffset: 3, + ), + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://google.com'), + startOffset: 4, + endOffset: 12, + ), + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://pub.dev'), + startOffset: 13, + endOffset: 16, + ), + ], + ), + ); + }); + + test('overwrites incompatible attributions at the end of the span', () { + // Starting value: + // |aaaaaaa------| + // + // Ending value: + // |aaaa---------| + // |----bbbbbbbbb| + + final spans = AttributedSpans( + attributions: _createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://flutter.dev'), + startOffset: 0, + endOffset: 6, + ), + ); + + // Add an overlapping link at the end. + spans.addAttribution( + newAttribution: _LinkAttribution(url: 'https://pub.dev'), + start: 4, + end: 12, + ); + + expect( + spans, + AttributedSpans( + attributions: [ + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://flutter.dev'), + startOffset: 0, + endOffset: 3, + ), + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://pub.dev'), + startOffset: 4, + endOffset: 12, + ), + ], + ), + ); + }); + + test('overwrites multiple incompatible attributions at the midle of the text', () { + // Starting value: + // |aaaaaa--------------------| + // |----------bbbbbb----------| + // |--------------------cccccc| + // + // Ending value: + // |aaaa----------------------| + // |-----------------------ccc| + // |----ddddddddddddddddddd---| + + final spans = AttributedSpans( + attributions: [ + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://flutter.dev'), + startOffset: 0, + endOffset: 5, + ), + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://pub.dev'), + startOffset: 10, + endOffset: 15, + ), + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://google.com'), + startOffset: 20, + endOffset: 25, + ), + ], + ); + + // Add a link overlapping with all existing spans. + spans.addAttribution( + newAttribution: _LinkAttribution(url: 'https://youtube.com'), + start: 4, + end: 22, + ); + + expect( + spans, + AttributedSpans( + attributions: [ + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://flutter.dev'), + startOffset: 0, + endOffset: 3, + ), + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://youtube.com'), + startOffset: 4, + endOffset: 22, + ), + ..._createSpanMarkersForAttribution( + attribution: _LinkAttribution(url: 'https://google.com'), + startOffset: 23, + endOffset: 25, + ) + ], + ), + ); + }); + test('compatible attributions are merged', () { final spans = AttributedSpans(); @@ -760,8 +1008,30 @@ class _LinkAttribution implements Attribution { } @override - bool operator ==(Object other) => identical(this, other) || other is _LinkAttribution && runtimeType == other.runtimeType && url == other.url; + bool operator ==(Object other) => + identical(this, other) || other is _LinkAttribution && runtimeType == other.runtimeType && url == other.url; @override int get hashCode => url.hashCode; } + +/// Creates start and end markers for the [attribution], starting at [startOffset] +/// and ending at [endOffset]. +List _createSpanMarkersForAttribution({ + required Attribution attribution, + required int startOffset, + required int endOffset, +}) { + return [ + SpanMarker( + attribution: attribution, + offset: startOffset, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: attribution, + offset: endOffset, + markerType: SpanMarkerType.end, + ), + ]; +} diff --git a/attributed_text/test/attributed_text_placeholders_test.dart b/attributed_text/test/attributed_text_placeholders_test.dart new file mode 100644 index 0000000000..562fd4ccdb --- /dev/null +++ b/attributed_text/test/attributed_text_placeholders_test.dart @@ -0,0 +1,809 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:test/test.dart'; + +void main() { + group("AttributedAttributed > placeholders >", () { + group("construction >", () { + test("reports invalid placeholder positions", () { + // Index less than zero. + expect( + () => AttributedText("Hello, World!", null, { + -1: const _FakePlaceholder("bad-index"), + }), + throwsA(isA()), + ); + + // Index beyond length. + expect( + () => AttributedText("Hello, World!", null, { + 14: const _FakePlaceholder("bad-index"), + }), + throwsA(isA()), + ); + }); + + test("is order agnostic", () { + // Ensure that constructors don't below up when the placeholder + // entry order isn't the same as content order, and also ensure that + // equality doesn't care about the map order. + + final order1 = AttributedText("Hello, World!", null, { + 3: const _FakePlaceholder("first"), + 6: const _FakePlaceholder("second"), + 9: const _FakePlaceholder("third"), + }); + + final order2 = AttributedText("Hello, World!", null, { + 6: const _FakePlaceholder("second"), + 3: const _FakePlaceholder("first"), + 9: const _FakePlaceholder("third"), + }); + + final order3 = AttributedText("Hello, World!", null, { + 6: const _FakePlaceholder("second"), + 9: const _FakePlaceholder("third"), + 3: const _FakePlaceholder("first"), + }); + + final order4 = AttributedText("Hello, World!", null, { + 9: const _FakePlaceholder("third"), + 6: const _FakePlaceholder("second"), + 3: const _FakePlaceholder("first"), + }); + + expect(order1, equals(order2)); + expect(order1, equals(order3)); + expect(order1, equals(order4)); + }); + }); + + group("length >", () { + test("only a single placeholder", () { + expect( + AttributedText( + "", + null, + { + 0: const _FakePlaceholder("only"), + }, + ).length, + 1, + ); + }); + + test("only multiple placeholders", () { + expect( + AttributedText( + "", + null, + { + 0: const _FakePlaceholder("one"), + 1: const _FakePlaceholder("two"), + 2: const _FakePlaceholder("three"), + }, + ).length, + 3, + ); + }); + + test("text with a single placeholder", () { + expect( + AttributedText( + "Hello, world! ", + null, + { + 14: const _FakePlaceholder("trailing"), + }, + ).length, + 15, + ); + }); + + test("text with multiple placeholders", () { + expect( + AttributedText( + "Hello, world! ", + null, + { + 0: const _FakePlaceholder("leading"), + 6: const _FakePlaceholder("middle"), + 16: const _FakePlaceholder("trailing"), + }, + ).length, + 17, + ); + }); + }); + + test("reports characters and placeholders at indices", () { + final text1 = AttributedText("HelloWorld", null, { + 0: const _FakePlaceholder("one"), + 6: const _FakePlaceholder("two"), + 12: const _FakePlaceholder("three"), + }); + + expect(text1.first, const _FakePlaceholder("one")); + expect(text1[1], "H"); + expect(text1[6], const _FakePlaceholder("two")); + expect(text1[11], "d"); + expect(text1.last, const _FakePlaceholder("three")); + + final text2 = AttributedText("Hello World", null, { + 0: const _FakePlaceholder("one"), + }); + expect(text2[11], "d"); + expect(text2.last, "d"); + }); + + test("reports plain text value", () { + expect( + AttributedText("", null, { + 0: const _FakePlaceholder("only"), + }).toPlainText(replacementCharacter: "�"), + "�", + ); + + expect( + AttributedText("", null, { + 0: const _FakePlaceholder("one"), + 1: const _FakePlaceholder("two"), + 2: const _FakePlaceholder("three"), + }).toPlainText(replacementCharacter: "�"), + "���", + ); + + expect( + AttributedText("HelloWorld", null, { + 0: const _FakePlaceholder("one"), + 6: const _FakePlaceholder("two"), + 12: const _FakePlaceholder("three"), + }).toPlainText(replacementCharacter: "�"), + "�Hello�World�", + ); + }); + + group("plain text substring >", () { + test("when placeholders are not in range", () { + expect( + AttributedText("Hello, world!", null, { + 0: const _FakePlaceholder("leading"), + 6: const _FakePlaceholder("middle"), + 15: const _FakePlaceholder("trailing"), + }).substring(1, 6), + "Hello", + ); + }); + + test("with placeholders in the range", () { + expect( + AttributedText("Hello, world!", null, { + 0: const _FakePlaceholder("leading"), + 6: const _FakePlaceholder("middle"), + 15: const _FakePlaceholder("trailing"), + }).substring(0, 7), + "Hello", + ); + }); + }); + + group("equality >", () { + test("only a single placeholder", () { + expect( + AttributedText("", null, { + 0: const _FakePlaceholder("only"), + }), + equals( + AttributedText("", null, { + 0: const _FakePlaceholder("only"), + }), + ), + ); + + expect( + AttributedText("", null, { + 0: const _FakePlaceholder("only"), + }), + isNot( + equals( + AttributedText("", null), + ), + ), + ); + + expect( + AttributedText("", null), + isNot( + equals( + AttributedText("", null, { + 0: const _FakePlaceholder("only"), + }), + ), + ), + ); + }); + + test("some of multiple placeholders", () { + expect( + AttributedText("", null, { + 0: const _FakePlaceholder("one"), + 1: const _FakePlaceholder("two"), + 2: const _FakePlaceholder("three"), + }), + equals( + AttributedText("", null, { + 0: const _FakePlaceholder("one"), + 1: const _FakePlaceholder("two"), + 2: const _FakePlaceholder("three"), + }), + ), + ); + + expect( + AttributedText("", null), + isNot( + equals( + AttributedText("", null, { + 0: const _FakePlaceholder("one"), + 1: const _FakePlaceholder("two"), + 2: const _FakePlaceholder("three"), + }), + ), + ), + ); + + expect( + AttributedText("", null, { + 0: const _FakePlaceholder("one"), + 1: const _FakePlaceholder("two"), + 2: const _FakePlaceholder("three"), + }), + isNot( + equals( + AttributedText("", null), + ), + ), + ); + }); + + test("some text and a placeholder", () { + expect( + AttributedText("Hello, world!", null, { + 5: const _FakePlaceholder("middle"), + }), + equals( + AttributedText("Hello, world!", null, { + 5: const _FakePlaceholder("middle"), + }), + ), + ); + + expect( + AttributedText("Hello, world!", null), + isNot( + equals( + AttributedText("Hello, world!", null, { + 5: const _FakePlaceholder("middle"), + }), + ), + ), + ); + + expect( + AttributedText("Hello, world!", null, { + 5: const _FakePlaceholder("middle"), + }), + isNot( + equals( + AttributedText("Hello, world!", null), + ), + ), + ); + }); + }); + + group("attribution queries >", () { + test('finds spans that exceed plain text length', () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 12, markerType: SpanMarkerType.end), + ], + ), + { + 11: const _FakePlaceholder("trailing1"), + 12: const _FakePlaceholder("trailing2"), + }, + ); + + final ranges = attributedText.getAttributionSpans({ExpectedSpans.bold}); + + expect(ranges.length, 1); + expect( + ranges, + [ + const AttributionSpan(attribution: ExpectedSpans.bold, start: 0, end: 12), + ], + ); + }); + }); + + group("full copy >", () { + test("only a single placeholder", () { + expect( + AttributedText( + "", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.end), + ], + ), + { + 0: const _FakePlaceholder("only"), + }).copy(), + AttributedText( + "", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.end), + ], + ), + { + 0: const _FakePlaceholder("only"), + }), + ); + }); + + test("some of multiple placeholders", () { + expect( + AttributedText( + "", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _bold, offset: 1, markerType: SpanMarkerType.start), + SpanMarker(attribution: _bold, offset: 2, markerType: SpanMarkerType.end), + ], + ), + { + 0: const _FakePlaceholder("one"), + 1: const _FakePlaceholder("two"), + 2: const _FakePlaceholder("three"), + }).copy(), + AttributedText( + "", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _bold, offset: 1, markerType: SpanMarkerType.start), + SpanMarker(attribution: _bold, offset: 2, markerType: SpanMarkerType.end), + ], + ), + { + 0: const _FakePlaceholder("one"), + 1: const _FakePlaceholder("two"), + 2: const _FakePlaceholder("three"), + }), + ); + }); + + test("some text and a placeholder", () { + expect( + AttributedText( + "Hello, world!", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: _bold, offset: 5, markerType: SpanMarkerType.end), + ], + ), + { + 5: const _FakePlaceholder("middle"), + }).copy(), + AttributedText( + "Hello, world!", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: _bold, offset: 5, markerType: SpanMarkerType.end), + ], + ), + { + 5: const _FakePlaceholder("middle"), + }), + ); + }); + }); + + group("copy span >", () { + test("only a single placeholder", () { + expect( + AttributedText("", null, { + 0: const _FakePlaceholder("only"), + }).copyText(0, 1), + AttributedText("", null, { + 0: const _FakePlaceholder("only"), + }), + ); + }); + + test("some of multiple placeholders", () { + expect( + AttributedText("", null, { + 0: const _FakePlaceholder("one"), + 1: const _FakePlaceholder("two"), + 2: const _FakePlaceholder("three"), + }).copyText(1, 3), + AttributedText("", null, { + 0: const _FakePlaceholder("two"), + 1: const _FakePlaceholder("three"), + }), + ); + }); + + test("some text and a leading placeholder", () { + expect( + AttributedText("Hello, world!", null, { + 0: const _FakePlaceholder("leading"), + }).copyText(0, 6), + AttributedText("Hello", null, { + 0: const _FakePlaceholder("leading"), + }), + ); + }); + + test("some text and a middle placeholder", () { + expect( + AttributedText("Hello, world!", null, { + 5: const _FakePlaceholder("middle"), + }).copyText(0, 6), + AttributedText("Hello", null, { + 5: const _FakePlaceholder("middle"), + }), + ); + }); + + test("some text and a trailing placeholder", () { + expect( + AttributedText("Hello, world!", null, { + 13: const _FakePlaceholder("trailing"), + }).copyText(7, 14), + AttributedText("world!", null, { + 6: const _FakePlaceholder("trailing"), + }), + ); + }); + + test("empty text with leading placeholder (with attributions)", () { + // Create an empty text containing a placeholder with an attribution around it. + final text = AttributedText( + '', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: _bold, + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: _bold, + offset: 0, + markerType: SpanMarkerType.end, + ), + ], + ), + { + 0: const _FakePlaceholder('leading'), + }, + ); + + expect( + text.copyText(0), + AttributedText( + "", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.end), + ], + ), + { + 0: const _FakePlaceholder("leading"), + }, + ), + ); + }); + }); + + group("copy and append >", () { + test("only a single placeholder", () { + expect( + AttributedText("Hello").copyAndAppend( + AttributedText("", null, { + 0: const _FakePlaceholder("only"), + }), + ), + AttributedText("Hello", null, { + 5: const _FakePlaceholder("only"), + }), + ); + }); + + test("some of multiple placeholders", () { + expect( + AttributedText("Hello").copyAndAppend( + AttributedText("", null, { + 0: const _FakePlaceholder("one"), + 1: const _FakePlaceholder("two"), + 2: const _FakePlaceholder("three"), + }), + ), + AttributedText("Hello", null, { + 5: const _FakePlaceholder("one"), + 6: const _FakePlaceholder("two"), + 7: const _FakePlaceholder("three"), + }), + ); + }); + + test("some text and a leading placeholder", () { + expect( + AttributedText("Hello").copyAndAppend( + AttributedText(", world!", null, { + 0: const _FakePlaceholder("middle"), + }), + ), + AttributedText("Hello, world!", null, { + 5: const _FakePlaceholder("middle"), + }), + ); + }); + + test("some text and a middle placeholder", () { + expect( + AttributedText("Hello").copyAndAppend( + AttributedText(", world!", null, { + 2: const _FakePlaceholder("middle"), + }), + ), + AttributedText("Hello, world!", null, { + 7: const _FakePlaceholder("middle"), + }), + ); + }); + + test("some text and a trailing placeholder", () { + expect( + AttributedText("Hello").copyAndAppend( + AttributedText(", world!", null, { + 8: const _FakePlaceholder("trailing"), + }), + ), + AttributedText("Hello, world!", null, { + 13: const _FakePlaceholder("trailing"), + }), + ); + }); + + test("at end of text (with attributions) with leading placeholder", () { + // Note: We include attributions in this test because in a client app the presence + // of attributions unearthed a bug where we were still using the plain text + // length, when we should have been using the text + placeholders length. + expect( + AttributedText( + "Hello", + AttributedSpans(attributions: const [ + SpanMarker(attribution: ExpectedSpans.bold, offset: 1, markerType: SpanMarkerType.start), + SpanMarker(attribution: ExpectedSpans.bold, offset: 5, markerType: SpanMarkerType.end), + ]), + { + 0: const _FakePlaceholder("leading"), + }, + ).copyAndAppend( + AttributedText(", world!"), + ), + AttributedText( + "Hello, world!", + AttributedSpans(attributions: const [ + SpanMarker(attribution: ExpectedSpans.bold, offset: 1, markerType: SpanMarkerType.start), + SpanMarker(attribution: ExpectedSpans.bold, offset: 5, markerType: SpanMarkerType.end), + ]), + { + 0: const _FakePlaceholder("leading"), + }, + ), + ); + }); + + test("at end of text (with attributions) with trailing placeholder", () { + // Note: We include attributions in this test because in a client app the presence + // of attributions unearthed a bug where we were still using the plain text + // length, when we should have been using the text + placeholders length. + expect( + AttributedText( + "Hello", + AttributedSpans(attributions: const [ + SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.end), + ]), + { + 5: const _FakePlaceholder("trailing"), + }, + ).copyAndAppend( + AttributedText(", world!"), + ), + AttributedText( + "Hello, world!", + AttributedSpans(attributions: const [ + SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.end), + ]), + { + 5: const _FakePlaceholder("trailing"), + }, + ), + ); + }); + }); + + test("insert attributed text >", () { + final empty = AttributedText(""); + final hello = empty.insert( + textToInsert: AttributedText("Hello", null, { + 5: const _FakePlaceholder("middle"), + }), + startOffset: 0, + ); + final helloWorld = hello.insert( + textToInsert: AttributedText(", World!", null, { + 8: const _FakePlaceholder("trailing"), + }), + startOffset: 6, + ); + + expect( + hello, + AttributedText("Hello", null, { + 5: const _FakePlaceholder("middle"), + }), + ); + + expect( + helloWorld, + AttributedText("Hello, World!", null, { + 5: const _FakePlaceholder("middle"), + 14: const _FakePlaceholder("trailing"), + }), + ); + }); + + group("insert placeholders >", () { + test("multiple placeholders", () { + expect( + AttributedText("Hello, World!").insertPlaceholders({ + 0: const _FakePlaceholder("leading"), + 6: const _FakePlaceholder("middle"), + 14: const _FakePlaceholder("trailing"), + }), + AttributedText("Hello, World!", null, { + 0: const _FakePlaceholder("leading"), + 6: const _FakePlaceholder("middle"), + 14: const _FakePlaceholder("trailing"), + }), + ); + }); + + test("individual placeholder", () { + expect( + AttributedText().insertPlaceholder(0, const _FakePlaceholder("only")), + AttributedText("", null, { + 0: const _FakePlaceholder("only"), + }), + ); + + expect( + AttributedText("Hello").insertPlaceholder(5, const _FakePlaceholder("only")), + AttributedText("Hello", null, { + 5: const _FakePlaceholder("only"), + }), + ); + }); + }); + + test("remove region >", () { + expect( + AttributedText( + "Hello, World!", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: _bold, offset: 4, markerType: SpanMarkerType.end), + ], + ), + { + 5: const _FakePlaceholder("middle"), + }, + ).removeRegion(startOffset: 0, endOffset: 5), + AttributedText( + ", World!", + null, + { + 0: const _FakePlaceholder("middle"), + }, + ), + ); + + expect( + AttributedText( + "Hello, World!", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: _bold, offset: 5, markerType: SpanMarkerType.end), + ], + ), + { + 5: const _FakePlaceholder("middle"), + }, + ).removeRegion(startOffset: 3, endOffset: 10), + AttributedText( + "Helrld!", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: _bold, offset: 2, markerType: SpanMarkerType.end), + ], + ), + ), + ); + }); + + group("collapses spans >", () { + test("when placeholders sit beyond plain text", () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 11, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.underline, offset: 12, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.underline, offset: 12, markerType: SpanMarkerType.end), + ], + ), + { + 11: const _FakePlaceholder("trailing1"), + 12: const _FakePlaceholder("trailing2"), + }, + ); + + final spans = attributedText.computeAttributionSpans().toList(); + + expect(spans.length, 2); + + expect(spans[0].attributions, {ExpectedSpans.bold}); + expect(spans[0].start, 0); + expect(spans[0].end, 11); + + expect(spans[1].attributions, {ExpectedSpans.underline}); + expect(spans[1].start, 12); + expect(spans[1].end, 12); + }); + }); + }); +} + +const _bold = NamedAttribution("bold"); + +class _FakePlaceholder { + const _FakePlaceholder(this.name); + + final String name; + + @override + bool operator ==(Object other) => + identical(this, other) || other is _FakePlaceholder && runtimeType == other.runtimeType && name == other.name; + + @override + int get hashCode => name.hashCode; +} diff --git a/attributed_text/test/attributed_text_test.dart b/attributed_text/test/attributed_text_test.dart index 3fa92637d0..9671ea9933 100644 --- a/attributed_text/test/attributed_text_test.dart +++ b/attributed_text/test/attributed_text_test.dart @@ -1,12 +1,13 @@ import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; import 'package:test/test.dart'; void main() { group('Attributed Text', () { test('Bug 145 - insert character at beginning of styled text', () { final initialText = AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( + 'abcdefghij', + AttributedSpans( attributions: [ const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), @@ -20,63 +21,180 @@ void main() { applyAttributions: {ExpectedSpans.bold}, ); - expect(newText.text, 'aabcdefghij'); + expect(newText.toPlainText(), 'aabcdefghij'); expect( - newText.hasAttributionsWithin(attributions: {ExpectedSpans.bold}, range: const SpanRange(start: 0, end: 10)), + newText.hasAttributionsWithin(attributions: {ExpectedSpans.bold}, range: const SpanRange(0, 10)), true, ); }); + group('fragments', () { + test('can be copied as attributed text with a SpanRange', () { + final text = AttributedText( + "this that other", + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 14, markerType: SpanMarkerType.end), + ], + ), + ); + + final slice = text.copyTextInRange(const SpanRange(5, 9)); + expect(slice.toPlainText(), "that"); + expect(slice.length, 4); + expect(slice.getAttributedRange({ExpectedSpans.bold}, 0), const SpanRange(0, 1)); + expect(slice.getAttributedRange({ExpectedSpans.italics}, 2), const SpanRange(2, 3)); + }); + + test('can be copied as attributed text with start and end bounds', () { + final text = AttributedText( + "this that other", + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 14, markerType: SpanMarkerType.end), + ], + ), + ); + + final slice = text.copyText(5, 9); + expect(slice.toPlainText(), "that"); + expect(slice.length, 4); + expect(slice.getAttributedRange({ExpectedSpans.bold}, 0), const SpanRange(0, 1)); + expect(slice.getAttributedRange({ExpectedSpans.italics}, 2), const SpanRange(2, 3)); + }); + + test('can be copied as plain text with a SpanRange', () { + final text = AttributedText( + "this that other", + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 14, markerType: SpanMarkerType.end), + ], + ), + ); + + final substring = text.substringInRange(const SpanRange(5, 9)); + expect(substring, "that"); + }); + + test('can be copied as plain text with start and end bounds', () { + final text = AttributedText( + "this that other", + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 14, markerType: SpanMarkerType.end), + ], + ), + ); + + final substring = text.substring(5, 9); + expect(substring, "that"); + }); + }); + group('span manipulation', () { test('combines overlapping spans when adding from left to right', () { // Note: span overlaps at the boundary had a bug that was filed in #582. - final text = AttributedText(text: '01234567'); - text.addAttribution(ExpectedSpans.bold, SpanRange(start: 0, end: 4)); - text.addAttribution(ExpectedSpans.bold, SpanRange(start: 4, end: 8)); + final text = AttributedText('01234567'); + text.addAttribution(ExpectedSpans.bold, const SpanRange(0, 4)); + text.addAttribution(ExpectedSpans.bold, const SpanRange(4, 8)); // Ensure that the spans were merged into a single span. expect(text.spans.markers.length, 2); expect( text.spans.markers.first, - SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), ); expect( text.spans.markers.last, - SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end), ); }); test('combines overlapping spans when adding from left to right', () { - final text = AttributedText(text: '01234567'); - text.addAttribution(ExpectedSpans.bold, SpanRange(start: 4, end: 8)); - text.addAttribution(ExpectedSpans.bold, SpanRange(start: 0, end: 4)); + final text = AttributedText('01234567'); + text.addAttribution(ExpectedSpans.bold, const SpanRange(4, 8)); + text.addAttribution(ExpectedSpans.bold, const SpanRange(0, 4)); // Ensure that the spans were merged into a single span. expect(text.spans.markers.length, 2); expect( text.spans.markers.first, - SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), ); expect( text.spans.markers.last, - SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end), ); }); - test('combines back-to-back spans after addition', () { - final text = AttributedText(text: 'ABCD'); - text.addAttribution(ExpectedSpans.bold, const SpanRange(start: 0, end: 1)); - text.addAttribution(ExpectedSpans.bold, const SpanRange(start: 2, end: 3)); + test('automatically combines back-to-back spans after addition', () { + final text = AttributedText('ABCD'); + text.addAttribution(ExpectedSpans.bold, const SpanRange(0, 1)); + text.addAttribution(ExpectedSpans.bold, const SpanRange(2, 3)); // Ensure that we only have a single span expect(text.spans.markers.length, 2); expect( text.spans.markers.first, - SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), ); expect( text.spans.markers.last, - SpanMarker(attribution: ExpectedSpans.bold, offset: 3, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 3, markerType: SpanMarkerType.end), + ); + }); + + test('keeps back-to-back spans separate when requested', () { + final text = AttributedText('#john#sally'); + text.addAttribution(ExpectedSpans.hashTag, const SpanRange(0, 4)); + text.addAttribution(ExpectedSpans.hashTag, const SpanRange(5, 10), autoMerge: false); + + // Ensure that the hash tag spans were kept separate + expect(text.spans.markers.length, 2 * 2); + + final markers = text.spans.markers.toList(); + + // #john + expect( + markers[0], + const SpanMarker(attribution: ExpectedSpans.hashTag, offset: 0, markerType: SpanMarkerType.start), + ); + expect( + markers[1], + const SpanMarker(attribution: ExpectedSpans.hashTag, offset: 4, markerType: SpanMarkerType.end), + ); + + // #sally + expect( + markers[2], + const SpanMarker(attribution: ExpectedSpans.hashTag, offset: 5, markerType: SpanMarkerType.start), + ); + expect( + markers[3], + const SpanMarker(attribution: ExpectedSpans.hashTag, offset: 10, markerType: SpanMarkerType.end), + ); + }); + + test('throws exception when compatible attributions overlap but auto-merge is false', () { + final text = AttributedText('#john#sally'); + text.addAttribution(ExpectedSpans.hashTag, const SpanRange(0, 4)); + + expect( + () => text.addAttribution(ExpectedSpans.hashTag, const SpanRange(0, 10), autoMerge: false), + throwsA(isA()), ); }); }); @@ -84,12 +202,12 @@ void main() { test('notifies listeners when style changes', () { bool listenerCalled = false; - final text = AttributedText(text: 'abcdefghij'); + final text = AttributedText('abcdefghij'); text.addListener(() { listenerCalled = true; }); - text.addAttribution(ExpectedSpans.bold, const SpanRange(start: 1, end: 1)); + text.addAttribution(ExpectedSpans.bold, const SpanRange(1, 1)); expect(listenerCalled, isTrue); }); @@ -98,8 +216,8 @@ void main() { test("equivalent AttributedText are equal", () { expect( AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( + 'abcdefghij', + AttributedSpans( attributions: [ const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), const SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.start), @@ -110,8 +228,8 @@ void main() { ), equals( AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( + 'abcdefghij', + AttributedSpans( attributions: [ const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), const SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.start), @@ -127,8 +245,8 @@ void main() { test("different text are not equal", () { expect( AttributedText( - text: 'jihgfedcba', - spans: AttributedSpans( + 'jihgfedcba', + AttributedSpans( attributions: [ const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), const SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.start), @@ -138,8 +256,8 @@ void main() { ), ) == AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( + 'abcdefghij', + AttributedSpans( attributions: [ const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), const SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.start), @@ -155,8 +273,8 @@ void main() { test("different spans are not equal", () { expect( AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( + 'abcdefghij', + AttributedSpans( attributions: [ const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), const SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.start), @@ -166,8 +284,8 @@ void main() { ), ) == AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( + 'abcdefghij', + AttributedSpans( attributions: [ const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), const SpanMarker(attribution: ExpectedSpans.bold, offset: 5, markerType: SpanMarkerType.end), @@ -180,58 +298,634 @@ void main() { }); group('attribution queries', () { + test('finds all spans for single attribution throughout text', () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 3, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 7, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 10, markerType: SpanMarkerType.end), + ], + ), + ); + + final ranges = attributedText.getAttributionSpans({ExpectedSpans.bold}); + + expect(ranges.length, 3); + expect( + ranges, + [ + const AttributionSpan(attribution: ExpectedSpans.bold, start: 2, end: 3), + const AttributionSpan(attribution: ExpectedSpans.bold, start: 6, end: 7), + const AttributionSpan(attribution: ExpectedSpans.bold, start: 9, end: 10), + ], + ); + }); + + test('finds all spans for multiple attributions throughout text', () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 3, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 7, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 5, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 9, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end), + ], + ), + ); + + final ranges = attributedText.getAttributionSpans({ExpectedSpans.bold, ExpectedSpans.italics}); + + expect(ranges.length, 4); + expect( + ranges, + [ + const AttributionSpan(attribution: ExpectedSpans.bold, start: 2, end: 3), + const AttributionSpan(attribution: ExpectedSpans.italics, start: 5, end: 7), + const AttributionSpan(attribution: ExpectedSpans.bold, start: 6, end: 7), + const AttributionSpan(attribution: ExpectedSpans.italics, start: 9, end: 10), + ], + ); + }); + + test('returns empty list when searching for non-existent attribution spans', () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 3, markerType: SpanMarkerType.end), + ], + ), + ); + + final ranges = attributedText.getAttributionSpans({ExpectedSpans.italics}); + + expect(ranges.length, 0); + }); + test('finds all bold text around a character', () { final attributedText = AttributedText( - text: 'Hello world', - spans: AttributedSpans( + 'Hello world', + AttributedSpans( attributions: [ - SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), ], ), ); final range = attributedText.getAttributedRange({ExpectedSpans.bold}, 5); - expect(range, SpanRange(start: 4, end: 9)); + expect(range, const SpanRange(4, 9)); }); test('finds all bold and italics text around a character', () { final attributedText = AttributedText( - text: 'Hello world', - spans: AttributedSpans( + 'Hello world', + AttributedSpans( attributions: [ - SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 7, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 10, markerType: SpanMarkerType.end), ], ), ); final range = attributedText.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics}, 5); - expect(range, SpanRange(start: 4, end: 7)); + expect(range, const SpanRange(4, 7)); + }); + + test( + 'finds all bold, italic and strikethrough text within a word that also includes a span with only bold and italics', + () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 1, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 3, markerType: SpanMarkerType.end), + ], + ), + ); + + final range = attributedText + .getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics, ExpectedSpans.strikethrough}, 2); + expect(range, const SpanRange(1, 3)); + }); + + group('getAllAttributionsThroughout', () { + test('returns empty list if the range does not have any attributions', () { + final attributedText = AttributedText('Text without attributions'); + expect(attributedText.getAllAttributionsThroughout(const SpanRange(5, 12)), isEmpty); + }); + + test('returns attributions that apply to the entirety of the range', () { + // Create a text with the following attributions: + // - bold: applied throught the entire text. + // - underline: applied to the word "with", + // - italics: applied from the begining of the text until "wi|th". + // - strikethrough: applied from "wi|th" until the end of the text. + + final attributedText = AttributedText( + 'Text with attributions', + AttributedSpans( + attributions: const [ + SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: ExpectedSpans.underline, offset: 5, markerType: SpanMarkerType.start), + SpanMarker(attribution: ExpectedSpans.italics, offset: 6, markerType: SpanMarkerType.end), + SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 6, markerType: SpanMarkerType.start), + SpanMarker(attribution: ExpectedSpans.underline, offset: 8, markerType: SpanMarkerType.end), + SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 21, markerType: SpanMarkerType.end), + SpanMarker(attribution: ExpectedSpans.bold, offset: 21, markerType: SpanMarkerType.end), + ], + ), + ); + + expect( + attributedText.getAllAttributionsThroughout(const SpanRange(5, 8)), + {ExpectedSpans.bold, ExpectedSpans.underline}, + ); + }); + }); + }); + + group("attribution visitation", () { + test("visits full-length attributions", () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 10, markerType: SpanMarkerType.end), + ], + ), + ); + + final expectedVisits = [ + _AttributionVisit(0, {ExpectedSpans.bold}, {}), + _AttributionVisit(10, {}, {ExpectedSpans.bold}), + ]; + + attributedText.visitAttributions( + CallbackAttributionVisitor( + visitAttributions: ( + AttributedText fullText, + int index, + Set startingAttributions, + Set endingAttributions, + ) { + expect(_AttributionVisit(index, startingAttributions, endingAttributions), expectedVisits.first); + expectedVisits.removeAt(0); + }, + ), + ); + + expect(expectedVisits, isEmpty); + }); + + test("visits partial-length attributions", () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end), + ], + ), + ); + + final expectedVisits = [ + _AttributionVisit(2, {ExpectedSpans.bold}, {}), + _AttributionVisit(8, {}, {ExpectedSpans.bold}), + ]; + + attributedText.visitAttributions( + CallbackAttributionVisitor( + visitAttributions: ( + AttributedText fullText, + int index, + Set startingAttributions, + Set endingAttributions, + ) { + expect(_AttributionVisit(index, startingAttributions, endingAttributions), expectedVisits.first); + expectedVisits.removeAt(0); + }, + ), + ); + + expect(expectedVisits, isEmpty); + }); + + test("visits overlapping attributions", () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end), + ], + ), + ); + + final expectedVisits = [ + _AttributionVisit(0, {ExpectedSpans.bold}, {}), + _AttributionVisit(4, {ExpectedSpans.italics}, {}), + _AttributionVisit(6, {}, {ExpectedSpans.bold}), + _AttributionVisit(10, {}, {ExpectedSpans.italics}), + ]; + + attributedText.visitAttributions( + CallbackAttributionVisitor( + visitAttributions: ( + AttributedText fullText, + int index, + Set startingAttributions, + Set endingAttributions, + ) { + expect(_AttributionVisit(index, startingAttributions, endingAttributions), expectedVisits.first); + expectedVisits.removeAt(0); + }, + ), + ); + + expect(expectedVisits, isEmpty); }); - test('finds all bold, italic and strikethrough text within a word that also includes a span with only bold and italics', () { + test("visits multiple starting and ending attributions", () { final attributedText = AttributedText( - text: 'Hello world', - spans: AttributedSpans( + 'Hello world', + AttributedSpans( attributions: [ - SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.italics, offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.end), - SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 1, markerType: SpanMarkerType.start), - SpanMarker(attribution: ExpectedSpans.strikethrough, offset: 3, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 2, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 8, markerType: SpanMarkerType.end), ], ), ); - final range = attributedText.getAttributedRange({ExpectedSpans.bold, ExpectedSpans.italics, ExpectedSpans.strikethrough}, 2); - expect(range, SpanRange(start: 1, end: 3)); + final expectedVisits = [ + _AttributionVisit(2, {ExpectedSpans.bold, ExpectedSpans.italics}, {}), + _AttributionVisit(8, {}, {ExpectedSpans.bold, ExpectedSpans.italics}), + ]; + + attributedText.visitAttributions( + CallbackAttributionVisitor( + visitAttributions: ( + AttributedText fullText, + int index, + Set startingAttributions, + Set endingAttributions, + ) { + expect(_AttributionVisit(index, startingAttributions, endingAttributions), expectedVisits.first); + expectedVisits.removeAt(0); + }, + ), + ); + + expect(expectedVisits, isEmpty); + }); + }); + + group("attribution span visitation", () { + test("visits full-length attributions", () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 10, markerType: SpanMarkerType.end), + ], + ), + ); + + final expectedVisits = [ + MultiAttributionSpan(attributions: {ExpectedSpans.bold}, start: 0, end: 10), + ]; + + attributedText.visitAttributionSpans( + (span) { + expect(span, expectedVisits.first); + expectedVisits.removeAt(0); + }, + ); + + expect(expectedVisits, isEmpty); + }); + + test("visits partial-length attributions", () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end), + ], + ), + ); + + final expectedVisits = [ + const MultiAttributionSpan(attributions: {}, start: 0, end: 1), + MultiAttributionSpan(attributions: {ExpectedSpans.bold}, start: 2, end: 8), + const MultiAttributionSpan(attributions: {}, start: 9, end: 10), + ]; + + attributedText.visitAttributionSpans( + (span) { + expect(span, expectedVisits.first); + expectedVisits.removeAt(0); + }, + ); + + expect(expectedVisits, isEmpty); + }); + + test("visits overlapping attributions", () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 6, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 10, markerType: SpanMarkerType.end), + ], + ), + ); + + final expectedVisits = [ + MultiAttributionSpan(attributions: {ExpectedSpans.bold}, start: 0, end: 3), + MultiAttributionSpan(attributions: {ExpectedSpans.bold, ExpectedSpans.italics}, start: 4, end: 6), + MultiAttributionSpan(attributions: {ExpectedSpans.italics}, start: 7, end: 10), + ]; + + attributedText.visitAttributionSpans( + (span) { + expect(span, expectedVisits.first); + expectedVisits.removeAt(0); + }, + ); + + expect(expectedVisits, isEmpty); + }); + + test("visits multiple starting and ending attributions", () { + final attributedText = AttributedText( + 'Hello world', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.bold, offset: 8, markerType: SpanMarkerType.end), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 2, markerType: SpanMarkerType.start), + const SpanMarker(attribution: ExpectedSpans.italics, offset: 8, markerType: SpanMarkerType.end), + ], + ), + ); + + final expectedVisits = [ + const MultiAttributionSpan(attributions: {}, start: 0, end: 1), + MultiAttributionSpan(attributions: {ExpectedSpans.bold, ExpectedSpans.italics}, start: 2, end: 8), + const MultiAttributionSpan(attributions: {}, start: 9, end: 10), + ]; + + attributedText.visitAttributionSpans( + (span) { + expect(span, expectedVisits.first); + expectedVisits.removeAt(0); + }, + ); + + expect(expectedVisits, isEmpty); + }); + }); + + group('collapseSpans', () { + const boldAttribution = NamedAttribution('bold'); + + test('returns a single span for text without attributions', () { + final text = AttributedText('Hello World'); + + final spans = text.computeAttributionSpans().toList(); + + // Ensure a single span containing the whole text was returned. + expect(spans.length, 1); + expect(spans[0].attributions, isEmpty); + expect(spans[0].start, 0); + expect(spans[0].end, text.length - 1); + }); + + test('returns a single span for text with an attribution containing the whole text', () { + final text = AttributedText( + 'Hello World', + AttributedSpans( + attributions: const [ + SpanMarker( + attribution: boldAttribution, + markerType: SpanMarkerType.start, + offset: 0, + ), + SpanMarker( + attribution: boldAttribution, + markerType: SpanMarkerType.end, + offset: 10, + ), + ], + ), + ); + + final spans = text.computeAttributionSpans().toList(); + + // Ensure a single span containing the whole text was returned. + expect(spans.length, 1); + expect(spans[0].attributions, isNotEmpty); + expect(spans[0].start, 0); + expect(spans[0].end, text.length - 1); + }); + + test('returns two spans for text with an attribution from the beginning until half of the text', () { + // Create a text with a bold attribution in "Hello ". + final text = AttributedText( + 'Hello World', + AttributedSpans( + attributions: const [ + SpanMarker( + attribution: boldAttribution, + markerType: SpanMarkerType.start, + offset: 0, + ), + SpanMarker( + attribution: boldAttribution, + markerType: SpanMarkerType.end, + offset: 5, + ), + ], + ), + ); + + final spans = text.computeAttributionSpans().toList(); + + // Ensure two spans were returned. + // The first containing the attribution and the second without any attributions. + expect(spans.length, 2); + expect(spans[0].attributions, isNotEmpty); + expect(spans[0].start, 0); + expect(spans[0].end, 5); + expect(spans[1].attributions, isEmpty); + expect(spans[1].start, 6); + expect(spans[1].end, text.length - 1); + }); + + test('handles markers which end after the end of the text', () { + // Create a text with a bold attribution in "World". + // The marker end offset is bigger than the last character index (the text lenght is 11). + final text = AttributedText( + 'Hello World', + AttributedSpans( + attributions: const [ + SpanMarker( + attribution: boldAttribution, + markerType: SpanMarkerType.start, + offset: 6, + ), + SpanMarker( + attribution: boldAttribution, + markerType: SpanMarkerType.end, + offset: 11, + ), + ], + ), + ); + + final spans = text.computeAttributionSpans().toList(); + + // Ensure two spans were returned. The first containing no attributions and + // the second containing the attribution. + expect(spans.length, 2); + expect(spans[0].attributions, isEmpty); + expect(spans[0].start, 0); + expect(spans[0].end, 5); + expect(spans[1].attributions, isNotEmpty); + expect(spans[1].start, 6); + expect(spans[1].end, 10); + }); + }); + + group("copy >", () { + test("copies an AttributedText without any attributions", () { + final attributedText = AttributedText( + 'Sample Text', + ); + + expect(attributedText.copy(), AttributedText('Sample Text')); + }); + + test("copies an AttributedText with attributions", () { + final attributedText = AttributedText( + 'abcdefghij', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: ExpectedSpans.bold, + offset: 2, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ExpectedSpans.italics, + offset: 4, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ExpectedSpans.bold, + offset: 5, + markerType: SpanMarkerType.end, + ), + const SpanMarker( + attribution: ExpectedSpans.italics, + offset: 7, + markerType: SpanMarkerType.end, + ), + ], + ), + ); + + expect( + attributedText.copy(), + AttributedText( + 'abcdefghij', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: ExpectedSpans.bold, + offset: 2, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ExpectedSpans.italics, + offset: 4, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ExpectedSpans.bold, + offset: 5, + markerType: SpanMarkerType.end, + ), + const SpanMarker( + attribution: ExpectedSpans.italics, + offset: 7, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ); }); }); }); } + +class _AttributionVisit { + _AttributionVisit( + this.index, + this.startingAttributions, + this.endingAttributions, + ); + + final int index; + final Set startingAttributions; + final Set endingAttributions; + + @override + String toString() => + "[_AttributionVisit] - index: $index, starting: $startingAttributions, ending: $endingAttributions"; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _AttributionVisit && + runtimeType == other.runtimeType && + index == other.index && + const DeepCollectionEquality().equals(startingAttributions, other.startingAttributions) && + const DeepCollectionEquality().equals(endingAttributions, other.endingAttributions); + + @override + int get hashCode => index.hashCode ^ startingAttributions.hashCode ^ endingAttributions.hashCode; +} diff --git a/doc/website/bin/super_editor_docs.dart b/doc/website/bin/super_editor_docs.dart new file mode 100644 index 0000000000..0a6c63d500 --- /dev/null +++ b/doc/website/bin/super_editor_docs.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:static_shock/static_shock.dart'; + +Future main(List arguments) async { + // Configure the static website generator. + final staticShock = StaticShock() + // Here, you can directly hook into the StaticShock pipeline. For example, + // you can copy an "images" directory from the source set to build set: + ..pick(DirectoryPicker.parse("images")) + // All 3rd party behavior is added through plugins, even the behavior + // shipped with Static Shock. + ..plugin(const MarkdownPlugin()) + ..plugin(const JinjaPlugin()) + ..plugin(const PrettyUrlsPlugin()) + ..plugin(const RedirectsPlugin()) + ..plugin(const SassPlugin()) + ..plugin(DraftingPlugin( + showDrafts: arguments.contains("preview"), + )) + ..plugin(const PubPackagePlugin({ + "super_editor", + "super_editor_markdown", + "super_editor_quill", + "super_text_layout", + "attributed_text", + })) + ..plugin( + GitHubContributorsPlugin( + authToken: Platform.environment["github_doc_website_token"], + ), + ); + + // Generate the static website. + await staticShock.generateSite(); +} diff --git a/doc/website/pubspec.lock b/doc/website/pubspec.lock new file mode 100644 index 0000000000..3001d6576e --- /dev/null +++ b/doc/website/pubspec.lock @@ -0,0 +1,715 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "45cfa8471b89fb6643fe9bf51bd7931a76b8f5ec2d65de4fb176dba8d4f22c77" + url: "https://pub.dev" + source: hosted + version: "73.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.2" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "4959fec185fe70cce007c57e9ab6983101dbe593d2bf8bbfb4453aaec0cf470a" + url: "https://pub.dev" + source: hosted + version: "6.8.0" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + cli_pkg: + dependency: transitive + description: + name: cli_pkg + sha256: f812467b5d6a5f26ad0fba5dcfc95133df02edbae47dfa4ade3df5d2b5afdcf2 + url: "https://pub.dev" + source: hosted + version: "2.10.0" + cli_repl: + dependency: transitive + description: + name: cli_repl + sha256: a2ee06d98f211cb960c777519cb3d14e882acd90fe5e078668e3ab4baab0ddd4 + url: "https://pub.dev" + source: hosted + version: "0.2.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + url: "https://pub.dev" + source: hosted + version: "0.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5 + url: "https://pub.dev" + source: hosted + version: "1.9.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + url: "https://pub.dev" + source: hosted + version: "3.0.5" + dart_rss: + dependency: transitive + description: + name: dart_rss + sha256: "73539d4b7153b47beef8b51763ca55dcb6fc0bb412b29e0f5e74e93fabfd1ac6" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + fbh_front_matter: + dependency: transitive + description: + name: fbh_front_matter + sha256: "18b2f355326ff2b7ebd64eb1d969c091895ffa755ab55ede5de68f59cf115024" + url: "https://pub.dev" + source: hosted + version: "0.0.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + github: + dependency: transitive + description: + name: github + sha256: "57f6ad78591f9638e903409977443093f862d25062a6b582a3c89e4ae44e4814" + url: "https://pub.dev" + source: hosted + version: "9.24.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + grinder: + dependency: transitive + description: + name: grinder + sha256: e1996e485d2b56bb164a8585679758d488fbf567273f51c432c8733fee1f6188 + url: "https://pub.dev" + source: hosted + version: "0.9.5" + html_unescape: + dependency: transitive + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + http: + dependency: transitive + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "40f592dd352890c3b60fec1b68e786cefb9603e05ff303dbc4dda49b304ecdf4" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + image: + dependency: transitive + description: + name: image + sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + jinja: + dependency: transitive + description: + name: jinja + sha256: ee379abb8106faec4898d7a8b712f7085474c8d8dea43d06c52def04fbe5aa67 + url: "https://pub.dev" + source: hosted + version: "0.6.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + macros: + dependency: transitive + description: + name: macros + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + url: "https://pub.dev" + source: hosted + version: "0.1.2-main.4" + markdown: + dependency: transitive + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" + mason_logger: + dependency: transitive + description: + name: mason_logger + sha256: "1fdf5c76870eb6fc3611ed6fbae1973a3794abe581ea5e22e68af2f73c688b93" + url: "https://pub.dev" + source: hosted + version: "0.2.16" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + native_stack_traces: + dependency: transitive + description: + name: native_stack_traces + sha256: "64d2f4bcf3b69326fb9bc91b4dd3a06f94bb5bbc3a65e25ae6467ace0b34bfd3" + url: "https://pub.dev" + source: hosted + version: "0.5.7" + native_synchronization: + dependency: transitive + description: + name: native_synchronization + sha256: ff200fe0a64d733ff7d4dde2005271c297db81007604c8cd21037959858133ab + url: "https://pub.dev" + source: hosted + version: "0.2.0" + node_interop: + dependency: transitive + description: + name: node_interop + sha256: "3af2420c728173806f4378cf89c53ba9f27f7f67792b898561bff9d390deb98e" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: "54e8dc865349059ebe7f163d6acce7c89eb958b8047e6d6e80ce93b13d7c9e60" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + puppeteer: + dependency: transitive + description: + name: puppeteer + sha256: a6752d4f09b510ae41911bfd0997f957e723d38facf320dd9ee0e5661108744a + url: "https://pub.dev" + source: hosted + version: "3.13.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + sass: + dependency: transitive + description: + name: sass + sha256: "0303cb84a5655e72f6e2442f0ae4c8f7068f2c97a3ed60e582cd59c7c37a13b2" + url: "https://pub.dev" + source: hosted + version: "1.77.8" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + static_shock: + dependency: "direct main" + description: + path: "packages/static_shock" + ref: HEAD + resolved-ref: "0764a11f18b8ad041b475d1b20dcdb5a15719f9c" + url: "https://github.com/flutter-bounty-Hunters/static_shock" + source: git + version: "0.0.12" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" + url: "https://pub.dev" + source: hosted + version: "1.25.8" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + test_core: + dependency: transitive + description: + name: test_core + sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" + url: "https://pub.dev" + source: hosted + version: "0.6.5" + test_process: + dependency: transitive + description: + name: test_process + sha256: "217f19b538926e4922bdb2a01410100ec4e3beb4cc48eae5ae6b20037b07bbd6" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + textwrap: + dependency: transitive + description: + name: textwrap + sha256: "780b164d83dfed30b475b1310d288145c5e0109193c3c507cf015d38e4adc844" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + url: "https://pub.dev" + source: hosted + version: "14.2.5" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "68d1e89a91ed61ad9c370f9f8b6effed9ae5e0ede22a270bdfa6daf79fc2290a" + url: "https://pub.dev" + source: hosted + version: "5.5.4" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.4.0 <4.0.0" diff --git a/doc/website/source/_data.yaml b/doc/website/source/_data.yaml new file mode 100644 index 0000000000..6ccacaaa7d --- /dev/null +++ b/doc/website/source/_data.yaml @@ -0,0 +1,27 @@ +# Configuration for the package that this website documents. +package: + name: super_editor + title: Super Editor + description: A document editing toolkit for Flutter + is_on_pub: true + github: + url: https://github.com/superlistapp/super_editor + organization: superlistapp + name: super_editor + discord: https://discord.gg/8hna2VD32s + sponsorship: https://flutterbountyhunters.com + +# Configuration of the GitHub plugin for loading info about GitHub repositories. +github: + contributors: + repositories: + - { organization: superlistapp, name: super_editor } + +navigation: + items: + - title: Super Editor + url: /super-editor/guides + - title: Super Reader + url: /super-reader-guides + - title: Super Text Field + url: /super-text-field-guides \ No newline at end of file diff --git a/doc/website/source/_includes/components/navbar.jinja b/doc/website/source/_includes/components/navbar.jinja new file mode 100644 index 0000000000..c567291f11 --- /dev/null +++ b/doc/website/source/_includes/components/navbar.jinja @@ -0,0 +1,27 @@ + \ No newline at end of file diff --git a/doc/website/source/super-editor/guides/_data.yaml b/doc/website/source/super-editor/guides/_data.yaml new file mode 100644 index 0000000000..731923b498 --- /dev/null +++ b/doc/website/source/super-editor/guides/_data.yaml @@ -0,0 +1,114 @@ +layout: layouts/docs_page.jinja + +base_path: super-editor/guides/ + +navigation: + show_contributors: false + + header: Super Editor + + items: + - title: Getting Started + items: + - title: Quickstart + url: super-editor/guides/getting-started/quickstart + - title: Tour + url: super-editor/guides/getting-started/tour + + - title: Document Editors + items: + - title: Overview + url: super-editor/guides/document-editors/overview + + - title: Chat + items: + - title: Overview + url: super-editor/guides/chat/overview + - title: Screen Scaffold + url: super-editor/guides/chat/screen-scaffold + - title: Keyboard Toolbar + url: super-editor/guides/chat/keyboard-toolbar + + - title: AI / GPT + items: + - title: Overview + url: super-editor/guides/ai/overview + - title: Fade-In Content + url: super-editor/guides/ai/fade-in-content + + - title: Basics + items: + - title: Build an Editor + url: super-editor/guides/basics/build-an-editor + - title: Show a Hint + url: super-editor/guides/basics/show-a-hint + + - title: Built-In Content + items: + - title: Paragraphs + url: super-editor/guides/built-in-content/paragraphs + - title: Headers + url: super-editor/guides/built-in-content/headers + - title: Blockquotes + url: super-editor/guides/built-in-content/blockquotes + - title: List Items + url: super-editor/guides/built-in-content/list-items + - title: Tasks + url: super-editor/guides/built-in-content/tasks + - title: Images + url: super-editor/guides/built-in-content/images + - title: Horizontal Rules + url: super-editor/guides/built-in-content/horizontal-rules + + - title: Markdown + items: + - title: Import + url: super-editor/guides/markdown/import + - title: Export + url: super-editor/guides/markdown/export + - title: As You Type + url: super-editor/guides/markdown/as-you-type + + - title: Quill Deltas + items: + - title: Import + url: super-editor/guides/quill/import + - title: Export + url: super-editor/guides/quill/export + - title: Multi-Player + url: super-editor/guides/quill/multi-player + + - title: Styling + items: + - title: Dark Mode & Light Mode + url: super-editor/guides/styling/dark-mode-and-light-mode + - title: Style a Document + url: super-editor/guides/styling/style-a-document + - title: Text Underlines + url: super-editor/guides/styling/text-underlines + + - title: Editing UI + items: + - title: Overlay Controls + url: super-editor/guides/editing-ui/overlay-controls + - title: Add a Popover Toolbar + url: super-editor/guides/editing-ui/add-a-popover-toolbar + - title: Add a Mobile Keyboard Toolbar + url: super-editor/guides/editing-ui/add-a-mobile-keyboard-toolbar + + - title: The Viewport + items: + - title: Auto-Scrolling + url: super-editor/guides/viewport/auto-scrolling + - title: Embed in a Scrollview + url: super-editor/guides/viewport/embed-in-a-scrollview + + - title: Custom Content + items: + - title: Add New Type of Content + url: super-editor/guides/custom-content/add-new-type-of-content + + - title: Custom Rules + items: + - title: Un-Deletable Content + url: super-editor/guides/custom-rules/undeletable-content diff --git a/doc/website/source/super-editor/guides/add-a-mobile-keyboard-toolbar.md b/doc/website/source/super-editor/guides/add-a-mobile-keyboard-toolbar.md new file mode 100644 index 0000000000..7d2a999610 --- /dev/null +++ b/doc/website/source/super-editor/guides/add-a-mobile-keyboard-toolbar.md @@ -0,0 +1,4 @@ +--- +title: Add a Mobile Keyboard Toolbar +--- +# Add a Mobile Keyboard Toolbar diff --git a/doc/website/source/super-editor/guides/add-a-popover-toolbar.md b/doc/website/source/super-editor/guides/add-a-popover-toolbar.md new file mode 100644 index 0000000000..2bf20f85fa --- /dev/null +++ b/doc/website/source/super-editor/guides/add-a-popover-toolbar.md @@ -0,0 +1,276 @@ +--- +title: Add a Popover Toolbar +--- + +# Add a Popover Toolbar + +To display a Popover Toolbar, it is recomended to use an `OverlayPortal`. To do that, start by wraping `SuperEditor` with an `OverlayPortal` and give it a toolbar builder: + +```dart +class MyApp extends StatefulWidget { + State createState() => MyAppState(); +} + +class MyAppState extends State { + /// Controls the visibility of the toolbar. + final _popoverToolbarController = OverlayPortalController(); + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _popoverToolbarController, + overlayChildBuilder: _buildPopoverToolbar, + child: SuperEditor(), + ); + } + + Widget _buildPopoverToolbar() { + return const SizedBox(); + } +} +``` + +# Showing the toolbar + +Usually, a Popover Toolbar is displayed when the user selects some content. To do that, listen for selection changes to show or hide the toolbar: + +```dart +class MyAppState extends State { + // ... + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + // ... + + @override + void initState() { + super.initState(); + _document = MutableDocument.empty(); + + _composer = MutableDocumentComposer(); + _composer.selectionNotifier.addListener(_hideOrShowToolbar); + } + + void _hideOrShowToolbar() { + final selection = _composer.selection; + if (selection == null) { + // Nothing is selected. We don't want to show a toolbar in this case. + _popoverToolbarController.hide(); + return; + } + + if (selection.isCollapsed) { + // We only want to show the toolbar when a span of text + // is selected. Therefore, we ignore collapsed selections. + _popoverToolbarController.hide(); + return; + } + + // We have an expanded selection. Show the toolbar. + _popoverToolbarController.show(); + } + + // ... +} +``` + +# Aligning the toolbar with the content + +By default, no alignment is enforced to the toolbar. To align it with the content, and make it follow the content, it is recomended to use the `follow_the_leader` package. + +Start by adding `follow_the_leader` to your dependencies in your `pubspec.yaml`. + +```yaml +dependencies: + follow_the_leader: latest_version +``` + +Wrap `SuperEditor` with a `KeyedSubtree` widget to delimit the viewport area and assign a `GlobalKey` to it. This is used to prevent the toolbar from going off-screen. + +```dart +class MyAppState extends State { + // ... + final GlobalKey _viewportKey = GlobalKey(); + // ... + + @override + Widget build(BuildContext context) { + return OverlayPortal( + // ... + child: KeyedSubtree( + key: _viewportKey, + child: SuperEditor(), + ), + ); + } +} +``` + +Create a `SelectionLayerLinks` instance and pass it to the `SuperEditor`. This object holds the links that make it possible to follow the content. + +```dart +class MyAppState extends State { + // ... + final SelectionLayerLinks _selectionLayerLinks = SelectionLayerLinks(); + // ... + + @override + Widget build(BuildContext context) { + return OverlayPortal( + // ... + child: KeyedSubtree( + // ... + child: SuperEditor( + selectionLayerLinks: _selectionLayerLinks, + ), + ), + ); + } +} +``` + +Create a `FollowerBoundary` to configure the boundary of the area where the toolbar is allowed to be. + +```dart +class MyAppState extends State { + // ... + late FollowerBoundary _screenBoundary; + // ... + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // Confine the toolbar to the bounds of the widget attached + // to the _viewportKey. + _screenBoundary = WidgetFollowerBoundary( + boundaryKey: _viewportKey, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ); + } +} +``` + +Create a `FollowerAligner` to configure how the toolbar should be aligned with the selected content. + +```dart +class MyAppState extends State { + // ... + late final FollowerAligner _toolbarAligner; + // ... + + @override + void initState() { + super.initState(); + // Place the toolbar above the content by default. + _toolbarAligner = CupertinoPopoverToolbarAligner(_viewportKey); + }; + + // ... +} +``` + +Finally, wrap the toolbar with a `Follower` widget. + +```dart +class MyAppState extends State { + // ... + Widget _buildPopoverToolbar() { + return Follower.withAligner( + // Make the toolbar follow the expanded selection. + link: _selectionLayerLinks.expandedSelectionBoundsLink, + + // Configure how the toolbar is aligned to the content. + aligner: _toolbarAligner, + + // Configure the boundary where the toolbar is allowed + // to be displayed. + boundary: _screenBoundary, + + showWhenUnlinked: false, + child: _buildToolbarContent(), + ); + } + + Widget _buildToolbarContent() { + return const SizedBox(); + } + + // ... +} +``` + +# Showing different toolbars depending on the content + +To show different toolbar depending on the content, check the type of the selected node and show/hide the appropriate toolbars. + +```dart +class MyAppState extends State { + // ... + void _hideOrShowToolbar() { + // ... + if (selection.base.nodeId != selection.extent.nodeId) { + // Since we want to show different toolbars depending on the content, + // we don't show the toolbar if more than one node is selected. + _popoverToolbarController.hide(); + _imageToolbarController.hide(); + return; + } + + // Grab the selected node to check its type. + final selectedNode = _document.getNodeById(selection.extent.nodeId); + + if (selectedNode is ImageNode) { + // The selected node is an image. Show the image toolbar and hide + // the text toolbar. + _popoverToolbarController.hide(); + _imageToolbarController.show(); + return; + } + + // The currently selected node isn't an image. Hide the image toolbar + // if it's visible. + _imageToolbarController.hide(); + + if (selectedNode is TextNode) { + // The selected node is a text node, e.g., a paragraph, a list item, + // a task, etc. Show the text toolbar and hide the image toolbar. + _imageToolbarController.show(); + _popoverToolbarController.show(); + return; + } + + // The currently selected node isn't a text node. Hide the text toolbar + // if it's visible. + _popoverToolbarController.hide(); + } +} +``` + +# Sharing the editor focus with the toolbar + +In order to make it possible for the user to interact with focusable items in the toolbar, while keeping the editor focused (with non-primary focus), it is necessary to share focus between the editor and the popover toolbar. + +To do that, create a `FocusNode` for the popover, and setup focus sharing by using a `SuperEditorPopover` widget. + +```dart +class MyAppState extends State { + // ... + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverFocusNode.dispose(); + super.dispose(); + } + + // ... + Widget _buildToolbarContent() { + return SuperEditorPopover( + popoverFocusNode: _popoverFocusNode, + editorFocusNode: _editorFocusNode, + // The toolbar content. + child: SizedBox(), + ); + } +} +``` diff --git a/doc/website/source/super-editor/guides/add-new-type-of-content.md b/doc/website/source/super-editor/guides/add-new-type-of-content.md new file mode 100644 index 0000000000..4e5bedd56b --- /dev/null +++ b/doc/website/source/super-editor/guides/add-new-type-of-content.md @@ -0,0 +1,18 @@ +--- +title: Add a New Type of Content +--- +# Add a New Type of Content +Super Editor ships with a number of standard content types, including paragraphs, images, ordered +and unordered list items, blockquotes, and horizontal rules. However, you may need to implement +editor support for other types of content. Adding new content requires implementing a number +of new behaviors. + +## Define a new node type + +## Define a new view model, component, and builder + +## Add edit requests and commands (optional) + +## Add edit reactions (optional) + +## Add keyboard handlers (optional) diff --git a/doc/website/source/super-editor/guides/ai/fade-in-content.md b/doc/website/source/super-editor/guides/ai/fade-in-content.md new file mode 100644 index 0000000000..2f297ec5d4 --- /dev/null +++ b/doc/website/source/super-editor/guides/ai/fade-in-content.md @@ -0,0 +1,141 @@ +--- +title: Fade-In GPT Content +--- +ChatGPT-style large language models (LLM) produces text and content in snippets. +The answer to a single question might includes 50 pieces of information that are +generated by the LLM. The chat-style experience adds each new piece to the existing +answer, making it appear that the AI is "writing" the answer in real-time. + +It's common in these experiences to fade-in the content as its generated. +Super Editor includes a couple of tools that can be used to achieve this +effect, when feeding LLM content to a `SuperEditor` or `SuperReader`. + +## How to Fade-In Content +The following example demonstrates how to fade content into a `SuperReader`. +The `SuperReader` widget is typically used for LLMs because LLM output isn't +typically meant to be editable. + +First, create a `SuperReader` with a `Document` that will hold the LLM output. +Include a `FadeInStyler`, which will control the opacity of new content. + +```dart +class MyState extends State { + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + late final Editor _editor; + late final FadeInStyler _fadeInStylePhase; + + @override + void initState() { + super.initState(); + + _document = MutableDocument.empty(); + _composer = MutableDocumentComposer( + initialSelection: DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: _document.first.id, + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + _editor = createDefaultDocumentEditor(document: _document, composer: _composer); + _fadeInStylePhase = FadeInStyler(this); + } + + @override + void dispose() { + _fadeInStylePhase.dispose(); + _composer.dispose(); + _editor.dispose(); + _document.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SuperReader( + editor: _editor, + customStylePhases: [ + _fadeInStylePhase, + ], + ); + } +} +``` + +With a `SuperReader` in place, take the output from an LLM and feed it into the +`Editor` however you'd like. The way you feed content into the `Editor` depends on +the format of the output from the LLM. You can feed the LLM text into the `Editor` +as plain text, or you can parse the LLM text into styled `AttributedText` and +feed that to the `Editor`, or you can also parse out entire blocks like images and +horizontal rules from the LLM and insert those as nodes in the `Editor`. + +The most important part about feeding content from the LLM to the `Editor` is to +include the current timestamp when inserted. This timestamp is used by the `FadeInStyler` +to choose the opacity for the content. + +```dart +// Insert plain text. +_editor.execute([ + InsertPlainTextAtEndOfDocumentRequest( + "...this is a snippet from the LLM...", + createdAt: DateTime.now(), + ), +]); + +// Insert styled text. +_editor.execute([ + InsertStyledTextAtEndOfDocumentRequest( + AttributedText( + "...this is a bold snippet...", + AttributedSpans(attributions: [ + const SpanMarker( + attribution: boldAttribution, + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 27, + markerType: SpanMarkerType.end, + ), + ]), + ), + createdAt: DateTime.now(), + ), +]); + +// Insert a block node. +_editor.execute([ + InsertNodeAtEndOfDocumentRequest( + ImageNode( + id: Editor.createNodeId(), + imageUrl: "https://somewhere.com/image.png", + metadata: { + NodeMetadata.createdAt: DateTime.now(), + }, + ), + ), +]); +``` + +With a `FadeInStyler` plugged into `SuperReader`, and content insertions that +include a `CreatedAtAttribution`, the inserted content will fade-in within the +`SuperReader` widget. + +## How to Control the Fade-In +When using the `FadeInStyler` to fade-in content, the fade-in effect is configurable. + +```dart +// Customize fade-in times and curves. +final styler = FadeInStyler( + tickerProvider, + blockNodeFadeInDuration: const Duration(milliseconds: 1500), + textSnippetFadeInDuration: const Duration(milliseconds: 250), + fadeCurve: Curves.easeInOut, +); +``` + +The `FadeInStyler` supports different timing for text snippets vs blocks, like images. +The fade animation curve is also configurable. diff --git a/doc/website/source/super-editor/guides/ai/overview.md b/doc/website/source/super-editor/guides/ai/overview.md new file mode 100644 index 0000000000..45d92492db --- /dev/null +++ b/doc/website/source/super-editor/guides/ai/overview.md @@ -0,0 +1,12 @@ +--- +title: AI / GPT Overview +--- +ChatGPT-style experiences are all the rage right now. These experiences include +a large language model (LLM), which generates text based on a query, and a UI +that presents this text in a chat-style format. + +Super Editor doesn't include any LLM-specific functionality, but Super Editor +does offer some tools to help construct experiences that are similar to +ChatGPT, Gemini, etc. + + * [Fade-in content as the LLM generates it](/super-editor/guides/ai/fade-in-content) diff --git a/doc/website/source/super-editor/guides/assemble-a-document.md b/doc/website/source/super-editor/guides/assemble-a-document.md new file mode 100644 index 0000000000..9c69024ffd --- /dev/null +++ b/doc/website/source/super-editor/guides/assemble-a-document.md @@ -0,0 +1,56 @@ +--- +title: Assemble a Document +--- +# Assemble a Document +In Super Editor, a document is typically represented by an instance of `MutableDocument`. The +easiest way to assemble a `MutableDocument` is by [de-serializing Markdown](/guides/document-from-markdown). +However, there are situations where you might need to construct a `MutableDocument` directly. This +guide shows you how. + +## What is a Document? +In Super Editor, a `Document` is a series of nodes - specifically `DocumentNode`s. Different types of +`DocumentNode`s are available for different types of content, such as `ParagraphNode` and `ImageNode`. + +Assembling a `Document` means assembling a list of desired `DocumentNode`s. + +## Construct a `MutableDocument` with Content +The `MutableDocument` constructor accepts a list of `DocumentNode`s as initial content. + +```dart +final document = MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText(text: "Hello, world!"), + ), + ], +); +``` + +## Alter an existing `MutableDocument` +A `MutableDocument` can be altered after construction. + +You can insert nodes. + +```dart +document.insertNodeAt(1, ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText(text: "New paragraph"), +),); +``` + +You can move nodes. + +```dart +document.moveNode(nodeId: "node1", targetIndex: 2); +``` + +You can remove nodes. + +```dart +document.deleteNodeAt(2); +``` + +If your goal is to use a `MutableDocument` in an editor experience, consider wrapping the +`MutableDocument` in an `Editor`, and then use the standard edit pipeline to alter the document's +content. \ No newline at end of file diff --git a/doc/website/source/super-editor/guides/dark-mode-and-light-mode.md b/doc/website/source/super-editor/guides/dark-mode-and-light-mode.md new file mode 100644 index 0000000000..4674f11d5e --- /dev/null +++ b/doc/website/source/super-editor/guides/dark-mode-and-light-mode.md @@ -0,0 +1,79 @@ +--- +title: Dark Mode & Light Mode +--- +# Dark Mode & Light Mode +In Super Editor, there's no explicit concept of light mode and dark mode. Instead, you can implement +your own light mode and dark mode by switching out stylesheets. + +Build a `SuperEditor` widget with a configurable stylesheet. + +```dart +class _MyAppState extends State { + Stylesheet _stylesheet = _lightStylesheet; + + Widget build(BuildContext context) { + return SuperEditor( + stylesheet: _stylesheet, + // ...other properties + ); + } +} +``` + +Define the stylesheet you want to use for light mode, and another stylesheet for dark mode. + +```dart +// Super Editor comes with a standard stylesheet. You +// can copy that stylesheet and adjust for your light +// mode stylesheet. +final _lightStylesheet = defaultStylesheet.copyWith(); + +// For dark mode, you can define an entirely new stylesheet, +// or you can copy and adjust your light mode stylesheet. +// +// Note the background color of your editor is controlled +// by your widget tree outside of SuperEditor, which is +// why this stylesheet doesn't include a background color. +final _darkStylesheet = _lightStylesheet.copyWith( + addRulesAfter: [ + // Make all text a very light gray. + StyleRule( + BlockSelector.all, (doc, docNode) { + return { + "textStyle": const TextStyle( + color: Color(0xFFCCCCCC), + ), + }; + }, + ), + // Make the headers a medium gray. + StyleRule( + const BlockSelector("header1"), (doc, docNode) { + return { + "textStyle": const TextStyle( + color: Color(0xFF888888), + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), (doc, docNode) { + return { + "textStyle": const TextStyle( + color: Color(0xFF888888), + ), + }; + }, + ), + ], +); +``` + +When you're ready to switch brightness modes, rebuild your `SuperEditor` widget with the other +stylesheet. + +```dart +setState(() { + _stylesheet = _darkStylesheet; +}); +``` \ No newline at end of file diff --git a/doc/website/source/super-editor/guides/document-from-markdown.md b/doc/website/source/super-editor/guides/document-from-markdown.md new file mode 100644 index 0000000000..5cfa0073b7 --- /dev/null +++ b/doc/website/source/super-editor/guides/document-from-markdown.md @@ -0,0 +1,30 @@ +--- +title: Document from Markdown +--- +# Document from Markdown +Super Editor supports conversion to and from Markdown. + +## Add a dependency on super_editor_markdown +Markdown support is maintained in a separate package. To get started, add `super_editor_markdown` +to your `pubspec.yaml`. + +```yaml +dependencies: + super_editor_markdown: +``` + +## De-serialize Markdown +De-serialize a Markdown `String` with the supplied top-level function. + +```dart +final document = deserializeMarkdownToDocument(markdown); +``` + +The de-serialized document is a `MutableDocument`. Check other guides to find out how to use it. + +## Serialize Markdown +Serialize a `Document` to a Markdown `String`. + +```dart +final markdown = serializeDocumentToMarkdown(document); +``` diff --git a/doc/website/source/super-editor/guides/index.md b/doc/website/source/super-editor/guides/index.md new file mode 100644 index 0000000000..0162c487be --- /dev/null +++ b/doc/website/source/super-editor/guides/index.md @@ -0,0 +1,11 @@ +--- +title: Super Editor +--- +Welcome to Super Editor, a document editor for Flutter apps. + +Our mission with Super Editor is to make it possible to build any and all document +editors with Flutter. Whether you're building a productivity app, journaling app, note +taking app, or any other app that requires rich text layout and editing, Super Editor +is here to help! + +Get started with Super Editor with our [Quickstart](/super-editor-guides/quickstart) guide. diff --git a/doc/website/source/super-editor/guides/markdown/parsing.md b/doc/website/source/super-editor/guides/markdown/parsing.md new file mode 100644 index 0000000000..7705963ae4 --- /dev/null +++ b/doc/website/source/super-editor/guides/markdown/parsing.md @@ -0,0 +1,113 @@ +--- +title: Parsing Markdown Documents +contentRenderers: + - jinja + - markdown +--- +Super Editor supports parsing of Markdown documents into Super Editor documents. + +To get started with parsing Markdown documents, add the Super Editor Markdown package: + +```yaml +dependencies: + super_editor: any + super_editor_markdown: ^{{ pub.super_editor_markdown.version }} +``` + +Parse a Markdown document by calling the provided global function: + +```dart +final superEditorDocument = deserializeMarkdownToDocument(markdownText); +``` + +## Custom Parsing +Super Editor Markdown offers a few options to customize Markdown parsing. + +### Pre-Configured Syntax +Super Editor Markdown has a concept called `MarkdownSyntax`, which represents an entire +set of syntax preferences. The `deserializeMarkdownToDocument()` function accepts a +`MarkdownSyntax`. + +There are only two options: + * `MarkdownSyntax.superEditor`: Standard Markdown syntax, along with a number of custom + Markdown syntaxes, including paragraph alignment with notation like `:---` for left alignment + and `:---:` for center alignment. + * `MarkdownSyntax.normal`: Standard Markdown syntax as defined by the `markdown` package. + +By default, the `MarkdownSyntax.superEditor` option is used. To restrict parsing to the normal +syntax, pass that option into `deserializeMarkdownToDocument()`. + +```dart +final superEditorDocument = deserializeMarkdownToDocument( + markdownText, + syntax: MarkdownSyntax.normal, +); +``` + +### Encode HTML or Leave Alone +When parsing Markdown text, HTML-based symbols can be converted to HTML escape codes, +or not. + +For example, when encoding HTML characters, given the following Markdown: + +```markdown +Flutter & Dart are > Android +``` + +The parsed output would become: + +``` +Flutter & Dart are gt; Android +``` + +Using HTML escape codes ensures that non-HTML text isn't treated as HTML. + +By default `deserializeMarkdownToDocument()` doesn't make these conversions. + +To automatically convert characters to HTML escape codes, pass `true` for `encodeHtml`. + +```dart +final superEditorDocument = deserializeMarkdownToDocument( + markdownText, + encodeHtml: true, +); +``` + +### Custom Markdown Blocks. +Markdown is sometimes extended with custom block syntaxes. These are non-standard syntaxes, +and they're not understood by standard parsers, like the `markdown` package parser. However, +the `markdown` package parser accepts `BlockSyntax` objects to parse custom Markdown blocks, +and Super Editor Markdown forwards those `BlockSyntax`s. + +To parse custom Markdown block syntaxes, pass your `BlockSyntax`s to +`deserializeMarkdownToDocument()`: + +```dart +final superEditorDocument = deserializeMarkdownToDocument( + markdownText, + customBockSyntax: [ + const TableSyntax(), + ], +); +``` + +### Custom Super Editor Nodes +When parsing custom Markdown syntaxes, you'll need to tell Super Editor Markdown how to +convert those syntaxes into Super Editor `DocumentNode`s. Also, sometimes you might want +to deserialize a standard Markdown syntax into a Super Editor configuration that's different +from how Super Editor handles that syntax by default. + +To customize how Markdown converts into Super Editor documents, provide custom +`ElementToNodeConverter`s to `deserializeMarkdownToDocument()`. + +```dart +final superEditorDocument = deserializeMarkdownToDocument( + markdownText, + customBockSyntax: [ + const TableSyntax(), + ], + customElementToNodeConverters: [ + const MarkdownTableToNodeConverter(), + ], +); +``` \ No newline at end of file diff --git a/doc/website/source/super-editor/guides/markdown/serializing.md b/doc/website/source/super-editor/guides/markdown/serializing.md new file mode 100644 index 0000000000..7df42a583b --- /dev/null +++ b/doc/website/source/super-editor/guides/markdown/serializing.md @@ -0,0 +1,65 @@ +--- +title: Serializing Super Editor Document to Markdown +contentRenderers: + - jinja + - markdown +--- +Super Editor supports serializing Super Editor documents to Markdown documents. + +To get started with serializing Markdown documents, add the Super Editor Markdown package: + +```yaml +dependencies: + super_editor: any + super_editor_markdown: ^{{ pub.super_editor_markdown.version }} +``` + +Serialize a Super Editor document to Markdown document by calling the provided global function: + +```dart +final markdown = serializeDocumentToMarkdown(superEditorDocument); +``` + +## Custom Serialization +Sometimes an app uses non-standard Markdown. Super Editor Markdown provides customization +control to generate that non-standard Markdown. + +### Pre-Configured Syntax +Super Editor Markdown has a concept called `MarkdownSyntax`, which represents an entire +set of syntax preferences. The `serializeDocumentToMarkdown()` function accepts a +`MarkdownSyntax`. + +There are only two options: +* `MarkdownSyntax.superEditor`: Standard Markdown syntax, along with a number of custom + Markdown syntaxes, including paragraph alignment with notation like `:---` for left alignment + and `:---:` for center alignment. +* `MarkdownSyntax.normal`: Standard Markdown syntax as defined by the `markdown` package. + +By default, the `MarkdownSyntax.superEditor` option is used. To restrict serializing to the normal +syntax, pass `MarkdownSyntax.normal` into `serializeDocumentToMarkdown()`. + +```dart +final markdown = serializeDocumentToMarkdown( + superEditorDocument, + syntax: MarkdownSyntax.normal, +); +``` + +### Custom Super Editor Node Converters +Super Editor documents are serialized to Markdown by converting every `DocumentNode` in the +Super Editor document into a block-level Markdown syntax. You might want to convert these nodes +to Markdown in a different way, or you might have your own custom `DocumentNode`s, which require +explicit instructions from you about how to turn them into Markdown blocks. + +To control how various `DocumentNode`s serialize to Markdown blocks, provide +`customNodeSerializers` of type `DocumentNodeMarkdownSerializer` to +`serializeDocumentToMarkdown`: + +```dart +final markdown = serializeDocumentToMarkdown( + superEditorDocument, + syntax: [ + const TableNodeToMarkdownConverter(), + ], +); +``` \ No newline at end of file diff --git a/doc/website/source/super-editor/guides/quickstart.md b/doc/website/source/super-editor/guides/quickstart.md new file mode 100644 index 0000000000..d406f8e5ba --- /dev/null +++ b/doc/website/source/super-editor/guides/quickstart.md @@ -0,0 +1,69 @@ +--- +title: Super Editor Quickstart +contentRenderers: ["jinja", "markdown"] +--- +# Super Editor Quickstart +Super Editor comes with sane defaults to help you get started with an editor experience, quickly. These defaults include support for images, list items, blockquotes, and horizontal rules, as well as selection gestures, and various keyboard shortcuts. + +Drop in the default editor and start editing. + +## Add super_editor to your project +To use super_editor, add a dependency in your pubspec.yaml. + +```yaml +dependencies: + super_editor: {{ pub.super_editor.version }} +``` + +## Display an editor +A visual editor first requires a logical editor. A logical editor holds an underlying document, which the user edits, and a composer to manage the user's selection. + +Initialize the logical editor. + +```dart +class MyApp extends StatefulWidget { + State createState() => _MyApp(); +} + +class _MyApp extends State { + late final Editor _editor; + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + + void initState() { + super.initState(); + + _document = MutableDocument.empty(); + + _composer = MutableDocumentComposer(); + + _editor = Editor(); + } + + void dispose() { + _editor.dispose(); + _composer.dispose(); + _document.dispose(); + + super.dispose(); + } +} +``` + +With the logical pieces ready, you can now display a visual editor. Build a SuperEditor widget and return it from your build() method. + +```dart +class _MyApp extends State { + // ... + + Widget build(BuildContext context) { + return SuperEditor( + editor: _editor, + ); + } +} +``` + +That's all it takes to get started with your very own editor. Run your app, tap in the editor, and start typing! + +The next step is configuration. Check out the other guides for more help. \ No newline at end of file diff --git a/doc/website/source/super-editor/guides/quill/parsing.md b/doc/website/source/super-editor/guides/quill/parsing.md new file mode 100644 index 0000000000..a828f211d6 --- /dev/null +++ b/doc/website/source/super-editor/guides/quill/parsing.md @@ -0,0 +1,190 @@ +--- +title: Parsing Quill Delta Documents +contentRenderers: + - jinja + - markdown +--- +Super Editor supports parsing of Quill Delta documents into Super Editor documents. + +To get started with parsing Quill documents, add the Super Editor Quill package: + +```yaml +dependencies: + super_editor: any + super_editor_quill: ^{{ pub.super_editor_quill.version }} +``` + +A Quill Delta document is a JSON object, which contains a list of `ops`. You can parse +a full document, or you can parse just a list of `ops`. Global functions are provided +for both cases. + +Parse a complete Quill Delta document with `parseQuillDeltaDocument()`: + +```dart +final quillDocument = getMyQuillDocument(); + +final superEditorDocument = parseQuillDeltaDocument(quillDocument); +``` + +Parse a list of Quill Delta operations with `parseQuillDeltaOps()`: + +```dart +final quillOps = getMyQuillOps(); + +final superEditorDocument = parseQuillDeltaOps(quillOps); +``` + +Both parsing methods produce a `MutableDocument`, which you can then use within +a logical `Editor`, and then within a visual `SuperEditor` widget. + +## Custom Parsing +Super Editor Quill supports multiple customizations for document parsing. + +### Custom Blocks, Attributes, and Embeds. +It's common among Quill-based editors to use custom inline attributes, custom +inline embeds, and custom block formats. Super Editor Quill supports custom +parsing behaviors within each of the global parsing methods. + +Add support for your custom blocks, attributes, and embeds by passing those +artifacts to the parser methods: + +```dart +final superEditorDocument = parseQuillDeltaDocument( + quillDocument, + blockFormats: { + const MyVideoBlockFormat(), + ...defaultBlockFormats + }, + inlineFormats: { + const MyUserTagFormat(), + ...defaultInlineFormats + }, + inlineEmbedFormats: [ + const MyMathFormulaFormat(), + ], +); +``` + +#### Custom Blocks +A "block" is a standalone unit within a document, e.g., a paragraph, image, video, etc. + +To parse a custom block, implement `BlockDeltaFormat`. + +The following example implements a hypothetical video block: + +```dart +class VideoBlockFormat implements BlockDeltaFormat { + const VideoBlockFormat(); + + @override + List? applyTo(Operation operation, Editor editor) { + // Pull out the data based on your custom video Delta format... + if (!operation.hasAttribute('video')) { + return null; + } + final videoUrl = operation['video']; + if (videoUrl is! String) { + return null; + } + + // This example assumes that your editor supports a `InsertVideoRequest`. + editor.executeRequests([ + InsertVideoRequest(videoUrl), + ]); + } +} +``` + +#### Custom Inline Attributes +Inline attributes are metadata that applies to spans of text. Typically this metadata represents styles, +such as bold, italic, and underline. These attributes can also mix styling with logical information +such as for links. + +To parse a custom inline attribute, implement `InlineDeltaFormat`. + +The following example implements a hypothetical user tag inline style: + +```dart +class UserTagFormat implements InlineDeltaFormat { + @override + Attribution? from(Operation operation) { + // Pull out the data based on your custom user tag Delta attribute format... + if (!operation.hasAttribute('tag')) { + return null; + } + final tag = operation['tag']; + if (tag is! Map) { + return null; + } + + final userId = tag['userId']; + final userName = tag['userName']; + if (userId is! String || userName is! String) { + return null; + } + + // This hypothetical example assumes that your editor supports a + // `InsertUserTagRequest`. + editor.executeRequests([ + InsertUserTagRequest( + id: userId, + name: userName, + ), + ]); + + return attribution; + } +} +``` + +#### Custom Inline Embeds +Inline embeds are pieces of non-text content that are placed within lines of text. For example, +a math formula, or a tiny bitmap image. + +Currently, Super Editor doesn't support inline widgets. Therefore, the actions that you can take +with inline embeds, is limited. However, Super Editor Quill supports parsing those embeds. + +To parse a custom inline embed, implement `InlineEmbedFormat`. + +The following hypothetical example parses an inline bitmap image: + +```dart +class InlineImageFormat implements InlineEmbedFormat { + @override + bool insert(Editor editor, DocumentComposer composer, Map embed) { + // Pull out the data based on your custom Delta inline image format... + final url = embed['image']; + if (url is! String) { + return false; + } + + // TODO: take whatever action you'd like with the inline image URL. + + return true; + } +} +``` + +### Custom Editor +When Super Editor Quill parses a Quill document, it internally creates an `Editor`, `MutableDocument`, +and a `MutableDocumentComposer`, which is then used to run every content insertion listed in the Quill +document. + +Apps with custom formats might need to take unusual actions to insert those blocks, attributes, and +inline embeds through an `Editor` and into the `MutableDocument`. For this reason, Super Editor Quill +allows you to pass your own `Editor`, with custom request handlers and editables, into the global +parsers. + +```dart +final superEditorDocument = MutableDocument.empty(); +final editor = Editor(/* custom config */); + +final quillDocument = getQuillDocument(); + +// Parse the `quillDocument` into the given `customEditor`, which will use +// the request handlers that you chose to register with your `editor`. +parseQuillDeltaDocument( + quillDocument, + customEditor: editor, +); +``` \ No newline at end of file diff --git a/doc/website/source/super-editor/guides/quill/serializing.md b/doc/website/source/super-editor/guides/quill/serializing.md new file mode 100644 index 0000000000..f643d41aa1 --- /dev/null +++ b/doc/website/source/super-editor/guides/quill/serializing.md @@ -0,0 +1,113 @@ +--- +title: Serializing Super Editor to Quill Deltas +contentRenderers: + - jinja + - markdown +--- +Super Editor supports exporting its document to the Quill Delta format. The process +of going from a Super Editor document to Quill Deltas is known as "serializing" your +Super Editor document. + +To get started with serializing Quill documents, add the Super Editor Quill package: + +```yaml +dependencies: + super_editor: any + super_editor_quill: ^{{ pub.super_editor_quill.version }} +``` + +The `super_editor_quill` package adds an extension method to `MutableDocument`s, which +serializes the document to Quill Deltas. + +To serialize your document to Quill Deltas, call `toQuillDeltas()` on your document: + +```dart +final MutableDocument mySuperEditorDocument = getMySuperEditorDocument(); + +final quillDocument = mySuperEditorDocument.toQuillDeltas(); +``` + +## Custom Blocks, Attributes, and Inline Embeds +It's common among Quill use-cases to extend the Quill Delta format to include new types of +blocks, attributes, and inline embeds. Super Editor Quill allows you to provide your own such +serializers. + +Pass your custom serializers to the `toQuillDeltas()` method: + +```dart +final quillDocument = mySuperEditorDocument.toQuillDeltas( + serializers: [ + const MyMathFormulaSerializer(), + const MyUserTagSerializer(), + ...defaultDeltaSerializers, + ], +); +``` + +### Custom Block Serializers +To serialize a Super Editor `DocumentNode` into a Quill Delta block, implement a +`DeltaSerializer`. + +The following hypothetical example implements a Quill serializer for a video block: + +```dart +class VideoBlockSerializer implements DeltaSerializer { + const VideoBlockSerializer(); + + @override + bool serialize(DocumentNode node, Delta deltas) { + if (node is! VideoNode) { + return false; + } + + deltas.operations.add( + Operation.insert({ + "video": node.url, + }), + ); + + return true; + } +} +``` + +### Custom Inline Serializers +Serializing text includes both the serialization of a block (the paragraph), as well as any number of +inline attributes (e.g., bold, italic, links). Therefore, there are no standalone inline serializers. +Instead, to serialize custom inline attributes, you should extend the existing text serializer. + +The following hypothetical example extends `TextBlockDeltaSerializer` and adds support for a user tag: + +```dart +class MyAppParagraphSerializer extends TextBlockDeltaSerializer { + @override + @protected + Map getInlineAttributesFor(Set superEditorAttributions) { + // Collect any standard Quill Delta attributes that exist in the current + // span of text. + final inlineAttributes = super.getInlineAttributesFor(superEditorAttribions); + + // This hypothetical example assumes that your editor has the concept + // of a `UserTagAttribution`. + final userTag = superEditorAttributions.whereType().firstOrNull; + if (userTag != null) { + // This inline span includes a user tag. Configure the Quill Delta attributes + // based on our custom specifications. + inlineAttributes['tag'] = { + 'userId': userTag.id, + 'userName': userTag.name, + }; + } + + return inlineAttributes; + } +} +``` + +### Custom Inline Embeds +Currently, Super Editor doesn't support the concept of inline widgets, which means there's no +structure that would yield inline embeds. Therefore, no support currently exists for serializing +inline embeds. + +In the future, when Super Editor adjusts the definition of `AttributedText` to support inline +non-text content, `super_editor_quill` will be updated, accordingly. \ No newline at end of file diff --git a/doc/website/source/super-editor/guides/style-a-document.md b/doc/website/source/super-editor/guides/style-a-document.md new file mode 100644 index 0000000000..17330e540e --- /dev/null +++ b/doc/website/source/super-editor/guides/style-a-document.md @@ -0,0 +1,136 @@ +--- +title: Style a Document +--- +# Style a Document +Super Editor includes support for rudimentary stylesheets, which make it easy to apply sweeping +styles across all document content. + +A `Stylesheet` is a priority list of `StyleRule`s. Each `StyleRule` has a `BlockSelector`, which determines for which nodes the rule applies. Think of `BlockSelector`s as rudimentary css selectors. `BlockSelector` can match all nodes, nodes of a specific type, nodes that appear after a specific node type, and so on. A `StyleRule` also includes a `Styler`, which is a function that returns the style metadata. + +`SuperEditor` includes sane defaults for common node types, but you can define your own styles by providing a custom `StyleSheet`. + +## Creating a custom stylesheet + +The easiest way is to create a custom stylesheet is to copy the `defaultStylesheet` and add your rules at the end. For example, to make all level one headers green, create the following stylesheet: + +```dart +const myStyleSheet = defaultStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + // Matches all level one headers. + const BlockSelector("header1"), + (document, node) { + return { + Styles.textStyle: const TextStyle(color: Colors.green), + }; + }, + ), + ], +); +``` + +Then pass it to `SuperEditor`. + +```dart +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return SuperEditor( + // ... + stylesheet: myStyleSheet, + ); + } +} +``` + +See the `Styles` class for the list of keys to the style metadata used by `SuperEditor`. + +## Multiple matching rules + +Multiple `StyleRule`s can match a single node. When that happens, `SuperEditor` attempts to merge them, by looking at each key. For example, consider the following stylesheet: + +```dart +const myStyleSheet = defaultStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + // Matches all level one headers. + const BlockSelector("header1"), + (document, node) { + return { + Styles.textStyle: const TextStyle(color: Colors.green), + }; + }, + ), + StyleRule( + // Matches all nodes. + BlockSelector.all, + (document, node) { + return { + Styles.textStyle: const TextStyle(fontSize: 14), + }; + }, + ) + ], +); +``` + +Both styles will be applied. Each level one header will have green text with a font size of 14px. + +If the styles can't be merged, the first one wins. For example, consider the following stylesheet: + +```dart +const myStyleSheet = defaultStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + // Matches all nodes. + BlockSelector.all, + (document, node) { + return { + Styles.textAlign: TextAlign.center, + }; + }, + ), + StyleRule( + // Matches all nodes. + BlockSelector.all, + (document, node) { + return { + Styles.textAlign: TextAlign.right, + }; + }, + ) + ], +); +``` + +Since we cannot match two different text alignments, the first one is used. All nodes will be center-aligned. + +However, non-conflicting keys are preserved. For example, consider the following stylesheet: + +```dart +const myStyleSheet = defaultStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + // Matches all nodes. + BlockSelector.all, + (document, node) { + return { + Styles.textAlign: TextAlign.center, + }; + }, + ), + StyleRule( + // Matches all nodes. + BlockSelector.all, + (document, node) { + return { + Styles.textAlign: TextAlign.right, + Styles.textStyle: const TextStyle(color: Colors.green), + }; + }, + ) + ], +); +``` + +`SuperEditor` keeps the text alignment from the first rule, ignores the text alignment from the second rule, and keeps the text style from the second rule. As a result, all nodes will be center-aligned and have green text. \ No newline at end of file diff --git a/doc/website/source/super-editor/guides/text-underlines.md b/doc/website/source/super-editor/guides/text-underlines.md new file mode 100644 index 0000000000..186e86f8dd --- /dev/null +++ b/doc/website/source/super-editor/guides/text-underlines.md @@ -0,0 +1,167 @@ +--- +title: Text Underlines +--- +Underlines in Flutter text don't support any styles. They're always the same +thickness, the same distance from the text, the same color as the text, and +have the same square end-caps. It should be possible to control these styles, +but Flutter doesn't expose the lower level text layout controls. + +Editors require custom underline painting for styles and design languages that +don't exactly match the standard text underline. Super Editor supports custom +painting of underlines by manually positioning the painted lines beneath the +relevant spans of text. + +## Special Underlines +Super Editor treats some underlines as special. These include: + + * The user's composing region. + * Spelling errors. + * Grammar errors. + +For these special underlines, please see other guides and references to +work with them. + +## Custom Underlines +Super Editor supports painting custom text underlines. + +### Attribute the Text +First, attribute the desired text with a `CustomUnderlineAttribution`, which +specifies the visual type of underline. Super Editor includes some pre-defined +type names, but you can use any name. + +```dart +final underlineAttribution = CustomUnderlineAttribution( + CustomUnderlineAttribution.standard, +); + +AttributedText( + "This text includes an underline.", + AttributedSpans( + attributions: [ + SpanMarker(attribution: underlineAttribution, offset: 22, markerType: SpanMarkerType.start), + SpanMarker(attribution: underlineAttribution, offset: 30, markerType: SpanMarkerType.end), + ], + ), +) +``` + +### Style the Underlines +Add a style rule to your stylesheet, which specifies all underline styles. + +```dart +final myStylesheet = defaultStylesheet.copyWith( + addRulesBefore: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + // The `underlineStyles` key is used to identify a collection of + // underline styles. + // + // Within the `CustomUnderlineStyles`, you should add an entry + // for every underline type name that your app uses, and then + // specify the `UnderlineStyle` to paint that underline. + UnderlineStyler.underlineStyles: CustomUnderlineStyles({ + // In this example, we specify only one underline style. This + // style is for the `standard` underline type, and it paints + // a green squiggly underline. + CustomUnderlineAttribution.standard: SquiggleUnderlineStyle( + color: Colors.green, + ), + // You can add more types and styles here... + }), + }; + }, + ), + ], +); +``` + +### Custom Styles +Super Editor provides a few underline styles, which offer some configuration, +including `StraightUnderlineStyle`, `DottedUnderlineStyle`, and `SquiggleUnderlineStyle`. +However, these may not meet your needs. + +To paint your own underline, you need to create two classes: a subclass of `UnderlineStyle` +and a `CustomPainter` that actually does the painting. + +The `UnderlineStyle` subclass is like a view-model, and the `CustomPainter` uses +properties from the `UnderlineStyle` to decide how to paint the underline. + +For example, the following is the implementation of `StraightUnderlineStyle`. + +```dart +class StraightUnderlineStyle implements UnderlineStyle { + const StraightUnderlineStyle({ + this.color = const Color(0xFF000000), + this.thickness = 2, + this.capType = StrokeCap.square, + }); + + final Color color; + final double thickness; + final StrokeCap capType; + + @override + CustomPainter createPainter(List underlines) { + return StraightUnderlinePainter(underlines: underlines, color: color, thickness: thickness, capType: capType); + } +} +``` + +The job of the `UnderlineStyle` is to take a collection of properties and +pass them in some form to a `CustomPainter`. In the case of `StraightUnderlineStyle`, +the properties are passed to a `StraightUnderlinePainter`. The `createPainter()` +method is called by Super Editor at the appropriate time. + +To complete the example, the following is the implementation of `StraightUnderlinePainter`. + +```dart +class StraightUnderlinePainter extends CustomPainter { + const StraightUnderlinePainter({ + required List underlines, + this.color = const Color(0xFF000000), + this.thickness = 2, + this.capType = StrokeCap.square, + }) : _underlines = underlines; + + final List _underlines; + + final Color color; + final double thickness; + final StrokeCap capType; + + @override + void paint(Canvas canvas, Size size) { + if (_underlines.isEmpty) { + return; + } + + final linePaint = Paint() + ..style = PaintingStyle.stroke + ..color = color + ..strokeWidth = thickness + ..strokeCap = capType; + for (final underline in _underlines) { + canvas.drawLine(underline.start, underline.end, linePaint); + } + } + + @override + bool shouldRepaint(StraightUnderlinePainter oldDelegate) { + return color != oldDelegate.color || + thickness != oldDelegate.thickness || + capType != oldDelegate.capType || + !const DeepCollectionEquality().equals(_underlines, oldDelegate._underlines); + } +} +``` + +By providing your own version of these two classes, you can paint any underline you desire. + +With your own `UnderlineStyle` defined, use it in your stylesheet as discussed previously. + +As you implement your own underline painting, you might be confused where some of these +underline classes come from. Note that some of them are lower level than Super Editor - +they come from the `super_text_layout` package, which is another package in the +Super Editor mono repo. \ No newline at end of file diff --git a/golden_runner/.gitignore b/golden_runner/.gitignore new file mode 100644 index 0000000000..96486fd930 --- /dev/null +++ b/golden_runner/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# 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/ diff --git a/golden_runner/.metadata b/golden_runner/.metadata new file mode 100644 index 0000000000..2b69774845 --- /dev/null +++ b/golden_runner/.metadata @@ -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: "f8afcd5aa01b3ae6c55cb6e4c9fa4171e27a92f6" + channel: "master" + +project_type: package diff --git a/golden_runner/CHANGELOG.md b/golden_runner/CHANGELOG.md new file mode 100644 index 0000000000..41cc7d8192 --- /dev/null +++ b/golden_runner/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/golden_runner/LICENSE b/golden_runner/LICENSE new file mode 100644 index 0000000000..ba75c69f7f --- /dev/null +++ b/golden_runner/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/golden_runner/README.md b/golden_runner/README.md new file mode 100644 index 0000000000..13e9cf4570 --- /dev/null +++ b/golden_runner/README.md @@ -0,0 +1,41 @@ +This package contains a tool to run golden tests and update golden files in a docker container. + +The command should be run from the root of the package being tested. + +## Activate the package: + +```console +dart pub global activate --source path ./golden_runner +``` + +## Run golden tests: + +``` +# run all tests +flutter pub run ../golden_runner/tool/goldens test + +# run a single test +flutter pub run ../golden_runner/tool/goldens test --plain-name "something" + +# run all tests in a directory +flutter pub run ../golden_runner/tool/goldens test test_goldens/my_dir + +# run a single test in a directory +flutter pub run ../golden_runner/tool/goldens test --plain-name "something" test_goldens/my_dir +``` + +## Update golden files: + +``` +# update all goldens +flutter pub run ../golden_runner/tool/goldens update + +# update all goldens in a directory +flutter pub run ../golden_runner/tool/goldens update test_goldens/my_dir + +# update a single golden +flutter pub run ../golden_runner/tool/goldens update --plain-name "something" + +# update a single golden in a directory +flutter pub run ../golden_runner/tool/goldens update --plain-name "something" test_goldens/my_dir +``` diff --git a/golden_runner/analysis_options.yaml b/golden_runner/analysis_options.yaml new file mode 100644 index 0000000000..a5744c1cfb --- /dev/null +++ b/golden_runner/analysis_options.yaml @@ -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 diff --git a/golden_runner/bin/goldens.dart b/golden_runner/bin/goldens.dart new file mode 100644 index 0000000000..32a696c56d --- /dev/null +++ b/golden_runner/bin/goldens.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +// ignore: depend_on_referenced_packages +import 'package:args/command_runner.dart'; +import 'package:golden_runner/golden_runner.dart'; + +Future main(List arguments) async { + final runner = CommandRunner("goldens", "A tool to run and update golden tests using docker") + ..addCommand(GoldenTestCommand()) + ..addCommand(UpdateGoldensCommand()); + + try { + await runner.run(arguments); + } on UsageException catch (e) { + stdout.write(e); + } +} diff --git a/golden_runner/lib/golden_runner.dart b/golden_runner/lib/golden_runner.dart new file mode 100644 index 0000000000..0fc9aef473 --- /dev/null +++ b/golden_runner/lib/golden_runner.dart @@ -0,0 +1,3 @@ +library golden_runner; + +export 'src/commands.dart'; diff --git a/golden_runner/lib/src/commands.dart b/golden_runner/lib/src/commands.dart new file mode 100644 index 0000000000..a44131022c --- /dev/null +++ b/golden_runner/lib/src/commands.dart @@ -0,0 +1,356 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as path; + +/// A [Command] which runs golden tests. +/// +/// The command run the tests in a Linux Docker container. It expects to be running in a "golden_tester" directory. +/// +/// Usage: `flutter pub run test ` +/// +/// Options: +/// +/// `--plain-name "test-name"`: Runs only the tests containing the given value in the test name. +/// +/// The target can be a directory or a file. This argument is optional. +/// +/// This is intended to be added as an [CommandRunner] command. +class GoldenTestCommand extends Command { + GoldenTestCommand() { + argParser.addOption( + 'plain-name', + help: 'A plain-text substring of the names of tests to run', + ); + } + + @override + String get name => 'test'; + + @override + String get description => 'Runs golden tests'; + + @override + Future run() async { + final args = argResults!; + + // The tool must run from the root of the package being tested. + // For example, /super_editor/super_text_layout. + // We take the last part of the directory as the package directory. + final packageDirectory = path.split(Directory.current.path).last; + + // Builds the image used to run the container. + // We can build the image even if it already exists. + // Docker will cache each step used in the Dockerfile, so subsequent builds will be faster. + await _buildDockerImage(); + + // Arguments that are placed after 'flutter test test_goldens'. + final cmdArguments = []; + + final name = args['plain-name']; + if (name is String) { + cmdArguments + ..add('--plain-name') + ..add(name); + } + + stdout.writeln('Running golden tests'); + + // Other arguments passed at the end of the command. + // For example, the test directory. + final rest = [...args.rest]; + + late String testDirOrTestFileName; + late String testBaseDirectory; + + if (rest.isNotEmpty) { + // An argument was passed after the command options. + // For example, in "flutter pub run tool/goldens test my_test_dir", "my_test_dir" is the first argument + // after the command. + // Use the first argument after the command options as the test directory or test file name + // and remove it from the rest. + testDirOrTestFileName = rest.removeAt(0); + + if (path.extension(testDirOrTestFileName).isNotEmpty) { + // A test file was given. + // Extract the directory name so we can list the sub-directories. + testBaseDirectory = path.dirname(testDirOrTestFileName); + } else { + // A test directory was given. + // Don't try to extract the directory name because it can return an empty string if it's the root directory. + // For example, passing "test_goldens" to dirname would return "". + testBaseDirectory = testDirOrTestFileName; + } + } else { + testDirOrTestFileName = 'test_goldens'; + testBaseDirectory = testDirOrTestFileName; + } + + final dirs = _findAllTestDirectories(testBaseDirectory); + + final volumeMappings = _generateFailureDirectoriesMappings(packageDirectory, dirs); + + // Runs the container. + // + // --rm: Removes the container when it exits. + // + // --workdir: Sets the process working directory to the given package directory in the container. + await _runProcess( + executable: 'docker', + arguments: [ + 'run', + '--rm', + ...volumeMappings, + '--workdir', + '/golden_tester/$packageDirectory', + 'supereditor_golden_tester', + 'flutter', + 'test', + testDirOrTestFileName, + ...rest, + ...cmdArguments, + ], + description: 'Golden tests', + throwOnError: false, + ); + + // After running the tests, we don't need the image anymore. Remove it. + await _removeDockerImage(); + + // Mapping the failure directories causes them to be created automatically, even without any failing test. + // Remove all the empty failure directories. + for (final dirName in dirs) { + final dir = Directory('$dirName/failures'); + if (dir.existsSync() && dir.listSync().isEmpty) { + dir.deleteSync(); + } + } + } + + /// Returns a list of docker command line arguments to configure the volume mappings for the test failure directories. + /// + /// [testDirectories] must be a list of relative paths to the working directory. + /// + /// This mappings are used so when a failure happens, the failure images are save in the host OS. + List _generateFailureDirectoriesMappings(String packageDirectory, List testDirectories) { + final mappings = []; + + for (final dir in testDirectories) { + mappings.add('-v'); + mappings.add('${Directory.current.path}/$dir/failures:/golden_tester/$packageDirectory/$dir/failures'); + } + + return mappings; + } + + /// Returns a list of sub-directories inside a root test directory as relative paths to the working directory. + /// + /// For example, "test_goldens/editor", "test_goldens/components". + /// + /// Ignores "failures" directories. + List _findAllTestDirectories(String rootTestDir) { + final dir = Directory(rootTestDir); + final subDirs = dir + .listSync(recursive: true) // + .whereType() + // Ensure we use linux path separator. + // The tool can run in a host OS which uses a different path separator. + // Without this, the volume ma + .map((e) => e.path.replaceAll(path.separator, '/')) + .where((e) => !e.endsWith('failures')) + .toList(); + return [rootTestDir, ...subDirs]; + } +} + +/// A [Command] which updates golden files. +/// +/// The command run the tests in a Linux Docker container. It expects to be running in a "golden_tester" directory. +/// +/// Usage: `flutter pub run update ` +/// +/// Options: +/// +/// `--plain-name "test-name"`: Update only the tests containing the given value in the test name. +/// +/// The target can be a directory or a file. This argument is optional. +/// +/// This is intended to be added as an [CommandRunner] command. +class UpdateGoldensCommand extends Command { + UpdateGoldensCommand() { + argParser.addOption( + 'plain-name', + help: 'A plain-text substring of the names of tests to run', + ); + } + + @override + String get description => 'Updates golden files'; + + @override + String get name => 'update'; + + @override + Future run() async { + final args = argResults!; + + // The tool must run from the root of the package being tested. + // For example, /super_editor/super_text_layout. + // We take the last part of the directory as the package directory. + final packageDirectory = path.split(Directory.current.path).last; + + // Builds the image used to run the container. + // We can build the image even if it already exists. + // Docker will cache each step used in the Dockerfile, so subsequent builds will be faster. + await _buildDockerImage(); + + // Arguments that are placed after 'flutter test test_goldens'. + final cmdArguments = []; + + final name = args['plain-name']; + if (name is String) { + cmdArguments + ..add('--plain-name') + ..add(name); + } + + stdout.writeln('Updating golden files'); + + // Other arguments passed at the end of the command. + // For example, the test directory. + final rest = [...args.rest]; + + late String testDirOrTestFileName; + late String testBaseDirectory; + + if (rest.isNotEmpty) { + // An argument was passed after the command options. + // For example, in "flutter pub run tool/goldens test my_test_dir", "my_test_dir" is the first argument + // after the command. + // Use the first argument after the command options as the test directory or test file name + // and remove it from the rest. + testDirOrTestFileName = rest.removeAt(0); + + if (path.extension(testDirOrTestFileName).isNotEmpty) { + // A test file was given. + // Extract the directory name so we can list the sub-directories. + testBaseDirectory = path.dirname(testDirOrTestFileName); + } else { + // A test directory was given. + // Don't try to extract the directory name because it can return an empty string if it's the root directory. + // For example, passing "test_goldens" to dirname would return "". + testBaseDirectory = testDirOrTestFileName; + } + } else { + testDirOrTestFileName = 'test_goldens'; + testBaseDirectory = testDirOrTestFileName; + } + + // Runs the container. + // + // --rm: Removes the container when it exits. + // + // -v: Mounts the directory containing the tests of the host machine into the container. + // This is used to write the new golden files directly on the host OS. + // + // --workdir: Sets the working directory to /super_editor/super_text_layout in the container. + await _runProcess( + executable: 'docker', + arguments: [ + 'run', + '--rm', + '-v', + '${Directory.current.path}/$testBaseDirectory:/golden_tester/$packageDirectory/$testBaseDirectory', + '--workdir', + '/golden_tester/$packageDirectory', + 'supereditor_golden_tester', + 'flutter', + 'test', + '--update-goldens', + testDirOrTestFileName, + ...rest, + ...cmdArguments, + ], + description: 'Update goldens', + throwOnError: false, + ); + + // After running the tests, we don't need the image anymore. Remove it. + await _removeDockerImage(); + } +} + +/// Builds a linux docker image to run the tests. +/// +/// The golden_tester.Dockerfile is used to build this image. +Future _buildDockerImage() async { + stdout.write('building image'); + + await _runProcess( + executable: 'docker', + arguments: [ + 'build', + '-f', + './golden_tester.Dockerfile', + '-t', + 'supereditor_golden_tester', + '.', + ], + // We need to use the repository root as the working directory to be able to copy all of the files + // in this repository, not just the package directory. + workingDirectory: '../', + description: 'Image build', + ); +} + +/// Removes the image built by the golden tester. +Future _removeDockerImage() async { + await _runProcess( + executable: 'docker', + arguments: [ + 'image', 'rm', // + '-f', + 'supereditor_golden_tester', + ], + description: 'Removing image', + throwOnError: false, + ); +} + +/// Runs [executable] with the given [arguments]. +/// +/// [executable] could be an absolute path or it could be resolved from the PATH. +/// +/// The [arguments] must contain any modifiers, like `-`, `--` or `/`. +/// +/// Use [workingDirectory] to set the working directory for the process. +/// +/// The child process stdout and stderr are written to the current process stdout. +/// +/// If [throwOnError] is `true`, throws an exception if the process exits with a non-zero exit code. +/// +/// If [throwOnError] is `false`, the function returns the exit code. +Future _runProcess({ + required String executable, + required List arguments, + required String description, + String? workingDirectory, + bool throwOnError = true, +}) async { + final process = await Process.start( + executable, + arguments, + workingDirectory: workingDirectory, + ); + + await stdout.addStream(process.stdout); + await stderr.addStream(process.stderr); + + final exitCode = await process.exitCode; + + if (exitCode != 0 && throwOnError) { + throw Exception('$description failed'); + } + + return exitCode; +} diff --git a/golden_runner/pubspec.yaml b/golden_runner/pubspec.yaml new file mode 100644 index 0000000000..2f03de03be --- /dev/null +++ b/golden_runner/pubspec.yaml @@ -0,0 +1,17 @@ +name: golden_runner +description: Commands to test and update goldens in a Docker container +version: 0.0.1 +homepage: + +executables: + goldens: + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + args: ^2.3.1 + meta: ^1.8.0 + path: ^1.8.3 + +flutter: diff --git a/golden_tester.Dockerfile b/golden_tester.Dockerfile new file mode 100644 index 0000000000..b61f350cbd --- /dev/null +++ b/golden_tester.Dockerfile @@ -0,0 +1,24 @@ +FROM ubuntu:latest + +ENV FLUTTER_HOME=${HOME}/sdks/flutter +ENV PATH ${PATH}:${FLUTTER_HOME}/bin:${FLUTTER_HOME}/bin/cache/dart-sdk/bin + +USER root + +RUN apt update + +RUN apt install -y git curl unzip + +# Print the Ubuntu version. Useful when there are failing tests. +RUN cat /etc/lsb-release + +# Invalidate the cache when flutter pushes a new commit. +ADD https://api.github.com/repos/flutter/flutter/git/refs/heads/master ./flutter-latest-master + +RUN git clone https://github.com/flutter/flutter.git ${FLUTTER_HOME} + +RUN flutter doctor + +# Copy the whole repo. +# We need this because we use local dependencies. +COPY ./ /golden_tester diff --git a/super_clones/bear/.gitignore b/super_clones/bear/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/super_clones/bear/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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 +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/super_clones/bear/.metadata b/super_clones/bear/.metadata new file mode 100644 index 0000000000..6e13c77392 --- /dev/null +++ b/super_clones/bear/.metadata @@ -0,0 +1,30 @@ +# 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: "a14f74ff3a1cbd521163c5f03d68113d50af93d3" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3 + base_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3 + - platform: macos + create_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3 + base_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_clones/bear/README.md b/super_clones/bear/README.md new file mode 100644 index 0000000000..a5c36eee5b --- /dev/null +++ b/super_clones/bear/README.md @@ -0,0 +1,16 @@ +# bear + +A new Flutter project. + +## 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://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/super_clones/bear/analysis_options.yaml b/super_clones/bear/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_clones/bear/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_clones/bear/lib/app.dart b/super_clones/bear/lib/app.dart new file mode 100644 index 0000000000..5de4af84cc --- /dev/null +++ b/super_clones/bear/lib/app.dart @@ -0,0 +1,19 @@ +import 'package:bear/features/home_screen.dart'; +import 'package:flutter/material.dart'; + +class DashApp extends StatelessWidget { + const DashApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Dash', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.red), + useMaterial3: true, + ), + home: const HomeScreen(), + debugShowCheckedModeBanner: false, + ); + } +} diff --git a/super_clones/bear/lib/features/home_screen.dart b/super_clones/bear/lib/features/home_screen.dart new file mode 100644 index 0000000000..9e191e95a4 --- /dev/null +++ b/super_clones/bear/lib/features/home_screen.dart @@ -0,0 +1,53 @@ +import 'package:bear/infrastructure/editor/editor.dart'; +import 'package:flutter/material.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildToolbar(), + _buildNoteList(), + _buildDivider(), + Expanded( + child: _buildEditor(), + ), + ], + ), + ); + } + + Widget _buildToolbar() { + return Container( + width: 180, + color: const Color(0xFF303033), + ); + } + + Widget _buildNoteList() { + return Container( + width: 285, + color: Colors.white, + ); + } + + Widget _buildDivider() { + return Container( + width: 1, + color: const Color(0xFFDDDDDD), + ); + } + + Widget _buildEditor() { + return TextEditor(); + } +} diff --git a/super_clones/bear/lib/infrastructure/editor/components.dart b/super_clones/bear/lib/infrastructure/editor/components.dart new file mode 100644 index 0000000000..b344b40c4f --- /dev/null +++ b/super_clones/bear/lib/infrastructure/editor/components.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +const dashComponentBuilders = [ + DashListItemComponentBuilder(), + ...defaultComponentBuilders, +]; + +class DashListItemComponentBuilder implements ComponentBuilder { + const DashListItemComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + if (node is! ListItemNode) { + return null; + } + if (node.type != ListItemType.unordered) { + return null; + } + + return UnorderedListItemComponentViewModel( + nodeId: node.id, + indent: node.indent, + text: node.text, + textStyleBuilder: noStyleBuilder, + selectionColor: const Color(0x00000000), + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! UnorderedListItemComponentViewModel) { + return null; + } + + return UnorderedListItemComponent( + componentKey: componentContext.componentKey, + text: componentViewModel.text, + styleBuilder: componentViewModel.textStyleBuilder, + indent: componentViewModel.indent, + indentCalculator: (TextStyle textStyle, int indent) { + return (textStyle.fontSize! * 0.60) * 3 * (indent + 1); + }, + dotStyle: const ListItemDotStyle( + color: Colors.red, + size: Size(6, 6), + ), + textSelection: componentViewModel.selection, + selectionColor: componentViewModel.selectionColor, + highlightWhenEmpty: componentViewModel.highlightWhenEmpty, + underlines: componentViewModel.createUnderlines(), + ); + } +} diff --git a/super_clones/bear/lib/infrastructure/editor/editor.dart b/super_clones/bear/lib/infrastructure/editor/editor.dart new file mode 100644 index 0000000000..547819bd25 --- /dev/null +++ b/super_clones/bear/lib/infrastructure/editor/editor.dart @@ -0,0 +1,236 @@ +import 'package:bear/infrastructure/editor/components.dart'; +import 'package:bear/infrastructure/editor/stylesheet.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class TextEditor extends StatefulWidget { + const TextEditor({super.key}); + + @override + State createState() => _TextEditorState(); +} + +class _TextEditorState extends State { + late final MutableDocument _document; + final _composer = MutableDocumentComposer(); + late final Editor _editor; + + bool _isTopToolbarVisible = true; + bool _isFormattingToolbarVisible = false; + + @override + void initState() { + super.initState(); + + _document = deserializeMarkdownToDocument(_testDocumentContent); + _editor = createDefaultDocumentEditor(document: _document, composer: _composer) + ..addListener( + FunctionalEditListener(_hideTopToolbarOnContentChange), + ); + } + + /// Inspects every editor change and hides the top toolbar whenever the user makes + /// a content change, e.g., types some text, deletes some text. + void _hideTopToolbarOnContentChange(changeList) { + for (final change in changeList) { + if (change is! SelectionChangeEvent && change is! ComposingRegionChangeEvent) { + // We assume any non-selection and non-composing event is a content change. + // Hide the toolbar. + _hideTopToolbar(); + return; + } + } + } + + void _showTopToolbar() { + if (_isTopToolbarVisible) { + return; + } + + setState(() { + _isTopToolbarVisible = true; + }); + } + + void _hideTopToolbar() { + if (!_isTopToolbarVisible) { + return; + } + + setState(() { + _isTopToolbarVisible = false; + }); + } + + void _toggleFormattingToolbar() { + if (_isFormattingToolbarVisible) { + _hideFormattingToolbar(); + } else { + _showFormattingToolbar(); + } + } + + void _showFormattingToolbar() { + if (_isFormattingToolbarVisible) { + return; + } + + setState(() { + _isFormattingToolbarVisible = true; + }); + } + + void _hideFormattingToolbar() { + if (!_isFormattingToolbarVisible) { + return; + } + + setState(() { + _isFormattingToolbarVisible = false; + }); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerHover: (_) => _showTopToolbar(), + behavior: HitTestBehavior.opaque, + child: ColoredBox( + color: Colors.white, + child: Stack( + children: [ + Positioned.fill( + child: SuperEditor( + editor: _editor, + document: _document, + composer: _composer, + componentBuilders: dashComponentBuilders, + stylesheet: dashStylesheet, + ), + ), + Positioned( + left: 0, + right: 0, + top: 0, + child: _buildTopToolbar(), + ), + AnimatedPositioned( + left: 0, + right: 0, + bottom: _isFormattingToolbarVisible ? 24 : -50, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOutCirc, + child: _buildFormattingToolbar(), + ), + ], + ), + ), + ); + } + + Widget _buildTopToolbar() { + return AbsorbPointer( + // ^ Absorb pointer so that when the user hovers over this bar, the user can drag the + // window around, instead of tapping through to the document and placing moving the caret. + child: AnimatedOpacity( + opacity: _isTopToolbarVisible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + child: SizedBox( + height: 54, + child: Row( + children: [ + const Spacer(), + IconButton( + onPressed: _toggleFormattingToolbar, + icon: const Icon( + Icons.format_bold, + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.info_outline, + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.more_vert, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildFormattingToolbar() { + return Center( + child: IconTheme( + data: IconThemeData( + size: 18, + color: Color(0xFF777777), + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFDDDDDD), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + onPressed: () {}, + icon: Icon(Icons.text_fields), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.list), + ), + const SizedBox(width: 24), + IconButton( + onPressed: () {}, + icon: Icon(Icons.format_bold), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.format_italic), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.highlight), + ), + const SizedBox(width: 24), + IconButton( + onPressed: () {}, + icon: Icon(Icons.link), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.image_outlined), + ), + ], + ), + ), + ), + ); + } +} + +const _testDocumentContent = '''# Editor Decorations +## Padding +* There's about 150px of padding on top. +* ~600px padding on the bottom +* Horizontally centered with a max document width + +## Top Bar +* Shows itself when the mouse moves +* Disappears when the user starts typing +* Bottom border has nuanced appearance rules + * If the top bar is currently visible, but the bottom border isn't + * Any scrolling will cause the bottom bar to fade in +'''; diff --git a/super_clones/bear/lib/infrastructure/editor/stylesheet.dart b/super_clones/bear/lib/infrastructure/editor/stylesheet.dart new file mode 100644 index 0000000000..c4b33e10bc --- /dev/null +++ b/super_clones/bear/lib/infrastructure/editor/stylesheet.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:super_editor/super_editor.dart'; + +final dashStylesheet = Stylesheet( + rules: [ + ...defaultStylesheet.rules, + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF444444), + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(top: 40), + Styles.textStyle: const TextStyle( + fontSize: 24, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(top: 16), + Styles.textStyle: const TextStyle( + fontSize: 18, + ), + }; + }, + ), + StyleRule( + const BlockSelector("listItem"), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(top: 10), + Styles.textStyle: const TextStyle( + fontSize: 14, + ), + }; + }, + ), + ], + inlineTextStyler: defaultStylesheet.inlineTextStyler, +); diff --git a/super_clones/bear/lib/main.dart b/super_clones/bear/lib/main.dart new file mode 100644 index 0000000000..a3e043e076 --- /dev/null +++ b/super_clones/bear/lib/main.dart @@ -0,0 +1,6 @@ +import 'package:bear/app.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(const DashApp()); +} diff --git a/super_clones/bear/macos/.gitignore b/super_clones/bear/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/super_clones/bear/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/super_clones/bear/macos/Flutter/Flutter-Debug.xcconfig b/super_clones/bear/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..4b81f9b2d2 --- /dev/null +++ b/super_clones/bear/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_clones/bear/macos/Flutter/Flutter-Release.xcconfig b/super_clones/bear/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5caa9d1579 --- /dev/null +++ b/super_clones/bear/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_clones/bear/macos/Flutter/GeneratedPluginRegistrant.swift b/super_clones/bear/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..8236f5728c --- /dev/null +++ b/super_clones/bear/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/super_clones/bear/macos/Podfile b/super_clones/bear/macos/Podfile new file mode 100644 index 0000000000..c795730db8 --- /dev/null +++ b/super_clones/bear/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14' + +# 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__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/super_clones/bear/macos/Podfile.lock b/super_clones/bear/macos/Podfile.lock new file mode 100644 index 0000000000..4768d52553 --- /dev/null +++ b/super_clones/bear/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - FlutterMacOS (1.0.0) + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 + +PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 + +COCOAPODS: 1.15.2 diff --git a/super_clones/bear/macos/Runner.xcodeproj/project.pbxproj b/super_clones/bear/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..7de4a61974 --- /dev/null +++ b/super_clones/bear/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,803 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + 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 */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 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 */; }; + A022DCBB263A55F92B3D890F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5902A74075BFE0C3D48C8D48 /* Pods_Runner.framework */; }; + EC107CE66F186F0DD5FA70C2 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F38700AE193CFFFB919C20F7 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 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 */ + 10C3413F8C897B6E53597A9B /* 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 = ""; }; + 2D0B665771D6AE17F3FA431C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* bear.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bear.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 3BAE771960643842B59230E0 /* 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 = ""; }; + 56147A70B00CF7F4D1B3BF3D /* 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 = ""; }; + 5902A74075BFE0C3D48C8D48 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + AA46F0ABA89EFCB9A55708F4 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + B273427908DDEB7225FB9520 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + F38700AE193CFFFB919C20F7 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EC107CE66F186F0DD5FA70C2 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A022DCBB263A55F92B3D890F /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + E477D87D0C74F54658FF46B2 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* bear.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 5902A74075BFE0C3D48C8D48 /* Pods_Runner.framework */, + F38700AE193CFFFB919C20F7 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + E477D87D0C74F54658FF46B2 /* Pods */ = { + isa = PBXGroup; + children = ( + 3BAE771960643842B59230E0 /* Pods-Runner.debug.xcconfig */, + 56147A70B00CF7F4D1B3BF3D /* Pods-Runner.release.xcconfig */, + 10C3413F8C897B6E53597A9B /* Pods-Runner.profile.xcconfig */, + AA46F0ABA89EFCB9A55708F4 /* Pods-RunnerTests.debug.xcconfig */, + 2D0B665771D6AE17F3FA431C /* Pods-RunnerTests.release.xcconfig */, + B273427908DDEB7225FB9520 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 5906E7559FCC3CD340AE5906 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 1339FF43BED015CAAAC10DC7 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 16F0E29558E0C96104A0342E /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* bear.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 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 */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 1339FF43BED015CAAAC10DC7 /* [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; + }; + 16F0E29558E0C96104A0342E /* [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; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + 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"; + }; + 5906E7559FCC3CD340AE5906 /* [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-RunnerTests-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 */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 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 = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AA46F0ABA89EFCB9A55708F4 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bear.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bear.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bear"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2D0B665771D6AE17F3FA431C /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bear.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bear.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bear"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B273427908DDEB7225FB9520 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bear.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bear.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bear"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + 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", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + 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 */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 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 */; +} diff --git a/super_clones/bear/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/bear/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/bear/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/bear/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_clones/bear/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..39fbc67f7d --- /dev/null +++ b/super_clones/bear/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/bear/macos/Runner.xcworkspace/contents.xcworkspacedata b/super_clones/bear/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_clones/bear/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_clones/bear/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/bear/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/bear/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/bear/macos/Runner/AppDelegate.swift b/super_clones/bear/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..b3c1761412 --- /dev/null +++ b/super_clones/bear/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..82b6f9d9a3 Binary files /dev/null and b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000..13b35eba55 Binary files /dev/null and b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000..0a3f5fa40f Binary files /dev/null and b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000..bdb57226d5 Binary files /dev/null and b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000000..f083318e09 Binary files /dev/null and b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000..326c0e72c9 Binary files /dev/null and b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000..2f1632cfdd Binary files /dev/null and b/super_clones/bear/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/super_clones/bear/macos/Runner/Base.lproj/MainMenu.xib b/super_clones/bear/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/super_clones/bear/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/bear/macos/Runner/Configs/AppInfo.xcconfig b/super_clones/bear/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..7be447b7ad --- /dev/null +++ b/super_clones/bear/macos/Runner/Configs/AppInfo.xcconfig @@ -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 = bear + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.bear + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. diff --git a/super_clones/bear/macos/Runner/Configs/Debug.xcconfig b/super_clones/bear/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/super_clones/bear/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_clones/bear/macos/Runner/Configs/Release.xcconfig b/super_clones/bear/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/super_clones/bear/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_clones/bear/macos/Runner/Configs/Warnings.xcconfig b/super_clones/bear/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/super_clones/bear/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/super_clones/bear/macos/Runner/DebugProfile.entitlements b/super_clones/bear/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/super_clones/bear/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/super_clones/bear/macos/Runner/Info.plist b/super_clones/bear/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/super_clones/bear/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/super_clones/bear/macos/Runner/MainFlutterWindow.swift b/super_clones/bear/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..18ff6ede42 --- /dev/null +++ b/super_clones/bear/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,24 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + + // Remove the title bar and expand content to fill the entire window. + self.setContentSize(NSSize(width: 1000, height: 650)) + self.styleMask.update(with: StyleMask.fullSizeContentView) + self.titleVisibility = TitleVisibility.hidden + self.titlebarAppearsTransparent = true + self.backgroundColor = NSColor.white + self.toolbar = NSToolbar() + self.toolbarStyle = ToolbarStyle.unified + } +} diff --git a/super_clones/bear/macos/Runner/Release.entitlements b/super_clones/bear/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/super_clones/bear/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/super_clones/bear/macos/RunnerTests/RunnerTests.swift b/super_clones/bear/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..61f3bd1fc5 --- /dev/null +++ b/super_clones/bear/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_clones/bear/pubspec.lock b/super_clones/bear/pubspec.lock new file mode 100644 index 0000000000..b73b2dbd24 --- /dev/null +++ b/super_clones/bear/pubspec.lock @@ -0,0 +1,672 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + attributed_text: + dependency: transitive + description: + name: attributed_text + sha256: "177ea01f58a8d8df279f4066834375a2009bdd304d559c084bb06f784b258477" + url: "https://pub.dev" + source: hosted + version: "0.4.5" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + url: "https://pub.dev" + source: hosted + version: "2.0.28" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_test_robots: + dependency: transitive + description: + name: flutter_test_robots + sha256: "3b00f2081148bde55190997c2772f934ad2f4529cbcfc4ccfa593f8ddc117a28" + url: "https://pub.dev" + source: hosted + version: "0.0.24" + flutter_test_runners: + dependency: transitive + description: + name: flutter_test_runners + sha256: cc575117ed66a79185a26995399d7048341517a1bd21188cb43753739627832d + url: "https://pub.dev" + source: hosted + version: "0.0.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + follow_the_leader: + dependency: transitive + description: + name: follow_the_leader + sha256: "2e4c4ebe6b3f1942b2385904b118ba8ba117fae0b30c8c453be0b64a271dd07a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlord: + dependency: transitive + description: + name: overlord + sha256: "532f5685ac09ee805d97ce89794a4eeda41672c32955b4a835bdfce93e720a05" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + super_editor: + dependency: "direct main" + description: + path: "../../super_editor" + relative: true + source: path + version: "0.3.0-dev.38" + super_keyboard: + dependency: transitive + description: + name: super_keyboard + sha256: e3accebf33635f760efbd4d3c13f6484242a09e773ce8e711f4aa745d52b73b1 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + super_text_layout: + dependency: "direct overridden" + description: + path: "../../super_text_layout" + relative: true + source: path + version: "0.1.19" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.27.0" diff --git a/super_clones/bear/pubspec.yaml b/super_clones/bear/pubspec.yaml new file mode 100644 index 0000000000..6995a4037f --- /dev/null +++ b/super_clones/bear/pubspec.yaml @@ -0,0 +1,61 @@ +name: bear +description: "A Flutter clone of Bear" +publish_to: "none" + +version: 1.0.0+1 + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.6 + super_editor: + path: ../../super_editor + +dependency_overrides: + super_editor: + path: ../../super_editor + super_text_layout: + path: ../../super_text_layout + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + 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 diff --git a/super_clones/google_docs/.gitignore b/super_clones/google_docs/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/super_clones/google_docs/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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 +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/super_clones/google_docs/.metadata b/super_clones/google_docs/.metadata new file mode 100644 index 0000000000..779f429981 --- /dev/null +++ b/super_clones/google_docs/.metadata @@ -0,0 +1,45 @@ +# 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: "c0a481e31dcf8d9bd2dadc134aad0afe9e411d80" + channel: "master" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + base_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + - platform: android + create_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + base_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + - platform: ios + create_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + base_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + - platform: linux + create_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + base_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + - platform: macos + create_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + base_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + - platform: web + create_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + base_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + - platform: windows + create_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + base_revision: c0a481e31dcf8d9bd2dadc134aad0afe9e411d80 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_clones/google_docs/README.md b/super_clones/google_docs/README.md new file mode 100644 index 0000000000..c95428ec29 --- /dev/null +++ b/super_clones/google_docs/README.md @@ -0,0 +1,6 @@ +# Docs Example + +A Super Editor example app that approximates the UX of Google Docs. + +The purpose of this example app is to ensure that common desktop word processing use-cases are +achievable with Super Editor. diff --git a/super_clones/google_docs/analysis_options.yaml b/super_clones/google_docs/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_clones/google_docs/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_clones/google_docs/android/.gitignore b/super_clones/google_docs/android/.gitignore new file mode 100644 index 0000000000..6f568019d3 --- /dev/null +++ b/super_clones/google_docs/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/super_clones/google_docs/android/app/build.gradle b/super_clones/google_docs/android/app/build.gradle new file mode 100644 index 0000000000..b61cebd956 --- /dev/null +++ b/super_clones/google_docs/android/app/build.gradle @@ -0,0 +1,67 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "com.example.example_docs" + compileSdk flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.example_docs" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdkVersion flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies {} diff --git a/super_clones/google_docs/android/app/src/debug/AndroidManifest.xml b/super_clones/google_docs/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_clones/google_docs/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_clones/google_docs/android/app/src/main/AndroidManifest.xml b/super_clones/google_docs/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..78b2b6e6be --- /dev/null +++ b/super_clones/google_docs/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/google_docs/android/app/src/main/kotlin/com/example/example_docs/MainActivity.kt b/super_clones/google_docs/android/app/src/main/kotlin/com/example/example_docs/MainActivity.kt new file mode 100644 index 0000000000..c09774501b --- /dev/null +++ b/super_clones/google_docs/android/app/src/main/kotlin/com/example/example_docs/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.example_docs + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/super_clones/google_docs/android/app/src/main/res/drawable-v21/launch_background.xml b/super_clones/google_docs/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/super_clones/google_docs/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_clones/google_docs/android/app/src/main/res/drawable/launch_background.xml b/super_clones/google_docs/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..304732f884 --- /dev/null +++ b/super_clones/google_docs/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_clones/google_docs/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/super_clones/google_docs/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..db77bb4b7b Binary files /dev/null and b/super_clones/google_docs/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/super_clones/google_docs/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/super_clones/google_docs/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..17987b79bb Binary files /dev/null and b/super_clones/google_docs/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/super_clones/google_docs/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/super_clones/google_docs/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..09d4391482 Binary files /dev/null and b/super_clones/google_docs/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/super_clones/google_docs/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/super_clones/google_docs/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d5f1c8d34e Binary files /dev/null and b/super_clones/google_docs/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/super_clones/google_docs/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/super_clones/google_docs/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..4d6372eebd Binary files /dev/null and b/super_clones/google_docs/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/super_clones/google_docs/android/app/src/main/res/values-night/styles.xml b/super_clones/google_docs/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/super_clones/google_docs/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_clones/google_docs/android/app/src/main/res/values/styles.xml b/super_clones/google_docs/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..cb1ef88056 --- /dev/null +++ b/super_clones/google_docs/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_clones/google_docs/android/app/src/profile/AndroidManifest.xml b/super_clones/google_docs/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_clones/google_docs/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_clones/google_docs/android/build.gradle b/super_clones/google_docs/android/build.gradle new file mode 100644 index 0000000000..bc157bd1a1 --- /dev/null +++ b/super_clones/google_docs/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/super_clones/google_docs/android/gradle.properties b/super_clones/google_docs/android/gradle.properties new file mode 100644 index 0000000000..598d13fee4 --- /dev/null +++ b/super_clones/google_docs/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true +android.enableJetifier=true diff --git a/super_clones/google_docs/android/gradle/wrapper/gradle-wrapper.properties b/super_clones/google_docs/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..e1ca574ef0 --- /dev/null +++ b/super_clones/google_docs/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip diff --git a/super_clones/google_docs/android/settings.gradle b/super_clones/google_docs/android/settings.gradle new file mode 100644 index 0000000000..1d6d19b7f8 --- /dev/null +++ b/super_clones/google_docs/android/settings.gradle @@ -0,0 +1,26 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.3.0" apply false + id "org.jetbrains.kotlin.android" version "1.7.10" apply false +} + +include ":app" diff --git a/super_clones/google_docs/assets/images/docs_logo.png b/super_clones/google_docs/assets/images/docs_logo.png new file mode 100644 index 0000000000..eb4cd1248a Binary files /dev/null and b/super_clones/google_docs/assets/images/docs_logo.png differ diff --git a/super_clones/google_docs/ios/.gitignore b/super_clones/google_docs/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/super_clones/google_docs/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/super_clones/google_docs/ios/Flutter/AppFrameworkInfo.plist b/super_clones/google_docs/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..9625e105df --- /dev/null +++ b/super_clones/google_docs/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/super_clones/google_docs/ios/Flutter/Debug.xcconfig b/super_clones/google_docs/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..ec97fc6f30 --- /dev/null +++ b/super_clones/google_docs/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/super_clones/google_docs/ios/Flutter/Release.xcconfig b/super_clones/google_docs/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..c4855bfe20 --- /dev/null +++ b/super_clones/google_docs/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/super_clones/google_docs/ios/Podfile b/super_clones/google_docs/ios/Podfile new file mode 100644 index 0000000000..fdcc671eb3 --- /dev/null +++ b/super_clones/google_docs/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.0' + +# 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', '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 Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/super_clones/google_docs/ios/Podfile.lock b/super_clones/google_docs/ios/Podfile.lock new file mode 100644 index 0000000000..76356722e9 --- /dev/null +++ b/super_clones/google_docs/ios/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - Flutter (1.0.0) + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + url_launcher_ios: bf5ce03e0e2088bad9cc378ea97fa0ed5b49673b + +PODFILE CHECKSUM: 70d9d25280d0dd177a5f637cdb0f0b0b12c6a189 + +COCOAPODS: 1.12.1 diff --git a/super_clones/google_docs/ios/Runner.xcodeproj/project.pbxproj b/super_clones/google_docs/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..0413024639 --- /dev/null +++ b/super_clones/google_docs/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,725 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3FF9FC0B28AE90185295F58A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7C95A96CC051122C7CCB49E /* Pods_RunnerTests.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 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 */; }; + A91EC9E3EFDBAB9137BA593C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 720EA7F52BFD549285F1C931 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1BE6B197E0ADF30C0C46E916 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 2DA3C79B912813040E70B57A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 48EE20398213D9FBF506F82A /* 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 = ""; }; + 720EA7F52BFD549285F1C931 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 77B999B93EC9D0BB1E5789D8 /* 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 = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D2DDE7117EF5F16629F366B5 /* 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 = ""; }; + E665B59E41A605DFD197B01A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + F7C95A96CC051122C7CCB49E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 62889E8CDB517A5365C01660 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3FF9FC0B28AE90185295F58A /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A91EC9E3EFDBAB9137BA593C /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + C184FCB9552E23670DFA170B /* Pods */, + D15D70BBF26BCCB42346F97A /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + C184FCB9552E23670DFA170B /* Pods */ = { + isa = PBXGroup; + children = ( + 77B999B93EC9D0BB1E5789D8 /* Pods-Runner.debug.xcconfig */, + 48EE20398213D9FBF506F82A /* Pods-Runner.release.xcconfig */, + D2DDE7117EF5F16629F366B5 /* Pods-Runner.profile.xcconfig */, + 1BE6B197E0ADF30C0C46E916 /* Pods-RunnerTests.debug.xcconfig */, + E665B59E41A605DFD197B01A /* Pods-RunnerTests.release.xcconfig */, + 2DA3C79B912813040E70B57A /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D15D70BBF26BCCB42346F97A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 720EA7F52BFD549285F1C931 /* Pods_Runner.framework */, + F7C95A96CC051122C7CCB49E /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 73BC784FE3F145CFE5B69972 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 62889E8CDB517A5365C01660 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 1157AED947F6BE0DF96F18BF /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 0ACD19CA4D70B7FC39924647 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0ACD19CA4D70B7FC39924647 /* [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; + }; + 1157AED947F6BE0DF96F18BF /* [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; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 73BC784FE3F145CFE5B69972 /* [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-RunnerTests-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; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F9S7W35N3F; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleDocs; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1BE6B197E0ADF30C0C46E916 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleDocs.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E665B59E41A605DFD197B01A /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleDocs.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2DA3C79B912813040E70B57A /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleDocs.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F9S7W35N3F; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleDocs; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F9S7W35N3F; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleDocs; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/super_clones/google_docs/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/super_clones/google_docs/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/super_clones/google_docs/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_clones/google_docs/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/google_docs/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/google_docs/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/google_docs/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_clones/google_docs/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_clones/google_docs/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_clones/google_docs/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_clones/google_docs/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..87131a09be --- /dev/null +++ b/super_clones/google_docs/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/google_docs/ios/Runner.xcworkspace/contents.xcworkspacedata b/super_clones/google_docs/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_clones/google_docs/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_clones/google_docs/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/google_docs/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/google_docs/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/google_docs/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_clones/google_docs/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_clones/google_docs/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_clones/google_docs/ios/Runner/AppDelegate.swift b/super_clones/google_docs/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..70693e4a8c --- /dev/null +++ b/super_clones/google_docs/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000..dc9ada4725 Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..7353c41ecf Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..6ed2d933e1 Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..4cd7b0099c Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..fe730945a0 Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..321773cd85 Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..502f463a9b Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..e9f5fea27c Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..84ac32ae7d Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..8953cba090 Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..0467bf12aa Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/super_clones/google_docs/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/super_clones/google_docs/ios/Runner/Base.lproj/LaunchScreen.storyboard b/super_clones/google_docs/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/super_clones/google_docs/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/google_docs/ios/Runner/Base.lproj/Main.storyboard b/super_clones/google_docs/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/super_clones/google_docs/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/google_docs/ios/Runner/Info.plist b/super_clones/google_docs/ios/Runner/Info.plist new file mode 100644 index 0000000000..0aa8cf88f5 --- /dev/null +++ b/super_clones/google_docs/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example Docs + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example_docs + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/super_clones/google_docs/ios/Runner/Runner-Bridging-Header.h b/super_clones/google_docs/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/super_clones/google_docs/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/super_clones/google_docs/ios/RunnerTests/RunnerTests.swift b/super_clones/google_docs/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..86a7c3b1b6 --- /dev/null +++ b/super_clones/google_docs/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_clones/google_docs/lib/app.dart b/super_clones/google_docs/lib/app.dart new file mode 100644 index 0000000000..747e7bfb27 --- /dev/null +++ b/super_clones/google_docs/lib/app.dart @@ -0,0 +1,231 @@ +import 'package:example_docs/app_menu.dart'; +import 'package:example_docs/editor.dart'; +import 'package:example_docs/theme.dart'; +import 'package:example_docs/toolbar.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class DocsApp extends StatelessWidget { + const DocsApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Docs', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const MainScreen(), + debugShowCheckedModeBanner: false, + ); + } +} + +class MainScreen extends StatefulWidget { + const MainScreen({ + super.key, + }); + + @override + State createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + final FocusNode _editorFocusNode = FocusNode(); + late MutableDocument _document; + late MutableDocumentComposer _composer; + late Editor _editor; + + int _zoomLevel = 100; + + @override + void initState() { + super.initState(); + + _document = _createInitialDocument(); + _composer = MutableDocumentComposer(); + _editor = createDefaultDocumentEditor(document: _document, composer: _composer); + } + + @override + void dispose() { + _editor.dispose(); + _composer.dispose(); + _document.dispose(); + _editorFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFf9fbfd), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AppHeaderPane( + editorFocusNode: _editorFocusNode, + document: _document, + editor: _editor, + composer: _composer, + onZoomChange: (zoom) => setState(() { + _zoomLevel = zoom; + }), + ), + const SizedBox(height: 4), + const Divider(height: 1, thickness: 1, color: Color(0xFFc4c7c5)), + Expanded( + child: Transform.scale( + alignment: Alignment.topCenter, + scale: _zoomLevel / 100.0, + child: DocsEditor( + focusNode: _editorFocusNode, + document: _document, + composer: _composer, + editor: _editor, + ), + ), + ), + ], + )); + } +} + +/// The pane that appears at the top of the app, which includes the document title, app menu, +/// and editor toolbar. +class _AppHeaderPane extends StatelessWidget { + const _AppHeaderPane({ + required this.editorFocusNode, + required this.document, + required this.editor, + required this.composer, + required this.onZoomChange, + }); + + final FocusNode editorFocusNode; + final Document document; + final Editor editor; + final MutableDocumentComposer composer; + final void Function(int zoom) onZoomChange; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8), + child: Column( + children: [ + _buildTitleAndMenuBar(), + const SizedBox(height: 16), + DocsEditorToolbar( + editorFocusNode: editorFocusNode, + document: document, + editor: editor, + composer: composer, + onZoomChange: onZoomChange, + ), + ], + ), + ); + } + + Widget _buildTitleAndMenuBar() { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 8), + Image.asset("assets/images/docs_logo.png"), + const SizedBox(width: 4), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDocumentTitleAndActions(), + _buildMenus(), + ], + ) + ], + ); + } + + Widget _buildDocumentTitleAndActions() { + return const Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: menuButtonHorizontalPadding), // Push title to the right to match first letter of first menu item + Text( + "Some Document", + style: TextStyle( + color: Colors.black, + fontSize: 18, + letterSpacing: -0.8, + ), + ), + SizedBox(width: 24), + Icon(Icons.star_border, size: 18, color: titleActionIconColor), + SizedBox(width: 12), + Icon(Icons.drive_folder_upload, size: 18, color: titleActionIconColor), + SizedBox(width: 12), + Icon(Icons.cloud_done_outlined, size: 18, color: titleActionIconColor), + ], + ); + } + + Widget _buildMenus() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildMenuButton("File"), + _buildMenuButton("Edit"), + _buildMenuButton("View"), + _buildMenuButton("Insert"), + _buildMenuButton("Format"), + _buildMenuButton("Tools"), + _buildMenuButton("Extensions"), + _buildMenuButton("Help"), + ], + ); + } + + Widget _buildMenuButton(String label) { + return DocsAppMenu( + label: label, + items: const [ + // TODO: create options for each menu that matches Google Docs + DocsAppMenuItem(id: "new", label: "New"), + DocsAppMenuItem(id: "open", label: "Open"), + DocsAppMenuItem(id: "copy", label: "Make a Copy"), + ], + onSelected: (_) {}, + ); + } +} + +// Creates the document that's initially displayed when the app launches. +MutableDocument _createInitialDocument() { + return MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText("Welcome to a Super Editor version of Docs!"), + metadata: { + "blockType": header1Attribution, + }, + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText("By: The Super Editor Team"), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "This is an example document editor experience, which is meant to mimic the UX of Google Docs. We created this example app to ensure that common desktop word processing UX can be built with Super Editor."), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "A typical desktop word processor is comprised of a pane at the top of the window, which includes some combination of information about the current document, as well as toolbars that present editing options. The remainder of the window is filled by an editable document."), + ), + ], + ); +} diff --git a/super_clones/google_docs/lib/app_menu.dart b/super_clones/google_docs/lib/app_menu.dart new file mode 100644 index 0000000000..8429c819c5 --- /dev/null +++ b/super_clones/google_docs/lib/app_menu.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +import 'theme.dart'; + +/// An application-level popup menu. +/// +/// A menu button is displayed with the given [label]. +/// +/// When the user clicks the menu button, a popover appears, which displays the given [items] as +/// a vertical list. The popover is left-aligned with the menu button, and appears immediately below +/// the menu button. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class DocsAppMenu extends StatefulWidget { + const DocsAppMenu({ + super.key, + this.parentFocusNode, + this.boundaryKey, + required this.label, + required this.items, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [DocsAppMenu] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + /// The name of the menu, which is displayed on the menu button. + final String label; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [DocsAppMenuItem.label] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(DocsAppMenuItem? value) onSelected; + + @override + State createState() => _DocsAppMenuState(); +} + +class _DocsAppMenuState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + void _onItemSelected(DocsAppMenuItem? value) { + _popoverController.close(); + widget.onSelected(value); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + boundaryKey: widget.boundaryKey, + popoverGeometry: DocsAppMenuPopoverGeometry(), + popoverBuilder: (context) => DocsAppMenuPopoverAppearance( + child: ItemSelectionList( + focusNode: _popoverFocusNode, + value: null, + items: widget.items, + itemBuilder: _buildPopoverListItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return DocsMenuButton( + label: widget.label, + onTap: () => _popoverController.open(), + ); + } + + Widget _buildPopoverListItem(BuildContext context, DocsAppMenuItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Text( + item.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ), + ), + ); + } +} + +/// A [PopoverGeometry] designed for a Docs app menu. +/// +/// This [PopoverGeometry] aligns the top-left corner of the popover with the bottom-left corner +/// of the menu button that launched it. +class DocsAppMenuPopoverGeometry extends PopoverGeometry { + @override + PopoverAligner get aligner => FunctionalPopoverAligner( + (Rect globalLeaderRect, Size followerSize, Size screenSize, GlobalKey? boundaryKey) { + final boundsBox = boundaryKey?.currentContext?.findRenderObject() as RenderBox?; + final bounds = boundsBox != null + ? Rect.fromPoints( + boundsBox.localToGlobal(Offset.zero), + boundsBox.localToGlobal(boundsBox.size.bottomRight(Offset.zero)), + ) + : Offset.zero & screenSize; + late FollowerAlignment alignment; + + if (globalLeaderRect.bottom + followerSize.height < bounds.bottom) { + // The follower fits below the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + ); + } else if (globalLeaderRect.top - followerSize.height > bounds.top) { + // The follower fits above the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + ); + } else { + // There isn't enough room to fully display the follower below or above the leader. + // Pin the popover list to the bottom, letting the follower cover the leader. + alignment = const FollowerAlignment( + leaderAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + ); + } + + return alignment; + }, + ); + + @override + BoxConstraints get constraints => const BoxConstraints.tightFor(width: 250); +} + +/// The shape and color of a popover menu. +class DocsAppMenuPopoverAppearance extends StatefulWidget { + const DocsAppMenuPopoverAppearance({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => _DocsAppMenuPopoverAppearanceState(); +} + +class _DocsAppMenuPopoverAppearanceState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + late final Animation _containerFadeInAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _containerFadeInAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + borderRadius: const BorderRadius.only( + topRight: Radius.circular(4), + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(4), + ), + clipBehavior: Clip.hardEdge, + color: Colors.white, + elevation: 8, + child: FadeTransition( + opacity: _containerFadeInAnimation, + child: widget.child, + ), + ); + } +} + +/// An app menu button, such as a button that reads "File" or "Edit" at the top of +/// an app. +class DocsMenuButton extends StatelessWidget { + const DocsMenuButton({ + super.key, + required this.label, + required this.onTap, + }); + + /// The text displayed on the button. + final String label; + + /// Called when the user taps the button. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onTap, + style: TextButton.styleFrom( + foregroundColor: Colors.black, + surfaceTintColor: const Color(0xFFedf2fa), + padding: const EdgeInsets.symmetric(horizontal: menuButtonHorizontalPadding, vertical: 12), + minimumSize: Size.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + visualDensity: VisualDensity.compact, + textStyle: const TextStyle( + fontWeight: FontWeight.w400, + ), + ), + child: Text(label), + ); + } +} + +/// An item that's displayed within a popover app menu, such as when the user clicks on +/// an app's "File" menu. +/// +/// Two [DocsAppMenuItem]s are considered to be equal if they have the same [id]. +class DocsAppMenuItem { + const DocsAppMenuItem({ + required this.id, + required this.label, + }); + + /// The value that identifies this item. + final String id; + + /// The text that is displayed. + final String label; + + @override + bool operator ==(Object other) => + identical(this, other) || other is DocsAppMenuItem && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/super_clones/google_docs/lib/editor.dart b/super_clones/google_docs/lib/editor.dart new file mode 100644 index 0000000000..c032e18530 --- /dev/null +++ b/super_clones/google_docs/lib/editor.dart @@ -0,0 +1,113 @@ +import 'package:flutter/widgets.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:super_editor/super_editor.dart'; + +import 'theme.dart'; + +/// An editable document within a Docs app. +/// +/// This is the primary editing experience for the app. A [DocsEditor] takes up +/// all the space beneath the app header pane. +class DocsEditor extends StatefulWidget { + const DocsEditor({ + super.key, + this.focusNode, + required this.document, + required this.composer, + required this.editor, + }); + + final FocusNode? focusNode; + final MutableDocument document; + final MutableDocumentComposer composer; + final Editor editor; + + @override + State createState() => _DocsEditorState(); +} + +class _DocsEditorState extends State { + late FocusNode _editorFocusNode; + + @override + void initState() { + super.initState(); + _editorFocusNode = widget.focusNode ?? FocusNode(); + } + + @override + void didUpdateWidget(covariant DocsEditor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + if (oldWidget.focusNode == null) { + _editorFocusNode.dispose(); + } + _editorFocusNode = widget.focusNode ?? FocusNode(); + } + } + + @override + void dispose() { + if (widget.focusNode == null) { + _editorFocusNode.dispose(); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + return SuperEditor( + focusNode: _editorFocusNode, + editor: widget.editor, + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: docsStylesheet, + inlineTextStyler: _applyFontFamily, + ), + selectionStyle: _editorFocusNode.hasPrimaryFocus // + ? _standardEditorSelectionStyle + : _unfocusedEditorSelectionStyle, + selectionPolicies: const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: false, + clearSelectionWhenImeConnectionCloses: false, + ), + documentOverlayBuilders: const [ + DefaultCaretOverlayBuilder( + displayCaretWithExpandedSelection: false, + ), + ], + componentBuilders: [ + ...defaultComponentBuilders, + TaskComponentBuilder(widget.editor), + ], + ); + }, + ); + } + + TextStyle _applyFontFamily(Set attributions, TextStyle existingStyle) { + TextStyle styles = defaultInlineTextStyler(attributions, existingStyle); + + final fontFamilyAttribution = attributions.whereType().firstOrNull; + if (fontFamilyAttribution != null) { + styles = GoogleFonts.getFont( + fontFamilyAttribution.fontFamily, + textStyle: styles, + ); + } + + return styles; + } +} + +// Selection styles when the editor has focus. +const _standardEditorSelectionStyle = defaultSelectionStyle; + +// Selection styles when the editor doesn't have focus. +final _unfocusedEditorSelectionStyle = SelectionStyles( + selectionColor: const Color(0xFFDDDDDD), + highlightEmptyTextBlocks: defaultSelectionStyle.highlightEmptyTextBlocks, +); diff --git a/super_clones/google_docs/lib/infrastructure/color_selector.dart b/super_clones/google_docs/lib/infrastructure/color_selector.dart new file mode 100644 index 0000000000..828a6f89c2 --- /dev/null +++ b/super_clones/google_docs/lib/infrastructure/color_selector.dart @@ -0,0 +1,354 @@ +import 'package:example_docs/infrastructure/selectable_grid.dart'; +import 'package:example_docs/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A selection control, which displays a button with the selected color, and upon tap, displays a +/// color picker with the available colors, from which the user can select a different color. +/// +/// Includes the following keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" color selection up/down. +/// * Pressing LEFT/RIGHT moves the "active" color selection left/right. +/// * Pressing ENTER selects the currently active color. +class ColorSelector extends StatefulWidget { + const ColorSelector({ + super.key, + this.parentFocusNode, + this.tapRegionGroupId, + this.boundaryKey, + this.selectedColor, + this.colors = defaultColors, + this.columnCount = 10, + this.showClearButton = false, + required this.onSelected, + required this.colorButtonBuilder, + }); + + /// The [FocusNode], to which the color picker's [FocusNode] will be added as a child. + /// + /// See [PopoverScaffold.parentFocusNode] for more information. + final FocusNode? parentFocusNode; + + /// A group ID for a tap region that is shared with the color picker. + /// + /// Tapping on a [TapRegion] with the same [tapRegionGroupId] + /// won't invoke [onTapOutside]. + final String? tapRegionGroupId; + + /// A [GlobalKey] to a widget that determines the bounds where the color picker can be displayed. + /// + /// See [PopoverScaffold.boundaryKey] for more information. + final GlobalKey? boundaryKey; + + /// The currently selected color or `null` if no color is selected. + final Color? selectedColor; + + /// The colors that will be displayed in the color picker. + /// + /// Each color is displayed as a circle. + final List colors; + + /// Defines the number of columns that the color picker should have. + final int columnCount; + + /// Whether or not the color picker should display a "Clear" button. + /// + /// Pressing that button call [onSelected] with a `null` value. + final bool showClearButton; + + /// Called when the user selects an item on the color picker. + final void Function(Color? value) onSelected; + + /// Builds the button of this [ColorSelector]. + final Widget Function(BuildContext context, Color? selectedColor) colorButtonBuilder; + + @override + State createState() => _ColorSelectorState(); +} + +class _ColorSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the color picker. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + void _onItemSelected(Color? value) { + _popoverController.close(); + widget.onSelected(value); + } + + /// Decides a foreground color for a [background] color based on the brightness of the [background]. + /// + /// Returns [Colors.white] if [background] is a dark color and [Colors.black] otherwise. + Color _getColorForCheckIcon(Color background) { + // Adapted from https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color/3943023#3943023. + final intensity = (0.299 * background.red) + (0.587 * background.green) + (0.114 * background.blue); + return intensity > 130 ? Colors.black : Colors.white; + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + tapRegionGroupId: widget.tapRegionGroupId, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + boundaryKey: widget.boundaryKey, + popoverBuilder: (context) => Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.hardEdge, + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showClearButton) // + _buildClearButton(), + if (widget.showClearButton) // + const SizedBox(height: 3), + _buildColorGrid(), + _buildCustomColorsButton(), + _buildFooterButtons(), + ], + ), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return TextButton( + onPressed: () => _popoverController.open(), + style: defaultToolbarButtonStyle, + child: widget.colorButtonBuilder(context, widget.selectedColor), + ); + } + + Widget _buildClearButton() { + return SizedBox( + width: 243, + height: 32, + child: TextButton.icon( + onPressed: () => _onItemSelected(null), + style: ButtonStyle( + foregroundColor: WidgetStateProperty.all(Colors.black), + backgroundColor: WidgetStateProperty.resolveWith(getButtonColor), + padding: WidgetStateProperty.all(const EdgeInsets.all(5)), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + ), + ), + icon: const Icon(Icons.format_color_reset), + label: const SizedBox( + width: double.infinity, + child: Text( + 'None', + textAlign: TextAlign.left, + ), + ), + ), + ); + } + + Widget _buildColorGrid() { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: (widget.colors.length / widget.columnCount * 20), + maxWidth: 243, + ), + child: SelectableGrid( + focusNode: _popoverFocusNode, + value: widget.selectedColor, + items: widget.colors, + itemBuilder: _buildPopoverGridItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + columnCount: widget.columnCount, + mainAxisExtent: 18, + ), + ); + } + + Widget _buildPopoverGridItem(BuildContext context, Color item, bool isActive, VoidCallback onTap) { + return InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: Stack( + alignment: Alignment.center, + children: [ + Container( + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: item, + shape: BoxShape.circle, + boxShadow: [ + if (isActive) // + const BoxShadow( + blurRadius: 3, + ) + ], + ), + ), + if (item == widget.selectedColor) // + Icon( + Icons.check, + size: 15, + color: _getColorForCheckIcon(item), + ) + ], + ), + ); + } + + Widget _buildCustomColorsButton() { + return SizedBox( + width: 243, + height: 24, + child: TextButton( + onPressed: () {}, + style: ButtonStyle( + foregroundColor: WidgetStateProperty.all(Colors.black), + backgroundColor: WidgetStateProperty.resolveWith(getButtonColor), + padding: WidgetStateProperty.all(const EdgeInsets.all(5)), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + ), + ), + child: const SizedBox( + width: double.infinity, + child: Text( + 'Custom Colors', + textAlign: TextAlign.left, + ), + ), + ), + ); + } + + Widget _buildFooterButtons() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () {}, + style: defaultToolbarButtonStyle, + child: const Icon(Icons.add_circle_outline), + ), + TextButton( + onPressed: () {}, + style: defaultToolbarButtonStyle, + child: const Icon(Icons.colorize), + ), + ], + ); + } +} + +const defaultColors = [ + Colors.black, + Color(0xFF434343), + Color(0xFF666666), + Color(0xFF999999), + Color(0xFFB7B7B7), + Color(0xFFCCCCCC), + Color(0xFFD9D9D9), + Color(0xFFEFEFEF), + Color(0xFFF3F3F3), + Color(0xFFDFE0E3), + // + Color(0xFF980201), + Color(0xFFFF0000), + Color(0xFFFF9900), + Color(0xFFFFFF00), + Color(0xFF01FF00), + Color(0xFF02FFFF), + Color(0xFF4A86E8), + Color(0xFF0602FF), + Color(0xFF9901FF), + Color(0xFFFF00FF), + // + Color(0xFFE6B8AF), + Color(0xFFF4CCCC), + Color(0xFFFCE5CD), + Color(0xFFFFF2CC), + Color(0xFFD9EAD3), + Color(0xFFD0E0E3), + Color(0xFFC9DAF8), + Color(0xFFCFE2F3), + Color(0xFFD9D2E9), + Color(0xFFEAD1DC), + // + Color(0xFFDD7E6A), + Color(0xFFEA9999), + Color(0xFFF9CB9C), + Color(0xFFFFE599), + Color(0xFFB6D7A8), + Color(0xFFA2C4C9), + Color(0xFFA4C2F4), + Color(0xFF9FC5E8), + Color(0xFFB4A7D6), + Color(0xFFD5A6BD), + // + Color(0xFFCC4125), + Color(0xFFE06666), + Color(0xFFF6B26B), + Color(0xFFFFD965), + Color(0xFF93C47E), + Color(0xFF76A5AF), + Color(0xFF6D9EEB), + Color(0xFF6FA8DC), + Color(0xFF8E7CC3), + Color(0xFFC27BA0), + // + Color(0xFFA61C01), + Color(0xFFCC0200), + Color(0xFFE69139), + Color(0xFFF1C233), + Color(0xFF6AA84F), + Color(0xFF45808E), + Color(0xFF3C78D8), + Color(0xFF3D85C6), + Color(0xFF674EA7), + Color(0xFFA64D78), + // + Color(0xFF85200C), + Color(0xFF990201), + Color(0xFFB45F07), + Color(0xFFBF9001), + Color(0xFF38761D), + Color(0xFF144F5C), + Color(0xFF1155CC), + Color(0xFF0B5394), + Color(0xFF351D75), + Color(0xFF741B46), + // + Color(0xFF5B0E03), + Color(0xFF660202), + Color(0xFF783E03), + Color(0xFF7F6001), + Color(0xFF274E13), + Color(0xFF0C343D), + Color(0xFF1B4487), + Color(0xFF093763), + Color(0xFF20124D), + Color(0xFF4C1230), + // +]; diff --git a/super_clones/google_docs/lib/infrastructure/icon_selector.dart b/super_clones/google_docs/lib/infrastructure/icon_selector.dart new file mode 100644 index 0000000000..62baa438f1 --- /dev/null +++ b/super_clones/google_docs/lib/infrastructure/icon_selector.dart @@ -0,0 +1,158 @@ +import 'package:example_docs/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A selection control, which displays a button with the selected icon, and upon tap, displays a +/// popover list of available icons, from which the user can select a different icon. +/// +/// Includes the following keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" icon selection up/down. +/// * Pressing UP with the first icon active moves the active icon selection to the last icon. +/// * Pressing DOWN with the last icon active moves the active icon selection to the first icon. +/// * Pressing ENTER selects the currently active icon. +class IconSelector extends StatefulWidget { + const IconSelector({ + super.key, + this.parentFocusNode, + this.tapRegionGroupId, + this.boundaryKey, + this.selectedIcon, + required this.icons, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// See [PopoverScaffold.parentFocusNode] for more information. + final FocusNode? parentFocusNode; + + /// A group ID for a tap region that is shared with the popover list. + /// + /// Tapping on a [TapRegion] with the same [tapRegionGroupId] + /// won't invoke [onTapOutside]. + final String? tapRegionGroupId; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// See [PopoverScaffold.boundaryKey] for more information. + final GlobalKey? boundaryKey; + + /// The currently selected icon or `null` if no icon is selected. + final IconItem? selectedIcon; + + /// The icons that will be displayed in the popover list. + final List icons; + + /// Called when the user selects an icon on the popover list. + final void Function(IconItem? value) onSelected; + + @override + State createState() => _IconSelectorState(); +} + +class _IconSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + tapRegionGroupId: widget.tapRegionGroupId, + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + popoverGeometry: const PopoverGeometry( + constraints: BoxConstraints(minHeight: 40), + aligner: FunctionalPopoverAligner(popoverAligner), + ), + popoverBuilder: (context) => Material( + elevation: 8, + borderRadius: BorderRadius.circular(4), + clipBehavior: Clip.hardEdge, + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: ItemSelectionList( + axis: Axis.horizontal, + focusNode: _popoverFocusNode, + value: widget.selectedIcon, + items: widget.icons, + itemBuilder: _buildItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + ), + ), + ), + ); + } + + Widget _buildItem(BuildContext context, IconItem item, bool isActive, VoidCallback onTap) { + return Container( + height: 30, + width: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + color: item == widget.selectedIcon + ? toolbarButtonSelectedColor + : isActive + ? Colors.grey.withValues(alpha: 0.2) + : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Icon(item.icon), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return TextButton( + onPressed: () => _popoverController.open(), + style: defaultToolbarButtonStyle, + child: widget.selectedIcon == null // + ? const SizedBox() + : Icon(widget.selectedIcon!.icon), + ); + } + + void _onItemSelected(IconItem? value) { + _popoverController.close(); + widget.onSelected(value); + } +} + +/// An option that is displayed as an icon by a [IconSelector]. +/// +/// Two [IconItem]s are considered to be equal if they have the same [id]. +class IconItem { + const IconItem({ + required this.id, + required this.icon, + }); + + /// The value that identifies this item. + final String id; + + /// The icon that is displayed. + final IconData icon; + + @override + bool operator ==(Object other) => + identical(this, other) || other is IconItem && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/super_clones/google_docs/lib/infrastructure/increment_decrement_field.dart b/super_clones/google_docs/lib/infrastructure/increment_decrement_field.dart new file mode 100644 index 0000000000..5fe990996a --- /dev/null +++ b/super_clones/google_docs/lib/infrastructure/increment_decrement_field.dart @@ -0,0 +1,148 @@ +import 'dart:math'; + +import 'package:example_docs/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A Widget that allows the user to increment or decrement a [value]. +/// +/// Displays a textfield with the current [value], surrounded by the increment and +/// decrement buttons. +/// +/// Selects all the content on the textfield upon focus. +class IncrementDecrementField extends StatefulWidget { + const IncrementDecrementField({ + super.key, + required this.value, + required this.onChange, + }); + + /// The current value. + final int value; + + /// Called when the user presses one of the buttons or when the user + /// presses ENTER on the textfield. + /// + /// If the user clears the textfield, no change is reported. Instead, + /// the textfield value is reset to the current [value]. + final void Function(int value) onChange; + + @override + State createState() => _IncrementDecrementFieldState(); +} + +class _IncrementDecrementFieldState extends State { + final ImeAttributedTextEditingController _controller = ImeAttributedTextEditingController(); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller + ..text = AttributedText(widget.value.toString()) + ..onPerformActionPressed = _onPerformAction; + + _focusNode.addListener(_onFocusChange); + } + + @override + void didUpdateWidget(covariant IncrementDecrementField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _controller.text = AttributedText(widget.value.toString()); + } + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onPerformAction(TextInputAction action) { + if (action == TextInputAction.done) { + final value = int.tryParse(_controller.text.toPlainText(includePlaceholders: false).trim()); + if (value != null) { + widget.onChange(value); + } + + _controller.text = AttributedText(widget.value.toString()); + } + } + + void _onIncrement() { + final value = int.tryParse(_controller.text.toPlainText(includePlaceholders: false).trim()); + if (value == null) { + return; + } + + widget.onChange(value + 1); + } + + void _onDecrement() { + final value = int.tryParse(_controller.text.toPlainText(includePlaceholders: false).trim()); + if (value == null) { + return; + } + + widget.onChange(max(value - 1, 1)); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) { + _controller.selectAll(); + } + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: _onDecrement, + style: defaultToolbarButtonStyle, + child: const Icon(Icons.remove), + ), + const SizedBox(width: 4), + SizedBox( + height: 24, + width: 32, + child: SuperDesktopTextField( + focusNode: _focusNode, + textAlign: TextAlign.center, + inputSource: TextInputSource.ime, + textInputAction: TextInputAction.done, + textController: _controller, + minLines: 1, + maxLines: 1, + textStyleBuilder: (attributions) => const TextStyle( + fontSize: 12, + color: Colors.black, + ), + decorationBuilder: (context, child) { + return Container( + height: 24, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: _focusNode.hasFocus ? const Color(0xFF0B57D0) : const Color(0xFF747775), + width: 1, + ), + ), + child: Center(child: child), + ); + }, + ), + ), + const SizedBox(width: 4), + TextButton( + onPressed: _onIncrement, + style: defaultToolbarButtonStyle, + child: const Icon(Icons.add), + ) + ], + ); + } +} diff --git a/super_clones/google_docs/lib/infrastructure/selectable_grid.dart b/super_clones/google_docs/lib/infrastructure/selectable_grid.dart new file mode 100644 index 0000000000..1b60db813c --- /dev/null +++ b/super_clones/google_docs/lib/infrastructure/selectable_grid.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// A grid where the user can navigate between its items and select one of them. +/// +/// Includes the following keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing LEFT/RIGHT moves the "active" item selection left/right. +/// * Pressing ENTER selects the currently active item. +class SelectableGrid extends StatefulWidget { + const SelectableGrid({ + super.key, + this.focusNode, + required this.value, + required this.items, + required this.itemBuilder, + required this.columnCount, + this.mainAxisExtent, + this.onItemActivated, + required this.onItemSelected, + this.onCancel, + }); + + /// The [FocusNode] of the grid. + final FocusNode? focusNode; + + /// The currently selected value or `null` if no item is selected. + final GridItemType? value; + + /// The items that will be displayed on the grid. + /// + /// For each item, [itemBuilder] is called to build its visual representation. + final List items; + + /// Builds each item on the grid. + /// + /// This method is called for each item in [items], to build its visual representation. + /// + /// The provided `onTap` must be called when the item is tapped. + final SelectableGridItemBuilder itemBuilder; + + /// How many columns the grid must have. + final int columnCount; + + /// The extent of each item on the grid. + final double? mainAxisExtent; + + /// Called when the user activates an item on the grid. + /// + /// The activation can be performed by: + /// 1. Pressing UP ARROW or DOWN ARROW. + /// 2. Pressing LEFT ARROW or RIGHT ARROW. + final ValueChanged? onItemActivated; + + /// Called when the user selects an item on the grid. + /// + /// The selection can be performed by: + /// 1. Tapping on an item in the grid. + /// 2. Pressing ENTER when the grid has an active item. + final ValueChanged onItemSelected; + + /// Called when the user presses ESCAPE. + final VoidCallback? onCancel; + + @override + State> createState() => _SelectableGridState(); +} + +class _SelectableGridState extends State> + with SingleTickerProviderStateMixin { + final ScrollController _scrollController = ScrollController(); + + /// Holds keys to each item on the grid. + /// + /// Used to scroll the grid to reveal the active item. + final List _itemKeys = []; + + int? _activeIndex; + + @override + void initState() { + super.initState(); + _activateSelectedItem(); + } + + @override + void dispose() { + _scrollController.dispose(); + + super.dispose(); + } + + void _activateSelectedItem() { + final selectedItem = widget.value; + + if (selectedItem == null) { + _activeIndex = null; + return; + } + + int selectedItemIndex = widget.items.indexOf(selectedItem); + if (selectedItemIndex < 0) { + // A selected item was provided, but it isn't included in the list of items. + _activeIndex = null; + return; + } + + // The grid was just displayed. + // Jump to the active item without animation. + _activateItem(selectedItemIndex, animationDuration: Duration.zero); + } + + /// Activates the item at [itemIndex] and ensure it's visible on screen. + /// + /// The active item is selected when the user presses ENTER. + void _activateItem(int? itemIndex, {required Duration animationDuration}) { + _activeIndex = itemIndex; + if (itemIndex != null) { + widget.onItemActivated?.call(widget.items[itemIndex]); + } + + // This method might be called before the widget was rendered. + // For example, when the widget is created with a selected item, + // this item is immediately activated, before the rendering pipeline is + // executed. Therefore, the RenderBox won't be available at the same frame. + // + // Scrolls on the next frame to let the popover be laid-out first, + // so we can access its RenderBox. + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (!mounted) { + return; + } + _scrollToShowActiveItem(animationDuration); + }); + } + + /// Scrolls the scrollable to display the selected item. + void _scrollToShowActiveItem(Duration animationDuration) { + if (_activeIndex == null) { + return; + } + + final key = _itemKeys[_activeIndex!]; + + final childRenderBox = key.currentContext?.findRenderObject() as RenderBox?; + if (childRenderBox == null) { + return; + } + + childRenderBox.showOnScreen( + rect: Offset.zero & childRenderBox.size, + duration: animationDuration, + curve: Curves.easeIn, + ); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + if (!const [ + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.escape, + ].contains(event.logicalKey)) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onCancel?.call(); + return KeyEventResult.handled; + } + + if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { + if (_activeIndex == null) { + // The user pressed ENTER without an active item. + // Clear the selected item. + widget.onItemSelected(null); + return KeyEventResult.handled; + } + + widget.onItemSelected(widget.items[_activeIndex!]); + + return KeyEventResult.handled; + } + + // The user pressed an arrow key. Update the active item. + int? newActiveIndex; + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_activeIndex == null || _activeIndex! >= widget.items.length - 1) { + // We don't have an active item or we are at the end of the list. Activate the first item. + newActiveIndex = 0; + } else { + // Activate the next item. + newActiveIndex = _activeIndex! + 1; + } + } + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_activeIndex == null || _activeIndex! <= 0) { + // We don't have an active item or we are at the beginning of the list. Activate the last item. + newActiveIndex = widget.items.length - 1; + } else { + // Activate the previous item. + newActiveIndex = _activeIndex! - 1; + } + } + + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + newActiveIndex = (_activeIndex ?? 0) + widget.columnCount; + if (newActiveIndex >= widget.items.length - 1) { + // We don't have an active item or we are at the end of the list. Activate the first item. + newActiveIndex = 0; + } + } + + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + newActiveIndex = (_activeIndex ?? 0) - widget.columnCount; + if (newActiveIndex <= 0) { + // We don't have an active item or we are at the beginning of the list. Activate the last item. + newActiveIndex = widget.items.length - 1; + } + } + + setState(() { + _activateItem(newActiveIndex, animationDuration: const Duration(milliseconds: 100)); + }); + + return KeyEventResult.handled; + } + + @override + Widget build(BuildContext context) { + _itemKeys.clear(); + + for (int i = 0; i < widget.items.length; i++) { + _itemKeys.add(GlobalKey()); + } + return Focus( + focusNode: widget.focusNode, + onKeyEvent: _onKeyEvent, + child: GridView.builder( + clipBehavior: Clip.none, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.columnCount, + mainAxisSpacing: 2, + mainAxisExtent: widget.mainAxisExtent, + ), + itemCount: widget.items.length, + itemBuilder: (context, index) { + return widget.itemBuilder( + context, + widget.items[index], + _activeIndex == index, + () => widget.onItemSelected(widget.items[index]), + ); + }, + ), + ); + } +} + +/// Builds a grid item. +/// +/// [isActive] is `true` if [item] is the currently active item on the grid, or `false` otherwise. +/// +/// The active item is the currently focused item in the grid, which can be selected by pressing ENTER. +/// +/// The provided [onTap] must be called when the button is tapped. +typedef SelectableGridItemBuilder = Widget Function(BuildContext context, T item, bool isActive, VoidCallback onTap); diff --git a/super_clones/google_docs/lib/infrastructure/text_item_selector.dart b/super_clones/google_docs/lib/infrastructure/text_item_selector.dart new file mode 100644 index 0000000000..3228581a8c --- /dev/null +++ b/super_clones/google_docs/lib/infrastructure/text_item_selector.dart @@ -0,0 +1,234 @@ +import 'package:example_docs/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A selection control, which displays a button with the selected text, and upon tap, displays a +/// popover list of available texts, from which the user can select a different text. +/// +/// Includes the following keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" text selection up/down. +/// * Pressing UP with the first text active moves the active text selection to the last text. +/// * Pressing DOWN with the last text active moves the active text selection to the first text. +/// * Pressing ENTER selects the currently active text. +class TextItemSelector extends StatefulWidget { + const TextItemSelector({ + super.key, + required this.parentFocusNode, + this.tapRegionGroupId, + this.boundaryKey, + this.selectedText, + required this.items, + this.popoverGeometry, + this.buttonSize, + this.itemBuilder = defaultPopoverListItemBuilder, + this.separatorBuilder, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// See [PopoverScaffold.parentFocusNode] for more information. + final FocusNode parentFocusNode; + + /// A group ID for a tap region that is shared with the popover list. + /// + /// Tapping on a [TapRegion] with the same [tapRegionGroupId] + /// won't invoke [onTapOutside]. + final String? tapRegionGroupId; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// See [PopoverScaffold.boundaryKey] for more information. + final GlobalKey? boundaryKey; + + /// The currently selected text or `null` if no text is selected. + /// + /// This value is used to build the button. + final TextItem? selectedText; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [TextItem.label] is displayed. + final List items; + + /// Builds each item on the list. + /// + /// Defaults to [defaultPopoverListItemBuilder]. + final SelectableListItemBuilder itemBuilder; + + /// Builds a separator between each item. + /// + /// If `null`, no separator is displayed. + final IndexedWidgetBuilder? separatorBuilder; + + /// The desired size of the button. + /// + /// If `null` a default fixed size is used. + final Size? buttonSize; + + /// Controls the size and position of the popover. + /// + /// The popover is first sized, then positioned. + final PopoverGeometry? popoverGeometry; + + /// Called when the user selects an item on the popover list. + final void Function(TextItem? value) onSelected; + + @override + State createState() => _TextItemSelectorState(); +} + +class _TextItemSelectorState extends State { + final PopoverController _popoverController = PopoverController(); + final WidgetStatesController _buttonStatesController = WidgetStatesController(); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _popoverController.addListener(_onPopoverVisibilityChange); + } + + @override + void dispose() { + _focusNode.dispose(); + _popoverController.dispose(); + _buttonStatesController.dispose(); + super.dispose(); + } + + void _onItemSelected(TextItem? value) { + if (value == null) { + return; + } + widget.onSelected(value); + _popoverController.close(); + } + + void _onPopoverVisibilityChange() { + _buttonStatesController.update(WidgetState.pressed, _popoverController.shouldShow); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + popoverFocusNode: _focusNode, + parentFocusNode: widget.parentFocusNode, + tapRegionGroupId: widget.tapRegionGroupId, + controller: _popoverController, + buttonBuilder: _buildButton, + popoverBuilder: _buildPopover, + popoverGeometry: + widget.popoverGeometry ?? const PopoverGeometry(aligner: FunctionalPopoverAligner(popoverAligner)), + ); + } + + Widget _buildButton(BuildContext context) { + final size = WidgetStateProperty.all(widget.buttonSize ?? const Size(97, 30)); + return Stack( + alignment: Alignment.centerLeft, + children: [ + TextButton( + statesController: _buttonStatesController, + onPressed: () => _popoverController.open(), + style: defaultToolbarButtonStyle.copyWith( + fixedSize: size, + minimumSize: size, + maximumSize: size, + ), + child: SizedBox( + width: (widget.buttonSize?.width ?? 97) - 30, + child: Text( + widget.selectedText?.label ?? '', + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: TextStyle( + color: Colors.black.withValues(alpha: 0.7), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + const SizedBox(width: 10), + const Positioned( + right: 0, + child: Icon(Icons.arrow_drop_down), + ), + ], + ); + } + + Widget _buildPopover(BuildContext context) { + return Material( + elevation: 8, + borderRadius: BorderRadius.circular(4), + clipBehavior: Clip.hardEdge, + color: Colors.white, + child: SizedBox( + child: ItemSelectionList( + focusNode: _focusNode, + value: widget.selectedText, + items: widget.items, + itemBuilder: widget.itemBuilder, + separatorBuilder: widget.separatorBuilder, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + ), + ), + ); + } +} + +Widget defaultPopoverListItemBuilder(BuildContext context, TextItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 32), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + children: [ + Text( + item.label, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ); +} + +/// An option that is displayed as text by a [TextItemSelector]. +/// +/// Two [TextItem]s are considered to be equal if they have the same [id]. +class TextItem { + const TextItem({ + required this.id, + required this.label, + }); + + /// The value that identifies this item. + final String id; + + /// The text that is displayed. + final String label; + + @override + bool operator ==(Object other) => + identical(this, other) || other is TextItem && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/super_clones/google_docs/lib/main.dart b/super_clones/google_docs/lib/main.dart new file mode 100644 index 0000000000..4974e62daa --- /dev/null +++ b/super_clones/google_docs/lib/main.dart @@ -0,0 +1,6 @@ +import 'package:example_docs/app.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(const DocsApp()); +} diff --git a/super_clones/google_docs/lib/theme.dart b/super_clones/google_docs/lib/theme.dart new file mode 100644 index 0000000000..e70596a003 --- /dev/null +++ b/super_clones/google_docs/lib/theme.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/super_editor.dart'; + +/// The background color of the window panes, such as the background of the +/// app header/ribbon. +const windowBackgroundColor = Color(0xFFf9fbfd); + +/// The color of the icons that appear next to the document title. +const titleActionIconColor = Color(0xFF444746); + +/// The horizontal padding of the primary app menu buttons, e.g., "File", "Edit". +const menuButtonHorizontalPadding = 8.0; + +/// The background color of the app toolbar, i.e., the toolbar with options for font +/// family, font size, text alignment. +const toolbarBackgroundColor = Color(0xFFedf2fa); + +/// The background color of a selected button on the toolbar, i.e., the color of a +/// bold button when the selection is bold. +const toolbarButtonSelectedColor = Color(0xFFd3e3fd); + +/// The background color of a hovered button on the toolbar. +const toolbarButtonHoveredColor = Color(0xFFE1E6ED); + +/// The background color of a pressed button on the toolbar. +const toolbarButtonPressedColor = Color(0xFFDAE0E6); + +/// The color of the vertical divider of the toolbar. +const toolbarDividerColor = Color(0xFFC7C7C7); + +/// Computes the background color for toolbar buttons. +Color? getButtonColor(Set states) { + if (states.contains(WidgetState.pressed)) { + return toolbarButtonPressedColor; + } + + if (states.contains(WidgetState.selected)) { + return toolbarButtonSelectedColor; + } + + if (states.contains(WidgetState.hovered)) { + return toolbarButtonHoveredColor; + } + + return Colors.transparent; +} + +final defaultToolbarButtonStyle = ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith(getButtonColor), + overlayColor: WidgetStateProperty.all(Colors.transparent), + foregroundColor: WidgetStateProperty.all(Colors.black), + fixedSize: WidgetStateProperty.all(const Size(30, 30)), + minimumSize: WidgetStateProperty.all(const Size(30, 30)), + maximumSize: WidgetStateProperty.all(const Size(30, 30)), + iconSize: WidgetStateProperty.all(18), + padding: WidgetStateProperty.all(EdgeInsets.zero), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4.0), + ), + ), + shadowColor: WidgetStateProperty.all(Colors.transparent), +); + +final docsStylesheet = [ + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + height: 1.0, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + height: 1.0, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + height: 1.0, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header4"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + height: 1.0, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header5"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + height: 1.0, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header6"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + height: 1.0, + ), + }; + }, + ), +]; + +FollowerAlignment popoverAligner(Rect globalLeaderRect, Size followerSize, Size screenSize, GlobalKey? boundaryKey) { + return const FollowerAlignment( + leaderAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + followerOffset: Offset(0, 1), + ); +} diff --git a/super_clones/google_docs/lib/toolbar.dart b/super_clones/google_docs/lib/toolbar.dart new file mode 100644 index 0000000000..dacf6f7b27 --- /dev/null +++ b/super_clones/google_docs/lib/toolbar.dart @@ -0,0 +1,1563 @@ +import 'dart:math'; + +import 'package:example_docs/infrastructure/icon_selector.dart'; +import 'package:example_docs/infrastructure/color_selector.dart'; +import 'package:example_docs/infrastructure/text_item_selector.dart'; +import 'package:example_docs/infrastructure/increment_decrement_field.dart'; +import 'package:example_docs/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +/// The application toolbar that includes document editing options such as font family, +/// font size, text alignment, etc. +/// +/// The toolbar is divided by groups of children. Depending on the size of the screen, +/// some of the groups are hidden and a button to show the hidden groups is displayed. +/// Upon tapping this button, a popover is displayed revealing the hidden groups. +/// +/// All children of a visible group are displayed and no child is displayed for a +/// hidden group. +class DocsEditorToolbar extends StatefulWidget { + const DocsEditorToolbar({ + super.key, + required this.document, + required this.editor, + required this.composer, + required this.editorFocusNode, + required this.onZoomChange, + }); + + final Document document; + final Editor editor; + final MutableDocumentComposer composer; + final FocusNode editorFocusNode; + final void Function(int zoom) onZoomChange; + + @override + State createState() => _DocsEditorToolbarState(); +} + +class _DocsEditorToolbarState extends State { + /// Groups the additional toolbar options popover, which is shown by tapping + /// the "more items" button with the popovers shown by the toolbar items, + /// like the color picker. + static const _tapRegionGroupId = 'docs_toolbar'; + + final FocusNode _urlFocusNode = FocusNode(); + final PopoverController _linkPopoverController = PopoverController(); + ImeAttributedTextEditingController? _urlController; + + final PopoverController _searchPopoverController = PopoverController(); + final FocusNode _searchFocusNode = FocusNode(); + + TextItem _selectedZoom = const TextItem(id: '100', label: '100%'); + + @override + void initState() { + super.initState(); + widget.composer.selectionNotifier.addListener(_onSelectionChanged); + + _urlController = ImeAttributedTextEditingController() // + ..onPerformActionPressed = _onUrlFieldPerformAction + ..text = AttributedText("https://"); + } + + @override + void didUpdateWidget(covariant DocsEditorToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.composer.selectionNotifier != widget.composer.selectionNotifier) { + oldWidget.composer.selectionNotifier.addListener(_onSelectionChanged); + widget.composer.selectionNotifier.addListener(_onSelectionChanged); + } + } + + @override + void dispose() { + widget.composer.selectionNotifier.removeListener(_onSelectionChanged); + _urlFocusNode.dispose(); + _linkPopoverController.dispose(); + _searchPopoverController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void _onSelectionChanged() { + // Rebuild to update the visuals, because information like which buttons are toggled + // depends on the selection. + setState(() {}); + } + + void _onUrlFieldPerformAction(TextInputAction action) { + if (action == TextInputAction.done) { + _applyLink(); + } + } + + void _onChangeZoomLevelRequested(TextItem? zoomLevel) { + if (zoomLevel == null) { + return; + } + + widget.onZoomChange(int.parse(zoomLevel.id)); + + setState(() { + _selectedZoom = zoomLevel; + }); + } + + /// Converts the currently selected text node into a new type of + /// text node, represented by [newType]. + /// + /// For example: convert a paragraph to a blockquote, or a header + /// to a list item. + void _convertTextToNewType(String? newType) { + final existingTextType = _getCurrentTextType(); + + if (existingTextType == newType) { + // The text is already the desired type. Return. + return; + } + + // Apply a new block type to an existing paragraph node. + widget.editor.execute([ + ChangeParagraphBlockTypeRequest( + nodeId: widget.composer.selection!.extent.nodeId, + blockType: _getBlockTypeAttribution(newType), + ), + ]); + } + + /// Changes the font family of the current selected range to reflect [newFontFamily]. + /// + /// If [newFontFamily] is `null`, the font family attributions are removed and + /// the default font family is applied. + void _onChangeFontFamilyRequested(String? newFontFamily) { + final selection = widget.composer.selection; + if (selection == null) { + return; + } + + final fontFamilyAttributions = widget.document.getAttributionsByType(selection); + + widget.editor.execute([ + for (final existingAttribution in fontFamilyAttributions) // + RemoveTextAttributionsRequest(documentRange: selection, attributions: {existingAttribution}), + if (newFontFamily != null) // + AddTextAttributionsRequest( + documentRange: selection, + attributions: {FontFamilyAttribution(newFontFamily)}, + ), + ]); + + // Rebuild to update the selected font on the toolbar. + setState(() {}); + } + + /// Changes the font size of the current selected range to reflect [newFontSize]. + void _onChangeFontSizeRequested(int newFontSize) { + final selection = widget.composer.selection; + if (selection == null) { + return; + } + + final fontSizeAttributions = widget.document.getAttributionsByType(selection); + + widget.editor.execute([ + for (final existingAttribution in fontSizeAttributions) // + RemoveTextAttributionsRequest(documentRange: selection, attributions: {existingAttribution}), + AddTextAttributionsRequest( + documentRange: selection, + attributions: {FontSizeAttribution(newFontSize.toDouble())}, + ), + ]); + + // Rebuild to update the font size on the toolbar. + setState(() {}); + } + + /// Toggles the bold attribution on the current selected range. + void _onToggleBoldRequested() { + _toggleAttribution(boldAttribution); + } + + /// Toggles the italics attribution on the current selected range. + void _onToggleItalicsRequested() { + _toggleAttribution(italicsAttribution); + } + + /// Toggles the underline attribution on the current selected range. + void _onToggleUnderlineRequested() { + _toggleAttribution(underlineAttribution); + } + + /// Changes the color of the current selected range to reflect [newColor]. + /// + /// If [newColor] is `null`, the color attributions are removed and + /// the default color is applied. + void _onChangeTextColorRequested(Color? newColor) { + final selection = widget.composer.selection; + if (selection == null) { + return; + } + + final colorAttributions = widget.document.getAttributionsByType(selection); + + widget.editor.execute([ + for (final existingAttribution in colorAttributions) // + RemoveTextAttributionsRequest(documentRange: selection, attributions: {existingAttribution}), + if (newColor != null) // + AddTextAttributionsRequest( + documentRange: selection, + attributions: {ColorAttribution(newColor)}, + ), + ]); + + // Rebuild to update the color on the toolbar button. + setState(() {}); + } + + /// Changes the background color of the current selected range to reflect [newColor]. + /// + /// If [newColor] is `null`, the background color attributions are removed and + /// the default color is applied. + void _onChangeBackgroundColorRequested(Color? newColor) { + final selection = widget.composer.selection; + if (selection == null) { + return; + } + + final colorAttributions = widget.document.getAttributionsByType(selection); + + widget.editor.execute([ + for (final existingAttribution in colorAttributions) // + RemoveTextAttributionsRequest(documentRange: selection, attributions: {existingAttribution}), + if (newColor != null) // + AddTextAttributionsRequest( + documentRange: selection, + attributions: {BackgroundColorAttribution(newColor)}, + ), + ]); + + // Rebuild to update the background color on the toolbar button. + setState(() {}); + } + + /// Applies the link entered on the URL textfield to the current + /// selected range. + void _applyLink() { + final url = _urlController!.text.toPlainText(includePlaceholders: false); + + final selection = widget.composer.selection!; + final baseOffset = (selection.base.nodePosition as TextPosition).offset; + final extentOffset = (selection.extent.nodePosition as TextPosition).offset; + final selectionStart = min(baseOffset, extentOffset); + final selectionEnd = max(baseOffset, extentOffset); + final selectionRange = TextRange(start: selectionStart, end: selectionEnd - 1); + + final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; + final text = textNode.text; + + final trimmedRange = _trimTextRangeWhitespace(text, selectionRange); + + final linkAttribution = LinkAttribution.fromUri(Uri.parse(url)); + + widget.editor.execute([ + AddTextAttributionsRequest( + documentRange: DocumentRange( + start: DocumentPosition( + nodeId: textNode.id, + nodePosition: TextNodePosition(offset: trimmedRange.start), + ), + end: DocumentPosition( + nodeId: textNode.id, + nodePosition: TextNodePosition(offset: trimmedRange.end), + ), + ), + attributions: {linkAttribution}, + ), + ]); + + // Clear the field and hide the URL bar + _urlController!.clearTextAndSelection(); + _urlFocusNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + _linkPopoverController.close(); + setState(() {}); + } + + /// Changes the alignment of the current selected text node + /// to reflect [newAlignment]. + void _changeAlignment(TextAlign? newAlignment) { + if (widget.composer.selection == null || newAlignment == null) { + return; + } + + widget.editor.execute([ + ChangeParagraphAlignmentRequest( + nodeId: widget.composer.selection!.extent.nodeId, + alignment: newAlignment, + ), + ]); + } + + /// Converts the selected node to a [TaskNode], or to a + /// [ParagraphNode] if it's already a [TaskNode]. + void _onToggleTaskNodeRequested() { + final selection = widget.composer.selection; + if (selection == null) { + return; + } + + final node = widget.document.getNodeById(selection.extent.nodeId); + if (node is TaskNode) { + widget.editor.execute([ + DeleteUpstreamAtBeginningOfNodeRequest(node), + ]); + } else { + widget.editor.execute([ + ConvertParagraphToTaskRequest(nodeId: selection.extent.nodeId), + ]); + } + } + + /// Converts the selected node to a unordered [ListItemNode], + /// or to a [ParagraphNode] if it's already a [ListItemNode]. + void _onToggleUnorderedListItemRequested() { + final selection = widget.composer.selection; + if (selection == null) { + return; + } + + final node = widget.document.getNodeById(selection.extent.nodeId); + if (node is ListItemNode) { + widget.editor.execute([ + ConvertListItemToParagraphRequest(nodeId: node.id, paragraphMetadata: node.metadata), + ]); + } else { + widget.editor.execute([ + ConvertParagraphToListItemRequest( + nodeId: selection.extent.nodeId, + type: ListItemType.unordered, + ), + ]); + } + } + + /// Converts the selected node to a ordered [ListItemNode], + /// or to a [ParagraphNode] if it's already a [ListItemNode]. + void _onToggleOrderedListItemRequested() { + final selection = widget.composer.selection; + if (selection == null) { + return; + } + + final node = widget.document.getNodeById(selection.extent.nodeId); + if (node is ListItemNode) { + widget.editor.execute([ + ConvertListItemToParagraphRequest(nodeId: node.id, paragraphMetadata: node.metadata), + ]); + } else { + widget.editor.execute([ + ConvertParagraphToListItemRequest( + nodeId: selection.extent.nodeId, + type: ListItemType.ordered, + ), + ]); + } + } + + /// Removes all attributions from the selected range. + void _onClearFormattingRequested() { + final selection = widget.composer.selection; + if (selection == null) { + return; + } + } + + /// Shows a dialog with an alert that the selected feature + /// isn't implemented yet. + void _showNotImplementedAlert() { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Docs Demo'), + content: const Text('Feature not implemented yet'), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + /// Toggles the given [attribution] on the selected range. + void _toggleAttribution(Attribution attribution) { + if (widget.composer.selection == null) { + return; + } + + widget.editor.execute([ + ToggleTextAttributionsRequest( + documentRange: widget.composer.selection!, + attributions: {attribution}, + ), + ]); + + // Rebuild to update the toggled buttons on the toolbar. + setState(() {}); + } + + /// Reacts to the change of the alignment on the toolbar. + void _onChangeAlignmentRequested(IconItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _changeAlignment(TextAlign.values.firstWhere((e) => e.name == selectedItem.id)); + }); + } + } + + /// Reacts to the change of the block type on the toolbar. + void _onChangeBlockTypeRequested(TextItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _convertTextToNewType(selectedItem.id); + }); + } + } + + /// Given [text] and a [range] within the [text], the [range] is + /// shortened on both sides to remove any trailing whitespace and + /// the new range is returned. + SpanRange _trimTextRangeWhitespace(AttributedText text, TextRange range) { + int startOffset = range.start; + int endOffset = range.end; + + final plainText = text.toPlainText(); + while (startOffset < range.end && plainText[startOffset] == ' ') { + startOffset += 1; + } + while (endOffset > startOffset && plainText[endOffset] == ' ') { + endOffset -= 1; + } + + // Add 1 to the end offset because SpanRange treats the end offset to be exclusive. + return SpanRange(startOffset, endOffset + 1); + } + + bool _doesSelectionHaveAttributions(Set attributions) { + final selection = widget.composer.selection; + if (selection == null) { + return false; + } + + if (selection.isCollapsed) { + return widget.composer.preferences.currentAttributions.containsAll(attributions); + } + + return widget.document.doesSelectedTextContainAttributions(selection, attributions); + } + + /// Returns the text alignment of the currently selected text node. + /// + /// Throws an exception if the currently selected node is not a text node. + TextAlign _getCurrentTextAlignment() { + if (widget.composer.selection == null) { + return TextAlign.left; + } + final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); + if (selectedNode is ParagraphNode) { + final align = selectedNode.getMetadataValue('textAlign'); + switch (align) { + case 'left': + return TextAlign.left; + case 'center': + return TextAlign.center; + case 'right': + return TextAlign.right; + case 'justify': + return TextAlign.justify; + default: + return TextAlign.left; + } + } else { + throw Exception('Invalid node type: $selectedNode'); + } + } + + /// Returns the text [Attribution] associated with the given + /// block type, e.g., `"header1"` -> [header1Attribution]. + Attribution? _getBlockTypeAttribution(String? newType) { + return switch (newType) { + BlockTypes.header1 => header1Attribution, + BlockTypes.header2 => header2Attribution, + BlockTypes.header3 => header3Attribution, + BlockTypes.blockquote => blockquoteAttribution, + BlockTypes.paragraph => paragraphAttribution, + _ => null, + }; + } + + /// Returns whether or not the currently selected node is a paragraph. + bool _isParagraphNode() { + final selection = widget.composer.selection; + + if (selection == null) { + return false; + } + + if (selection.base.nodeId != selection.extent.nodeId) { + return false; + } + + final selectedNode = widget.document.getNodeById(selection.extent.nodeId); + return selectedNode is ParagraphNode; + } + + /// Returns the text type of the selected node, i.e, a header, + /// a blockquote, etc. + String? _getCurrentTextType() { + final selection = widget.composer.selection; + if (selection == null) { + return null; + } + + final selectedNode = widget.document.getNodeById(selection.extent.nodeId); + if (selectedNode is ParagraphNode) { + return (selectedNode.getMetadataValue('blockType') as NamedAttribution).id; + } + + return null; + } + + /// Returns all attributions of the currently selected range, if the selection is expanded, + /// or the current composer attributes, if the selection is collapsed. + Set _getAllAttributions() { + final selection = widget.composer.selection; + if (selection == null) { + return {}; + } + + if (selection.isCollapsed) { + return widget // + .composer + .preferences + .currentAttributions; + } + + return widget.document.getAllAttributions(selection); + } + + TextStyle _getDefaultTextStyleForBlockType(TextItem item) { + return switch (item.id) { + BlockTypes.header1 => const TextStyle( + color: Color(0xFF333333), + fontSize: 38, + fontWeight: FontWeight.bold, + ), + BlockTypes.header2 => const TextStyle( + color: Color(0xFF333333), + fontSize: 26, + fontWeight: FontWeight.bold, + ), + BlockTypes.header3 => const TextStyle( + color: Color(0xFF333333), + fontSize: 22, + fontWeight: FontWeight.bold, + ), + BlockTypes.paragraph => const TextStyle( + color: Colors.black, + fontSize: 18, + height: 1.4, + ), + BlockTypes.blockquote => const TextStyle( + color: Colors.grey, + fontSize: 20, + fontWeight: FontWeight.bold, + height: 1.4, + ), + _ => const TextStyle( + color: Colors.black, + fontSize: 18, + height: 1.4, + ) + }; + } + + void _showUrlPopover() { + _linkPopoverController.open(); + _urlFocusNode.requestFocus(); + } + + /// Computes how many button groups should be visible for the given [width]. + int _computeVisibleGroupCount(double width) { + return switch (width) { + >= 1300 => 5, + >= 1000 => 4, + >= 900 => 3, + >= 600 => 2, + _ => 1, + }; + } + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final visibleGroupCount = _computeVisibleGroupCount(width); + final attributions = _getAllAttributions(); + + return Material( + color: toolbarBackgroundColor, + shape: const StadiumBorder(), + child: SizedBox( + width: double.infinity, + height: 40, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 9.0), + child: _GroupedToolbarItems( + tapRegionGroupId: _tapRegionGroupId, + visibleGroupCount: visibleGroupCount, + groups: [ + _WidgetGroup( + widgets: [ + const SizedBox(width: 1), + _buildSearchPopoverButton(expanded: width > 1250), + const SizedBox(width: 7), + ToolbarImageButton( + onPressed: _showNotImplementedAlert, + hint: 'Undo', + child: const Icon(Icons.undo), + ), + const SizedBox(width: 1), + ToolbarImageButton( + onPressed: _showNotImplementedAlert, + hint: 'Redo', + child: const Icon(Icons.redo), + ), + const SizedBox(width: 1), + ToolbarImageButton( + onPressed: _showNotImplementedAlert, + hint: 'Print', + child: const Icon(Icons.print_outlined), + ), + const SizedBox(width: 1), + ToolbarImageButton( + onPressed: _showNotImplementedAlert, + hint: 'Spellcheck', + child: const Icon(Icons.spellcheck), + ), + const SizedBox(width: 1), + ToolbarImageButton( + onPressed: _showNotImplementedAlert, + hint: 'Paint Formating', + child: const Icon(Icons.format_paint_outlined), + ), + _buildZoomSelector(), + ], + ), + _WidgetGroup( + widgets: [ + _buildBlockTypeSelector(), + _buildFontFamilySelector(attributions), + ], + ), + _WidgetGroup( + widgets: [ + _buildFontSizeSelector(attributions), + ToolbarImageButton( + onPressed: _onToggleBoldRequested, + selected: _doesSelectionHaveAttributions({boldAttribution}), + hint: 'Bold', + child: const Icon(Icons.format_bold), + ), + const SizedBox(width: 2), + ToolbarImageButton( + onPressed: _onToggleItalicsRequested, + selected: _doesSelectionHaveAttributions({italicsAttribution}), + hint: 'Italic', + child: const Icon(Icons.format_italic), + ), + const SizedBox(width: 2), + ToolbarImageButton( + onPressed: _onToggleUnderlineRequested, + selected: _doesSelectionHaveAttributions({underlineAttribution}), + hint: 'Underline', + child: const Icon(Icons.format_underline), + ), + const SizedBox(width: 2), + _buildColorButton(attributions), + const SizedBox(width: 2), + _buildBackgroundColorButton(attributions), + ], + ), + _WidgetGroup( + widgets: [ + _buildLinkButton(), + ToolbarImageButton( + onPressed: _showNotImplementedAlert, + hint: 'Add comment', + child: const Icon(Icons.add_comment_outlined), + ), + ToolbarImageButton( + onPressed: _showNotImplementedAlert, + hint: 'Add photo', + child: const Icon(Icons.add_photo_alternate_outlined), + ), + ], + ), + _WidgetGroup( + widgets: [ + if (_isParagraphNode()) // + _buildAlignmentSelector(), + ToolbarImageButton( + onPressed: _showNotImplementedAlert, + hint: 'Line spacing', + child: const Icon(Icons.format_line_spacing), + ), + ToolbarImageButton( + onPressed: _onToggleTaskNodeRequested, + hint: 'Checklist', + child: const Icon(Icons.checklist), + ), + ToolbarImageButton( + onPressed: _onToggleUnorderedListItemRequested, + hint: 'Bulleted list', + child: const Icon(Icons.format_list_bulleted), + ), + ToolbarImageButton( + onPressed: _onToggleOrderedListItemRequested, + hint: 'Numbered list', + child: const Icon(Icons.format_list_numbered), + ), + ToolbarImageButton( + onPressed: _showNotImplementedAlert, + hint: 'Decrease indent', + child: const Icon(Icons.format_indent_decrease), + ), + ToolbarImageButton( + onPressed: _showNotImplementedAlert, + hint: 'Increase indent', + child: const Icon(Icons.format_indent_increase), + ), + ToolbarImageButton( + onPressed: _onClearFormattingRequested, + hint: 'Clear formatting', + child: const Icon(Icons.format_clear), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// Builds the search button, which upon tap shows a popover with + /// the available actions. + Widget _buildSearchPopoverButton({required bool expanded}) { + return PopoverScaffold( + parentFocusNode: widget.editorFocusNode, + popoverFocusNode: _searchFocusNode, + tapRegionGroupId: _tapRegionGroupId, + onTapOutside: (controller) => _searchPopoverController.close(), + controller: _searchPopoverController, + popoverGeometry: const PopoverGeometry( + aligner: FunctionalPopoverAligner(_searchPopoverAligner), + ), + buttonBuilder: (context) => _buildSearchButton(expanded: expanded), + popoverBuilder: (context) => _buildSearchPopover(), + ); + } + + /// Builds the button that triggers the search popover. + Widget _buildSearchButton({required bool expanded}) { + if (!expanded) { + return ToolbarImageButton( + onPressed: () => _searchPopoverController.open(), + hint: 'Search menus', + size: const Size(44, 30), + child: const Icon(Icons.search), + ); + } + + return Tooltip( + message: 'Search menus', + waitDuration: _tooltipDelay, + child: TextButton.icon( + onPressed: () { + _searchPopoverController.open(); + _searchFocusNode.requestFocus(); + }, + icon: const Icon( + Icons.search, + size: 18, + ), + label: const Text('Menus'), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(Colors.white), + overlayColor: WidgetStateProperty.all(Colors.transparent), + foregroundColor: WidgetStateProperty.all(Colors.black), + fixedSize: WidgetStateProperty.all(const Size(100, 30)), + minimumSize: WidgetStateProperty.all(const Size(100, 30)), + textStyle: WidgetStateProperty.all( + const TextStyle(fontWeight: FontWeight.w200), + ), + mouseCursor: WidgetStateProperty.all(SystemMouseCursors.text), + ), + ), + ); + } + + /// Builds the search popover with the available actions. + Widget _buildSearchPopover() { + return Material( + borderRadius: BorderRadius.circular(8.0), + elevation: 5, + color: Colors.white, + child: SizedBox( + width: 350, + height: 160, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const Icon(Icons.search, size: 18), + const SizedBox(width: 13), + SuperTextField( + hintBuilder: (context) => const Text('Menus (Options + /)'), + hintBehavior: HintBehavior.displayHintUntilTextEntered, + ), + ], + ), + ), + ItemSelectionList<_ActionMenu>( + value: _ActionMenu( + icon: Icons.format_bold, + label: 'Bold', + ), + focusNode: _searchFocusNode, + onCancel: () => _searchPopoverController.close(), + items: [ + _ActionMenu( + icon: Icons.format_bold, + label: 'Bold', + ), + _ActionMenu( + icon: Icons.format_italic, + label: 'Italic', + ), + _ActionMenu( + icon: Icons.format_underline, + label: 'Underline', + ), + ], + itemBuilder: (context, item, isActive, onTap) => SizedBox( + height: 40, + child: ColoredBox( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Icon(item.icon, size: 18), + const SizedBox(width: 13), + Expanded(child: Text(item.label)), + ], + ), + ), + ), + ), + onItemSelected: (_) => _searchPopoverController.close(), + ), + ], + ), + ), + ); + } + + /// Builds the zoom button, which upon tap shows a popover for the user + /// to select the zoom. + Widget _buildZoomSelector() { + return Tooltip( + message: 'Zoom', + waitDuration: _tooltipDelay, + child: TextItemSelector( + parentFocusNode: widget.editorFocusNode, + selectedText: _selectedZoom, + buttonSize: const Size(77, 30), + popoverGeometry: const PopoverGeometry( + constraints: BoxConstraints.tightFor(width: 77), + aligner: FunctionalPopoverAligner(popoverAligner), + ), + items: const [ + TextItem(id: '50', label: '50%'), + TextItem(id: '75', label: '75%'), + TextItem(id: '90', label: '90%'), + TextItem(id: '100', label: '100%'), + TextItem(id: '125', label: '125%'), + TextItem(id: '150', label: '150%'), + TextItem(id: '200', label: '200%'), + ], + onSelected: _onChangeZoomLevelRequested, + ), + ); + } + + /// Builds the block type button, which upon tap shows a popover for the user + /// to change the block type of the currently selected node. + Widget _buildBlockTypeSelector() { + final currentBlockType = _getCurrentTextType(); + + return Tooltip( + message: 'Styles', + waitDuration: _tooltipDelay, + child: TextItemSelector( + parentFocusNode: widget.editorFocusNode, + selectedText: currentBlockType != null // + ? _blockTypes.where((e) => e.id == currentBlockType).firstOrNull + : null, + buttonSize: const Size(122, 30), + popoverGeometry: const PopoverGeometry( + constraints: BoxConstraints.tightFor(width: 220), + aligner: FunctionalPopoverAligner(popoverAligner), + ), + items: _blockTypes, + onSelected: _onChangeBlockTypeRequested, + itemBuilder: (context, item, isActive, onTap) => DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 71), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(right: 20.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + child: currentBlockType != null && item.id == currentBlockType + ? const Icon( + Icons.check, + size: 18, + ) + : null, + ), + Text( + item.label, + overflow: TextOverflow.ellipsis, + style: _getDefaultTextStyleForBlockType(item), + ), + ], + ), + ), + ), + ), + separatorBuilder: (context, index) => const Divider(height: 1), + ), + ); + } + + /// Builds the font button, which upon tap shows a popover for the user + /// to change the font family of the selection. + Widget _buildFontFamilySelector(Set attributions) { + const defaultFont = 'Roboto'; + + final selectedFont = attributions.whereType().firstOrNull?.fontFamily ?? defaultFont; + final textItem = TextItem(id: selectedFont, label: selectedFont); + + return Tooltip( + message: 'Font', + waitDuration: _tooltipDelay, + child: TextItemSelector( + parentFocusNode: widget.editorFocusNode, + tapRegionGroupId: _tapRegionGroupId, + selectedText: textItem, + items: _availableFonts.map((fontFamily) => TextItem(id: fontFamily, label: fontFamily)).toList(), + onSelected: (value) => _onChangeFontFamilyRequested(value?.id), + buttonSize: const Size(97, 30), + popoverGeometry: const PopoverGeometry( + constraints: BoxConstraints.tightFor(width: 247), + aligner: FunctionalPopoverAligner(popoverAligner), + ), + itemBuilder: (context, item, isActive, onTap) => DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 32), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(right: 20.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + child: item == textItem + ? const Icon( + Icons.check, + size: 18, + ) + : null, + ), + Text( + item.id, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.getFont( + item.id, + textStyle: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + /// Builds the font size button, which upon tap shows a popover for the user + /// to change the font size of the selection. + Widget _buildFontSizeSelector(Set attributions) { + const defaultFontSize = 18; + + final fontAttribution = attributions.whereType().firstOrNull; + return Tooltip( + message: 'Font size', + waitDuration: _tooltipDelay, + child: IncrementDecrementField( + value: fontAttribution?.fontSize.toInt() ?? defaultFontSize, + onChange: _onChangeFontSizeRequested, + ), + ); + } + + /// Builds a color button, which changes the text color. + Widget _buildColorButton(Set attributions) { + final colorAttribution = attributions.whereType().firstOrNull; + + return Tooltip( + message: 'Text color', + waitDuration: _tooltipDelay, + child: ColorSelector( + parentFocusNode: widget.editorFocusNode, + tapRegionGroupId: _tapRegionGroupId, + onSelected: _onChangeTextColorRequested, + selectedColor: colorAttribution?.color ?? Colors.black, + colorButtonBuilder: (_, color) => _buildTextColorIcon(color), + ), + ); + } + + /// Builds the button icon with a rectangle of the selected [color]. + Widget _buildTextColorIcon(Color? color) { + return Stack( + children: [ + const Icon(Icons.format_color_text), + Positioned( + bottom: 0, + left: 1, + child: Container( + width: 16, + height: 4, + color: color ?? Colors.black, + ), + ), + ], + ); + } + + /// Builds a color button, which changes the text background color. + Widget _buildBackgroundColorButton(Set attributions) { + final colorAttribution = attributions.whereType().firstOrNull; + + return Tooltip( + message: 'Highlight color', + waitDuration: _tooltipDelay, + child: ColorSelector( + parentFocusNode: widget.editorFocusNode, + tapRegionGroupId: _tapRegionGroupId, + onSelected: _onChangeBackgroundColorRequested, + showClearButton: true, + selectedColor: colorAttribution?.color ?? Colors.black, + colorButtonBuilder: (_, color) => _buildBackgroundColorIcon(color), + ), + ); + } + + /// Builds the button icon with a rectangle of the selected [color]. + Widget _buildBackgroundColorIcon(Color? color) { + return Stack( + children: [ + const Icon(Icons.format_color_fill), + Positioned( + bottom: 0, + left: 1, + child: Container( + width: 16, + height: 4, + color: color ?? Colors.black, + ), + ), + ], + ); + } + + /// Builds the link button, which upon tap shows a popover for the user + /// to enter a URL. + Widget _buildLinkButton() { + return Tooltip( + message: 'Insert Link', + waitDuration: _tooltipDelay, + child: PopoverScaffold( + parentFocusNode: widget.editorFocusNode, + tapRegionGroupId: _tapRegionGroupId, + onTapOutside: (controller) => _linkPopoverController.close(), + controller: _linkPopoverController, + buttonBuilder: (context) => TextButton( + onPressed: _showUrlPopover, + style: defaultToolbarButtonStyle, + child: const Icon(Icons.link), + ), + popoverBuilder: (context) => _buildLinkPopover(), + ), + ); + } + + Widget _buildLinkPopover() { + return Material( + shape: const StadiumBorder(), + elevation: 5, + clipBehavior: Clip.hardEdge, + child: Container( + width: 400, + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: SuperTextField( + focusNode: _urlFocusNode, + textController: _urlController, + minLines: 1, + maxLines: 1, + inputSource: TextInputSource.ime, + hintBehavior: HintBehavior.displayHintUntilTextEntered, + hintBuilder: (context) { + return const Text( + "enter a url...", + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ); + }, + textStyleBuilder: (_) { + return const TextStyle( + color: Colors.black, + fontSize: 16, + ); + }, + ), + ), + IconButton( + icon: const Icon(Icons.close), + iconSize: 20, + splashRadius: 16, + padding: EdgeInsets.zero, + onPressed: () { + setState(() { + _urlFocusNode.unfocus(); + _urlController!.clearTextAndSelection(); + }); + }, + ), + ], + ), + ), + ); + } + + /// Builds the alignment button, which upon tap shows a popover for the user + /// to change the paragraph alignment. + Widget _buildAlignmentSelector() { + final alignment = _getCurrentTextAlignment(); + return Tooltip( + message: 'Alignment', + waitDuration: _tooltipDelay, + child: IconSelector( + parentFocusNode: widget.editorFocusNode, + tapRegionGroupId: _tapRegionGroupId, + selectedIcon: IconItem( + id: alignment.name, + icon: _getTextAlignIcon(alignment), + ), + icons: const [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify] + .map( + (alignment) => IconItem( + icon: _getTextAlignIcon(alignment), + id: alignment.name, + ), + ) + .toList(), + onSelected: _onChangeAlignmentRequested, + ), + ); + } +} + +/// The content of a toolbar, divided by groups of widgets. +/// +/// Only the groups with index less than [visibleGroupCount] +/// are displayed. When there is any hidden groups, a button is +/// displayed to show a popover with the remaining groups. +class _GroupedToolbarItems extends StatefulWidget { + const _GroupedToolbarItems({ + required this.groups, + required this.visibleGroupCount, + this.tapRegionGroupId, + }); + + /// All of the groups available. + final List<_WidgetGroup> groups; + + /// The number of groups to be displayed. + /// + /// All groups with an index greater of equal to [visibleGroupCount] + /// are hidden. + final int visibleGroupCount; + + /// A group ID for a tap region that is shared with the toolbar items + /// that display popovers. + /// + /// When the popover of hidden groups is displayed, a [TapRegion] is used + /// to close this popover upon tapping outside of it. If a popover child + /// also displays other popovers, tapping on the child's popover triggers + /// [TapRegion.onTapOutside] of the hidden groups popover, closing the popover. + /// To prevent that, provide a [tapRegionGroupId] with the same value as + /// child's [TapRegion] groupId. + final String? tapRegionGroupId; + + @override + State<_GroupedToolbarItems> createState() => _GroupedToolbarItemsState(); +} + +class _GroupedToolbarItemsState extends State<_GroupedToolbarItems> { + final PopoverController _popoverController = PopoverController(); + + @override + void dispose() { + _popoverController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _GroupedRow( + groups: widget.groups, + visibleGroupCount: widget.visibleGroupCount, + ), + if (widget.visibleGroupCount < widget.groups.length) // + PopoverScaffold( + tapRegionGroupId: widget.tapRegionGroupId, + onTapOutside: (controller) => _popoverController.close(), + controller: _popoverController, + popoverGeometry: PopoverGeometry(aligner: FunctionalPopoverAligner(_aditionalItensAligner)), + buttonBuilder: (context) => TextButton( + onPressed: () => _popoverController.open(), + style: defaultToolbarButtonStyle, + child: const Icon(Icons.more_vert), + ), + popoverBuilder: (context) => _buildAditionalOptionsPopover(), + ), + ], + ); + } + + Widget _buildAditionalOptionsPopover() { + return Material( + elevation: 8, + borderRadius: BorderRadius.circular(4), + color: toolbarBackgroundColor, + child: SizedBox( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 9.0), + child: Wrap( + children: [ + for (int i = widget.visibleGroupCount; i < widget.groups.length; i++) // + ...[ + ...widget.groups[i].widgets, + if (i < widget.groups.length - 1) // + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Container( + height: 30, + width: 1, + color: toolbarDividerColor, + ), + ) + ] + ], + ), + ), + ), + ); + } + + /// Aligns the "aditional items" popover top-right with the button bottom-right. + FollowerAlignment _aditionalItensAligner( + Rect globalLeaderRect, Size followerSize, Size screenSize, GlobalKey? boundaryKey) { + return const FollowerAlignment( + leaderAnchor: Alignment.bottomRight, + followerAnchor: Alignment.topRight, + followerOffset: Offset(0, 6), + ); + } +} + +/// A row that takes groups of widgets and displays a subset of them separated +/// by a vertical divider. +/// +/// Only the groups with index less than [visibleGroupCount] are displayed. +/// When the visibility of a group changes, its opacity and size is animated. +/// +/// A group is either entirely visible or entirely invisible. +class _GroupedRow extends StatelessWidget { + const _GroupedRow({ + required this.groups, + required this.visibleGroupCount, + }); + + /// The groups of widgets to be displayed. + final List<_WidgetGroup> groups; + + /// The number of groups that should be visible. + final int visibleGroupCount; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < groups.length; i++) // + AnimatedOpacity( + opacity: i < visibleGroupCount ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + child: SizedBox( + width: i < visibleGroupCount ? null : 0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...groups[i].widgets, + if (i < visibleGroupCount - 1 && i < groups.length - 1) // + const Padding( + padding: EdgeInsets.symmetric(horizontal: 2.0), + child: VerticalDivider( + width: 1, + color: toolbarDividerColor, + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +/// A group of widgets that should be either all visible or all invisible. +/// +/// This class is used to avoid passing lists of lists of Widgets around, +/// to represent lists of groups. +class _WidgetGroup { + _WidgetGroup({ + required this.widgets, + }); + + final List widgets; +} + +/// A button that applies a default style and applies the `MaterialState.selected` +/// when [selected] is `true`. +class ToolbarImageButton extends StatefulWidget { + const ToolbarImageButton({ + super.key, + this.onPressed, + this.selected = false, + this.size, + required this.hint, + required this.child, + }); + + /// Called when the button is pressed. + final VoidCallback? onPressed; + + /// Whether or not the internal button should contain the state + /// `MaterialState.selected`. + final bool selected; + + /// The desired size for the button. + final Size? size; + + /// Hint text displayed when hovering the button. + final String hint; + + final Widget child; + + @override + State createState() => _ToolbarImageButtonState(); +} + +class _ToolbarImageButtonState extends State { + final WidgetStatesController _statesController = WidgetStatesController(); + + @override + void initState() { + super.initState(); + _statesController.update(WidgetState.selected, widget.selected); + } + + @override + void didUpdateWidget(covariant ToolbarImageButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selected != widget.selected) { + _statesController.update(WidgetState.selected, widget.selected); + } + } + + @override + void dispose() { + _statesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = widget.size ?? const Size(30, 30); + return Tooltip( + message: widget.hint, + waitDuration: _tooltipDelay, + child: TextButton( + onPressed: widget.onPressed, + statesController: _statesController, + style: defaultToolbarButtonStyle.copyWith( + fixedSize: WidgetStateProperty.all(size), + minimumSize: WidgetStateProperty.all(size), + maximumSize: WidgetStateProperty.all(size), + ), + child: widget.child, + ), + ); + } +} + +/// An option displayed on the "Search" button popover. +class _ActionMenu { + _ActionMenu({ + required this.icon, + required this.label, + }); + + final IconData icon; + final String label; + + @override + bool operator ==(Object other) => + identical(this, other) || other is _ActionMenu && runtimeType == other.runtimeType && icon == other.icon; + + @override + int get hashCode => icon.hashCode; +} + +/// The fonts displayed on the font family selector. +final _availableFonts = [ + 'Amatic SC', + 'Caveat', + 'Comfortaa', + 'Comic Neue', + 'Courier Prime', + 'EB Garamond', + 'Lexend', + 'Lobster', + 'Lora', + 'Merriweather', + 'Montserrat', + 'Nunito', + 'Oswald', + 'Pacifico', + 'Playfair Display', + 'Roboto', + 'Roboto Mono', + 'Roboto Serif', + 'Special Elite', +]; + +/// The block types displayed in the block type selector. +const _blockTypes = [ + TextItem(id: BlockTypes.header1, label: 'Header 1'), + TextItem(id: BlockTypes.header2, label: 'Header 2'), + TextItem(id: BlockTypes.header3, label: 'Header 3'), + TextItem(id: BlockTypes.paragraph, label: 'Normal Text'), + TextItem(id: BlockTypes.blockquote, label: 'Blockquote'), +]; + +const _tooltipDelay = Duration(milliseconds: 500); + +IconData _getTextAlignIcon(TextAlign align) { + switch (align) { + case TextAlign.left: + case TextAlign.start: + return Icons.format_align_left; + case TextAlign.center: + return Icons.format_align_center; + case TextAlign.right: + case TextAlign.end: + return Icons.format_align_right; + case TextAlign.justify: + return Icons.format_align_justify; + } +} + +/// Aligns the top-left of the leader with the top-left of the follower. +FollowerAlignment _searchPopoverAligner( + Rect globalLeaderRect, Size followerSize, Size screenSize, GlobalKey? boundaryKey) { + return const FollowerAlignment( + leaderAnchor: Alignment.topLeft, + followerAnchor: Alignment.topLeft, + ); +} + +/// Common identifiers for the block types used in the app. +class BlockTypes { + static const header1 = 'header1'; + static const header2 = 'header2'; + static const header3 = 'header3'; + static const blockquote = 'blockquote'; + static const paragraph = 'paragraph'; +} diff --git a/super_clones/google_docs/linux/.gitignore b/super_clones/google_docs/linux/.gitignore new file mode 100644 index 0000000000..d3896c9844 --- /dev/null +++ b/super_clones/google_docs/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/super_clones/google_docs/linux/CMakeLists.txt b/super_clones/google_docs/linux/CMakeLists.txt new file mode 100644 index 0000000000..d352e4580e --- /dev/null +++ b/super_clones/google_docs/linux/CMakeLists.txt @@ -0,0 +1,145 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example_docs") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.example_docs") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/super_clones/google_docs/linux/flutter/CMakeLists.txt b/super_clones/google_docs/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000000..d5bd01648a --- /dev/null +++ b/super_clones/google_docs/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/super_clones/google_docs/linux/flutter/generated_plugin_registrant.cc b/super_clones/google_docs/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..f6f23bfe97 --- /dev/null +++ b/super_clones/google_docs/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/super_clones/google_docs/linux/flutter/generated_plugin_registrant.h b/super_clones/google_docs/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..e0f0a47bc0 --- /dev/null +++ b/super_clones/google_docs/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/super_clones/google_docs/linux/flutter/generated_plugins.cmake b/super_clones/google_docs/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..f16b4c3421 --- /dev/null +++ b/super_clones/google_docs/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/super_clones/google_docs/linux/main.cc b/super_clones/google_docs/linux/main.cc new file mode 100644 index 0000000000..e7c5c54370 --- /dev/null +++ b/super_clones/google_docs/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/super_clones/google_docs/linux/my_application.cc b/super_clones/google_docs/linux/my_application.cc new file mode 100644 index 0000000000..8ef27ac1fe --- /dev/null +++ b/super_clones/google_docs/linux/my_application.cc @@ -0,0 +1,124 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "example_docs"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "example_docs"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/super_clones/google_docs/linux/my_application.h b/super_clones/google_docs/linux/my_application.h new file mode 100644 index 0000000000..72271d5e41 --- /dev/null +++ b/super_clones/google_docs/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/super_clones/google_docs/macos/.gitignore b/super_clones/google_docs/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/super_clones/google_docs/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/super_clones/google_docs/macos/Flutter/Flutter-Debug.xcconfig b/super_clones/google_docs/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..4b81f9b2d2 --- /dev/null +++ b/super_clones/google_docs/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_clones/google_docs/macos/Flutter/Flutter-Release.xcconfig b/super_clones/google_docs/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5caa9d1579 --- /dev/null +++ b/super_clones/google_docs/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_clones/google_docs/macos/Flutter/GeneratedPluginRegistrant.swift b/super_clones/google_docs/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..a1cdfd0cd9 --- /dev/null +++ b/super_clones/google_docs/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import path_provider_foundation +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/super_clones/google_docs/macos/Podfile b/super_clones/google_docs/macos/Podfile new file mode 100644 index 0000000000..b52666a103 --- /dev/null +++ b/super_clones/google_docs/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.15' + +# 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__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/super_clones/google_docs/macos/Podfile.lock b/super_clones/google_docs/macos/Podfile.lock new file mode 100644 index 0000000000..529de006ae --- /dev/null +++ b/super_clones/google_docs/macos/Podfile.lock @@ -0,0 +1,29 @@ +PODS: + - FlutterMacOS (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 + +COCOAPODS: 1.16.2 diff --git a/super_clones/google_docs/macos/Runner.xcodeproj/project.pbxproj b/super_clones/google_docs/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..06513accfb --- /dev/null +++ b/super_clones/google_docs/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,791 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + 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 */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 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 */; }; + 5A172B85CD48C0393B809E4B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 38AAD14857BB5ED261D82F81 /* Pods_Runner.framework */; }; + C601DBF51F895CA52AABE4F5 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46F822D71EC0F56AB3B751FD /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 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 */ + 21727F45B11C02AC16A966DE /* 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 = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example_docs.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example_docs.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 38AAD14857BB5ED261D82F81 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3A60D6070323B014C0478622 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 46F822D71EC0F56AB3B751FD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A7F656F4C68E03056ADF2154 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + CCB80849E380D1FE2CFBC174 /* 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 = ""; }; + D1558C4424714AA123578682 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + DB3F90EAA618ACAF38603323 /* 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C601DBF51F895CA52AABE4F5 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5A172B85CD48C0393B809E4B /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1206F8BB4E786A36A6B9B5E3 /* Pods */ = { + isa = PBXGroup; + children = ( + CCB80849E380D1FE2CFBC174 /* Pods-Runner.debug.xcconfig */, + DB3F90EAA618ACAF38603323 /* Pods-Runner.release.xcconfig */, + 21727F45B11C02AC16A966DE /* Pods-Runner.profile.xcconfig */, + A7F656F4C68E03056ADF2154 /* Pods-RunnerTests.debug.xcconfig */, + D1558C4424714AA123578682 /* Pods-RunnerTests.release.xcconfig */, + 3A60D6070323B014C0478622 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 1206F8BB4E786A36A6B9B5E3 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example_docs.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 38AAD14857BB5ED261D82F81 /* Pods_Runner.framework */, + 46F822D71EC0F56AB3B751FD /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 8A5719BBF91DB16A626AA80D /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 982ECD51A9E5799D108FDAEB /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 60C4EE200B3AC461E253D8AF /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example_docs.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 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 */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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; + alwaysOutOfDate = 1; + 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"; + }; + 60C4EE200B3AC461E253D8AF /* [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; + }; + 8A5719BBF91DB16A626AA80D /* [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-RunnerTests-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; + }; + 982ECD51A9E5799D108FDAEB /* [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 */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 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 = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A7F656F4C68E03056ADF2154 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleDocs.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example_docs.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example_docs"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D1558C4424714AA123578682 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleDocs.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example_docs.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example_docs"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3A60D6070323B014C0478622 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleDocs.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example_docs.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example_docs"; + }; + name = Profile; + }; + 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.15; + 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.15; + 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.15; + 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 */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 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 */; +} diff --git a/super_clones/google_docs/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/google_docs/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/google_docs/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/google_docs/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_clones/google_docs/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..d0dbb0a040 --- /dev/null +++ b/super_clones/google_docs/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/google_docs/macos/Runner.xcworkspace/contents.xcworkspacedata b/super_clones/google_docs/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_clones/google_docs/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_clones/google_docs/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/google_docs/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/google_docs/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/google_docs/macos/Runner/AppDelegate.swift b/super_clones/google_docs/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..b3c1761412 --- /dev/null +++ b/super_clones/google_docs/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..82b6f9d9a3 Binary files /dev/null and b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000..13b35eba55 Binary files /dev/null and b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000..0a3f5fa40f Binary files /dev/null and b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000..bdb57226d5 Binary files /dev/null and b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000000..f083318e09 Binary files /dev/null and b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000..326c0e72c9 Binary files /dev/null and b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000..2f1632cfdd Binary files /dev/null and b/super_clones/google_docs/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/super_clones/google_docs/macos/Runner/Base.lproj/MainMenu.xib b/super_clones/google_docs/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/super_clones/google_docs/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/google_docs/macos/Runner/Configs/AppInfo.xcconfig b/super_clones/google_docs/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..e08cae30a2 --- /dev/null +++ b/super_clones/google_docs/macos/Runner/Configs/AppInfo.xcconfig @@ -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 = example_docs + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleDocs + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. diff --git a/super_clones/google_docs/macos/Runner/Configs/Debug.xcconfig b/super_clones/google_docs/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/super_clones/google_docs/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_clones/google_docs/macos/Runner/Configs/Release.xcconfig b/super_clones/google_docs/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/super_clones/google_docs/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_clones/google_docs/macos/Runner/Configs/Warnings.xcconfig b/super_clones/google_docs/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/super_clones/google_docs/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/super_clones/google_docs/macos/Runner/DebugProfile.entitlements b/super_clones/google_docs/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..0bfcd596ff --- /dev/null +++ b/super_clones/google_docs/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + \ No newline at end of file diff --git a/super_clones/google_docs/macos/Runner/Info.plist b/super_clones/google_docs/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/super_clones/google_docs/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/super_clones/google_docs/macos/Runner/MainFlutterWindow.swift b/super_clones/google_docs/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..3cc05eb234 --- /dev/null +++ b/super_clones/google_docs/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/super_clones/google_docs/macos/Runner/Release.entitlements b/super_clones/google_docs/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..4e40553203 --- /dev/null +++ b/super_clones/google_docs/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + \ No newline at end of file diff --git a/super_clones/google_docs/macos/RunnerTests/RunnerTests.swift b/super_clones/google_docs/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..5418c9f539 --- /dev/null +++ b/super_clones/google_docs/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import FlutterMacOS +import Cocoa +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_clones/google_docs/pubspec.lock b/super_clones/google_docs/pubspec.lock new file mode 100644 index 0000000000..256217a828 --- /dev/null +++ b/super_clones/google_docs/pubspec.lock @@ -0,0 +1,759 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.dev" + source: hosted + version: "7.7.1" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + attributed_text: + dependency: "direct overridden" + description: + path: "../../attributed_text" + relative: true + source: path + version: "0.4.5" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "4b03e11f6d5b8f6e5bb5e9f7889a56fe6c5cbe942da5378ea4d4d7f73ef9dfe5" + url: "https://pub.dev" + source: hosted + version: "1.11.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + url: "https://pub.dev" + source: hosted + version: "2.0.32" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_test_robots: + dependency: transitive + description: + name: flutter_test_robots + sha256: "3b00f2081148bde55190997c2772f934ad2f4529cbcfc4ccfa593f8ddc117a28" + url: "https://pub.dev" + source: hosted + version: "0.0.24" + flutter_test_runners: + dependency: transitive + description: + name: flutter_test_runners + sha256: cc575117ed66a79185a26995399d7048341517a1bd21188cb43753739627832d + url: "https://pub.dev" + source: hosted + version: "0.0.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + follow_the_leader: + dependency: "direct main" + description: + name: follow_the_leader + sha256: "2e4c4ebe6b3f1942b2385904b118ba8ba117fae0b30c8c453be0b64a271dd07a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + http: + dependency: transitive + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlord: + dependency: "direct main" + description: + name: overlord + sha256: "532f5685ac09ee805d97ce89794a4eeda41672c32955b4a835bdfce93e720a05" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "8c4967f8b7cb46dc914e178daa29813d83ae502e0529d7b0478330616a691ef7" + url: "https://pub.dev" + source: hosted + version: "2.2.14" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + super_editor: + dependency: "direct main" + description: + path: "../../super_editor" + relative: true + source: path + version: "0.3.0-dev.38" + super_keyboard: + dependency: transitive + description: + name: super_keyboard + sha256: e3accebf33635f760efbd4d3c13f6484242a09e773ce8e711f4aa745d52b73b1 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + super_text_layout: + dependency: "direct main" + description: + path: "../../super_text_layout" + relative: true + source: path + version: "0.1.19" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/super_clones/google_docs/pubspec.yaml b/super_clones/google_docs/pubspec.yaml new file mode 100644 index 0000000000..5bbd956162 --- /dev/null +++ b/super_clones/google_docs/pubspec.yaml @@ -0,0 +1,72 @@ +name: example_docs +description: "Example - Docs" +publish_to: "none" + +version: 1.0.0+1 + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.6 + follow_the_leader: ^0.5.1 + super_editor: + path: ../../super_editor + super_text_layout: + path: ../../super_text_layout + google_fonts: ^6.3.2 + overlord: ^0.4.2 + +dependency_overrides: + # Override to local mono-repo path so devs can test this repo + # against changes that they're making to other mono-repo packages + super_editor: + path: ../../super_editor + super_text_layout: + path: ../../super_text_layout + attributed_text: + path: ../../attributed_text + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +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: + - assets/images/ + + # 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 diff --git a/super_clones/google_docs/web/favicon.png b/super_clones/google_docs/web/favicon.png new file mode 100644 index 0000000000..8aaa46ac1a Binary files /dev/null and b/super_clones/google_docs/web/favicon.png differ diff --git a/super_clones/google_docs/web/icons/Icon-192.png b/super_clones/google_docs/web/icons/Icon-192.png new file mode 100644 index 0000000000..b749bfef07 Binary files /dev/null and b/super_clones/google_docs/web/icons/Icon-192.png differ diff --git a/super_clones/google_docs/web/icons/Icon-512.png b/super_clones/google_docs/web/icons/Icon-512.png new file mode 100644 index 0000000000..88cfd48dff Binary files /dev/null and b/super_clones/google_docs/web/icons/Icon-512.png differ diff --git a/super_clones/google_docs/web/icons/Icon-maskable-192.png b/super_clones/google_docs/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000..eb9b4d76e5 Binary files /dev/null and b/super_clones/google_docs/web/icons/Icon-maskable-192.png differ diff --git a/super_clones/google_docs/web/icons/Icon-maskable-512.png b/super_clones/google_docs/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000..d69c56691f Binary files /dev/null and b/super_clones/google_docs/web/icons/Icon-maskable-512.png differ diff --git a/super_clones/google_docs/web/index.html b/super_clones/google_docs/web/index.html new file mode 100644 index 0000000000..e1f7e65ddc --- /dev/null +++ b/super_clones/google_docs/web/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + example_docs + + + + + + + + + + diff --git a/super_clones/google_docs/web/manifest.json b/super_clones/google_docs/web/manifest.json new file mode 100644 index 0000000000..e7ab54b6b2 --- /dev/null +++ b/super_clones/google_docs/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example_docs", + "short_name": "example_docs", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/super_clones/google_docs/windows/.gitignore b/super_clones/google_docs/windows/.gitignore new file mode 100644 index 0000000000..d492d0d98c --- /dev/null +++ b/super_clones/google_docs/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/super_clones/google_docs/windows/CMakeLists.txt b/super_clones/google_docs/windows/CMakeLists.txt new file mode 100644 index 0000000000..6644518284 --- /dev/null +++ b/super_clones/google_docs/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(example_docs LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example_docs") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/super_clones/google_docs/windows/flutter/CMakeLists.txt b/super_clones/google_docs/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000000..903f4899d6 --- /dev/null +++ b/super_clones/google_docs/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/super_clones/google_docs/windows/flutter/generated_plugin_registrant.cc b/super_clones/google_docs/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..4f7884874d --- /dev/null +++ b/super_clones/google_docs/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/super_clones/google_docs/windows/flutter/generated_plugin_registrant.h b/super_clones/google_docs/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..dc139d85a9 --- /dev/null +++ b/super_clones/google_docs/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/super_clones/google_docs/windows/flutter/generated_plugins.cmake b/super_clones/google_docs/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..88b22e5c77 --- /dev/null +++ b/super_clones/google_docs/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/super_clones/google_docs/windows/runner/CMakeLists.txt b/super_clones/google_docs/windows/runner/CMakeLists.txt new file mode 100644 index 0000000000..394917c053 --- /dev/null +++ b/super_clones/google_docs/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/super_clones/google_docs/windows/runner/Runner.rc b/super_clones/google_docs/windows/runner/Runner.rc new file mode 100644 index 0000000000..e83c3f1d7f --- /dev/null +++ b/super_clones/google_docs/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "example_docs" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example_docs" "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "example_docs.exe" "\0" + VALUE "ProductName", "example_docs" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/super_clones/google_docs/windows/runner/flutter_window.cpp b/super_clones/google_docs/windows/runner/flutter_window.cpp new file mode 100644 index 0000000000..955ee3038f --- /dev/null +++ b/super_clones/google_docs/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/super_clones/google_docs/windows/runner/flutter_window.h b/super_clones/google_docs/windows/runner/flutter_window.h new file mode 100644 index 0000000000..6da0652f05 --- /dev/null +++ b/super_clones/google_docs/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/super_clones/google_docs/windows/runner/main.cpp b/super_clones/google_docs/windows/runner/main.cpp new file mode 100644 index 0000000000..f0c99c733a --- /dev/null +++ b/super_clones/google_docs/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"example_docs", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/super_clones/google_docs/windows/runner/resource.h b/super_clones/google_docs/windows/runner/resource.h new file mode 100644 index 0000000000..66a65d1e4a --- /dev/null +++ b/super_clones/google_docs/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/super_clones/google_docs/windows/runner/resources/app_icon.ico b/super_clones/google_docs/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000..c04e20caf6 Binary files /dev/null and b/super_clones/google_docs/windows/runner/resources/app_icon.ico differ diff --git a/super_clones/google_docs/windows/runner/runner.exe.manifest b/super_clones/google_docs/windows/runner/runner.exe.manifest new file mode 100644 index 0000000000..a42ea7687c --- /dev/null +++ b/super_clones/google_docs/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/super_clones/google_docs/windows/runner/utils.cpp b/super_clones/google_docs/windows/runner/utils.cpp new file mode 100644 index 0000000000..b2b08734db --- /dev/null +++ b/super_clones/google_docs/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length <= 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/super_clones/google_docs/windows/runner/utils.h b/super_clones/google_docs/windows/runner/utils.h new file mode 100644 index 0000000000..3879d54755 --- /dev/null +++ b/super_clones/google_docs/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/super_clones/google_docs/windows/runner/win32_window.cpp b/super_clones/google_docs/windows/runner/win32_window.cpp new file mode 100644 index 0000000000..60608d0fe5 --- /dev/null +++ b/super_clones/google_docs/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/super_clones/google_docs/windows/runner/win32_window.h b/super_clones/google_docs/windows/runner/win32_window.h new file mode 100644 index 0000000000..e901dde684 --- /dev/null +++ b/super_clones/google_docs/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/super_clones/ios_messenger/.gitignore b/super_clones/ios_messenger/.gitignore new file mode 100644 index 0000000000..3820a95c65 --- /dev/null +++ b/super_clones/ios_messenger/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# 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 diff --git a/super_clones/ios_messenger/.metadata b/super_clones/ios_messenger/.metadata new file mode 100644 index 0000000000..e90f3ae0ef --- /dev/null +++ b/super_clones/ios_messenger/.metadata @@ -0,0 +1,33 @@ +# 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: "ac4e799d237041cf905519190471f657b657155a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ac4e799d237041cf905519190471f657b657155a + base_revision: ac4e799d237041cf905519190471f657b657155a + - platform: android + create_revision: ac4e799d237041cf905519190471f657b657155a + base_revision: ac4e799d237041cf905519190471f657b657155a + - platform: ios + create_revision: ac4e799d237041cf905519190471f657b657155a + base_revision: ac4e799d237041cf905519190471f657b657155a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_clones/ios_messenger/README.md b/super_clones/ios_messenger/README.md new file mode 100644 index 0000000000..398f83ed16 --- /dev/null +++ b/super_clones/ios_messenger/README.md @@ -0,0 +1,2 @@ +# iOS Messenger +A clone of the iOS Messenger app. diff --git a/super_clones/ios_messenger/analysis_options.yaml b/super_clones/ios_messenger/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_clones/ios_messenger/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_clones/ios_messenger/android/.gitignore b/super_clones/ios_messenger/android/.gitignore new file mode 100644 index 0000000000..be3943c96d --- /dev/null +++ b/super_clones/ios_messenger/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/super_clones/ios_messenger/android/app/build.gradle.kts b/super_clones/ios_messenger/android/app/build.gradle.kts new file mode 100644 index 0000000000..5e2be54e75 --- /dev/null +++ b/super_clones/ios_messenger/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.flutterbountyhunters.clones.iosmessenger.ios_messenger" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.flutterbountyhunters.clones.iosmessenger.ios_messenger" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/super_clones/ios_messenger/android/app/src/debug/AndroidManifest.xml b/super_clones/ios_messenger/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_clones/ios_messenger/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_clones/ios_messenger/android/app/src/main/AndroidManifest.xml b/super_clones/ios_messenger/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..fe81a876ba --- /dev/null +++ b/super_clones/ios_messenger/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/ios_messenger/android/app/src/main/kotlin/com/flutterbountyhunters/clones/iosmessenger/ios_messenger/MainActivity.kt b/super_clones/ios_messenger/android/app/src/main/kotlin/com/flutterbountyhunters/clones/iosmessenger/ios_messenger/MainActivity.kt new file mode 100644 index 0000000000..f7a418d566 --- /dev/null +++ b/super_clones/ios_messenger/android/app/src/main/kotlin/com/flutterbountyhunters/clones/iosmessenger/ios_messenger/MainActivity.kt @@ -0,0 +1,5 @@ +package com.flutterbountyhunters.clones.iosmessenger.ios_messenger + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/super_clones/ios_messenger/android/app/src/main/res/drawable-v21/launch_background.xml b/super_clones/ios_messenger/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/super_clones/ios_messenger/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_clones/ios_messenger/android/app/src/main/res/drawable/launch_background.xml b/super_clones/ios_messenger/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..304732f884 --- /dev/null +++ b/super_clones/ios_messenger/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_clones/ios_messenger/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/super_clones/ios_messenger/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..db77bb4b7b Binary files /dev/null and b/super_clones/ios_messenger/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/super_clones/ios_messenger/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/super_clones/ios_messenger/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..17987b79bb Binary files /dev/null and b/super_clones/ios_messenger/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/super_clones/ios_messenger/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/super_clones/ios_messenger/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..09d4391482 Binary files /dev/null and b/super_clones/ios_messenger/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/super_clones/ios_messenger/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/super_clones/ios_messenger/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d5f1c8d34e Binary files /dev/null and b/super_clones/ios_messenger/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/super_clones/ios_messenger/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/super_clones/ios_messenger/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..4d6372eebd Binary files /dev/null and b/super_clones/ios_messenger/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/super_clones/ios_messenger/android/app/src/main/res/values-night/styles.xml b/super_clones/ios_messenger/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/super_clones/ios_messenger/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_clones/ios_messenger/android/app/src/main/res/values/styles.xml b/super_clones/ios_messenger/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..cb1ef88056 --- /dev/null +++ b/super_clones/ios_messenger/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_clones/ios_messenger/android/app/src/profile/AndroidManifest.xml b/super_clones/ios_messenger/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_clones/ios_messenger/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_clones/ios_messenger/android/build.gradle.kts b/super_clones/ios_messenger/android/build.gradle.kts new file mode 100644 index 0000000000..dbee657bb5 --- /dev/null +++ b/super_clones/ios_messenger/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/super_clones/ios_messenger/android/gradle.properties b/super_clones/ios_messenger/android/gradle.properties new file mode 100644 index 0000000000..f018a61817 --- /dev/null +++ b/super_clones/ios_messenger/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/super_clones/ios_messenger/android/gradle/wrapper/gradle-wrapper.properties b/super_clones/ios_messenger/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..ac3b47926e --- /dev/null +++ b/super_clones/ios_messenger/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/super_clones/ios_messenger/android/settings.gradle.kts b/super_clones/ios_messenger/android/settings.gradle.kts new file mode 100644 index 0000000000..fb605bc840 --- /dev/null +++ b/super_clones/ios_messenger/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/super_clones/ios_messenger/ios/.gitignore b/super_clones/ios_messenger/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/super_clones/ios_messenger/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/super_clones/ios_messenger/ios/Flutter/AppFrameworkInfo.plist b/super_clones/ios_messenger/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..1dc6cf7652 --- /dev/null +++ b/super_clones/ios_messenger/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/super_clones/ios_messenger/ios/Flutter/Debug.xcconfig b/super_clones/ios_messenger/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..ec97fc6f30 --- /dev/null +++ b/super_clones/ios_messenger/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/super_clones/ios_messenger/ios/Flutter/Release.xcconfig b/super_clones/ios_messenger/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..c4855bfe20 --- /dev/null +++ b/super_clones/ios_messenger/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/super_clones/ios_messenger/ios/Podfile b/super_clones/ios_messenger/ios/Podfile new file mode 100644 index 0000000000..620e46eba6 --- /dev/null +++ b/super_clones/ios_messenger/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# 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', '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 Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/super_clones/ios_messenger/ios/Podfile.lock b/super_clones/ios_messenger/ios/Podfile.lock new file mode 100644 index 0000000000..798c1646e5 --- /dev/null +++ b/super_clones/ios_messenger/ios/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - Flutter (1.0.0) + - super_keyboard (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - super_keyboard (from `.symlinks/plugins/super_keyboard/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + super_keyboard: + :path: ".symlinks/plugins/super_keyboard/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + super_keyboard: 016de6ce9ab826f9a0b185608209d6a3b556d577 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/super_clones/ios_messenger/ios/Runner.xcodeproj/project.pbxproj b/super_clones/ios_messenger/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..d8dbdce9bc --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,731 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 5BF066CE62F61ED42C6DAD8C /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07331CCF461E9E3D6E0BCD95 /* Pods_RunnerTests.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 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 */; }; + EB088AEFAE5EE4EC132DF887 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E7F170A8FD0FBB26A1DF6EFA /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 07331CCF461E9E3D6E0BCD95 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 28969FBA8C252D99637A464A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3C557C49797C6288FF8198C7 /* 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 = ""; }; + 6EF04C66C7C1483B975C3345 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B6044A7BAB5CA5E0396F395A /* 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 = ""; }; + CDDFD5BA6386BFCD046EE66B /* 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 = ""; }; + E7F170A8FD0FBB26A1DF6EFA /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FC48D271C8D4BA13ED47FAF0 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EB088AEFAE5EE4EC132DF887 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EE4A7EB975617A273BA35C2D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5BF066CE62F61ED42C6DAD8C /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 3DB4C70CC687EA349406DFAA /* Pods */ = { + isa = PBXGroup; + children = ( + 3C557C49797C6288FF8198C7 /* Pods-Runner.debug.xcconfig */, + B6044A7BAB5CA5E0396F395A /* Pods-Runner.release.xcconfig */, + CDDFD5BA6386BFCD046EE66B /* Pods-Runner.profile.xcconfig */, + FC48D271C8D4BA13ED47FAF0 /* Pods-RunnerTests.debug.xcconfig */, + 6EF04C66C7C1483B975C3345 /* Pods-RunnerTests.release.xcconfig */, + 28969FBA8C252D99637A464A /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 3DB4C70CC687EA349406DFAA /* Pods */, + D436764D02065A614686ABFC /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + D436764D02065A614686ABFC /* Frameworks */ = { + isa = PBXGroup; + children = ( + E7F170A8FD0FBB26A1DF6EFA /* Pods_Runner.framework */, + 07331CCF461E9E3D6E0BCD95 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + EF2D06DA53D227D41590FF72 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + EE4A7EB975617A273BA35C2D /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + AA50EF494CA09A151C515441 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 56425FCEDD5FCEF531150569 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 56425FCEDD5FCEF531150569 /* [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; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + AA50EF494CA09A151C515441 /* [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; + }; + EF2D06DA53D227D41590FF72 /* [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-RunnerTests-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 */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 5RJWFAUGXQ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.iosmessenger.iosMessenger; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FC48D271C8D4BA13ED47FAF0 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.iosmessenger.iosMessenger.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6EF04C66C7C1483B975C3345 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.iosmessenger.iosMessenger.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 28969FBA8C252D99637A464A /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.iosmessenger.iosMessenger.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 5RJWFAUGXQ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.iosmessenger.iosMessenger; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 5RJWFAUGXQ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.iosmessenger.iosMessenger; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/super_clones/ios_messenger/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/super_clones/ios_messenger/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_clones/ios_messenger/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/ios_messenger/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/ios_messenger/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_clones/ios_messenger/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_clones/ios_messenger/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_clones/ios_messenger/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..e3773d42e2 --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/ios_messenger/ios/Runner.xcworkspace/contents.xcworkspacedata b/super_clones/ios_messenger/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_clones/ios_messenger/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/ios_messenger/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/ios_messenger/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_clones/ios_messenger/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_clones/ios_messenger/ios/Runner/AppDelegate.swift b/super_clones/ios_messenger/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..626664468b --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000..dc9ada4725 Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..7353c41ecf Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..6ed2d933e1 Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..4cd7b0099c Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..fe730945a0 Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..321773cd85 Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..502f463a9b Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..e9f5fea27c Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..84ac32ae7d Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..8953cba090 Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..0467bf12aa Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/super_clones/ios_messenger/ios/Runner/Base.lproj/LaunchScreen.storyboard b/super_clones/ios_messenger/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/ios_messenger/ios/Runner/Base.lproj/Main.storyboard b/super_clones/ios_messenger/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/ios_messenger/ios/Runner/Info.plist b/super_clones/ios_messenger/ios/Runner/Info.plist new file mode 100644 index 0000000000..793b56844e --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Ios Messenger + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ios_messenger + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/super_clones/ios_messenger/ios/Runner/Runner-Bridging-Header.h b/super_clones/ios_messenger/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/super_clones/ios_messenger/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/super_clones/ios_messenger/ios/RunnerTests/RunnerTests.swift b/super_clones/ios_messenger/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..86a7c3b1b6 --- /dev/null +++ b/super_clones/ios_messenger/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_clones/ios_messenger/lib/app.dart b/super_clones/ios_messenger/lib/app.dart new file mode 100644 index 0000000000..e65e7ef8cf --- /dev/null +++ b/super_clones/ios_messenger/lib/app.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:ios_messenger/conversation/conversation_screen.dart'; + +class IOSMessengerApp extends StatelessWidget { + const IOSMessengerApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Messenger', + theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple)), + home: const ConversationScreen(), + ); + } +} diff --git a/super_clones/ios_messenger/lib/conversation/chat_bubbles.dart b/super_clones/ios_messenger/lib/conversation/chat_bubbles.dart new file mode 100644 index 0000000000..e69de29bb2 diff --git a/super_clones/ios_messenger/lib/conversation/conversation_screen.dart b/super_clones/ios_messenger/lib/conversation/conversation_screen.dart new file mode 100644 index 0000000000..abde5ac062 --- /dev/null +++ b/super_clones/ios_messenger/lib/conversation/conversation_screen.dart @@ -0,0 +1,732 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_keyboard/super_keyboard.dart'; + +/// A chat experience, which includes a simulated list of comments, as well as +/// a bottom-mounted message editor, which uses `SuperEditor` for writing messages. +class ConversationScreen extends StatefulWidget { + const ConversationScreen({super.key}); + + @override + State createState() => _ConversationScreenState(); +} + +class _ConversationScreenState extends State { + final _messagePageController = MessagePageController(); + + @override + void initState() { + super.initState(); + + SKLog.startLogging(); + } + + @override + void dispose() { + SKLog.stopLogging(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MediaQuery( + // Simulate a dark platform brightness. + data: MediaQuery.of(context).copyWith(platformBrightness: Brightness.dark), + child: Material( + child: Stack( + children: [ + MessagePageScaffold( + controller: _messagePageController, + bottomSheetMinimumTopGap: 150, + bottomSheetMinimumHeight: 148, + contentBuilder: (contentContext, bottomSpacing) { + return MediaQuery.removePadding( + context: contentContext, + removeBottom: true, + // ^ Remove bottom padding because if we don't, when the keyboard + // opens to edit the bottom sheet, this content behind the bottom + // sheet adds some phantom space at the bottom, slightly pushing + // it up for no reason. + child: Stack( + children: [ + Positioned.fill(child: ColoredBox(color: Colors.black)), + Positioned.fill(child: _ChatThread(bottomSheetHeight: bottomSpacing)), + ], + ), + ); + }, + bottomSheetBuilder: (messageContext) { + return _MessageComposer(); + + // return _EditorBottomSheet( + // messagePageController: _messagePageController, + // ); + }, + ), + _buildAppBar(), + ], + ), + ), + ); + } + + Widget _buildAppBar() { + return AnnotatedRegion( + value: const SystemUiOverlayStyle( + statusBarBrightness: Brightness.dark, // iOS + statusBarIconBrightness: Brightness.light, // Android + ), + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaY: 10, sigmaX: 10), + child: ColoredBox( + color: Colors.grey.shade900.withValues(alpha: 0.8), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + icon: Icon(Icons.chevron_left), + iconSize: 40, + color: Colors.blueAccent, + onPressed: () {}, + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey), + ), + const SizedBox(height: 4), + Text("Jason", style: TextStyle(color: Colors.white, fontSize: 12)), + Text("Orlando, FL", style: TextStyle(color: Colors.grey, fontSize: 10)), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4, right: 8), + child: IconButton( + icon: Icon(Icons.video_camera_back_outlined), + iconSize: 30, + color: Colors.blueAccent, + onPressed: () {}, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// A simulated chat conversation thread, which is simulated as a bottom-aligned +/// list of tiles. +class _ChatThread extends StatefulWidget { + const _ChatThread({required this.bottomSheetHeight}); + + final double bottomSheetHeight; + + @override + State<_ChatThread> createState() => _ChatThreadState(); +} + +class _ChatThreadState extends State<_ChatThread> { + final _conversation = _createConversation(); + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: EdgeInsets.only( + top: 164, // FIXME: This value needs to be the height of the app bar. + bottom: widget.bottomSheetHeight, + ), + itemCount: _conversation.length, + reverse: true, + // ^ The list starts at the bottom and grows upward. This is how + // we should layout chat conversations where the most recent + // message appears at the bottom, and you want to retain the + // scroll offset near the newest messages, not the oldest. + itemBuilder: (context, index) { + final message = _conversation[_conversation.length - index - 1]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Align( + alignment: message.sender == _Actor.me ? Alignment.bottomRight : Alignment.bottomLeft, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 300), + child: Container( + decoration: ShapeDecoration( + shape: RoundedSuperellipseBorder(borderRadius: BorderRadius.circular(20)), + color: message.sender == _Actor.me ? Colors.blueAccent.shade200 : Colors.grey.shade900, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: SuperMessage(editor: createDefaultDocumentEditor(document: message.message)), + ), + ), + ), + ), + ); + }, + ); + } +} + +class _MessageComposer extends StatefulWidget { + const _MessageComposer(); + + @override + State<_MessageComposer> createState() => _MessageComposerState(); +} + +class _MessageComposerState extends State<_MessageComposer> { + @override + Widget build(BuildContext context) { + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaY: 10, sigmaX: 10), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.8), + child: Padding( + padding: EdgeInsets.only( + bottom: max(MediaQuery.viewPaddingOf(context).bottom, MediaQuery.viewInsetsOf(context).bottom), + ), + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 16), + child: Row( + spacing: 12, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey.shade900), + child: Center(child: Icon(Icons.add, color: Colors.grey.shade200, size: 20)), + ), + Expanded( + child: TextField( + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.4)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.4)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.4)), + ), + isCollapsed: true, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + hintText: "iMessage", + hintStyle: TextStyle(color: Colors.grey.shade600), + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 6.0), + child: Icon(Icons.mic, size: 20), + ), + suffixIconColor: Colors.grey.shade600, + suffixIconConstraints: BoxConstraints(maxHeight: 32), + ), + cursorColor: Colors.blueAccent.shade200, + cursorWidth: 2, + keyboardAppearance: Brightness.dark, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Bottom sheet, which includes a message editor. +class _EditorBottomSheet extends StatefulWidget { + const _EditorBottomSheet({required this.messagePageController}); + + final MessagePageController messagePageController; + + @override + State<_EditorBottomSheet> createState() => _EditorBottomSheetState(); +} + +class _EditorBottomSheetState extends State<_EditorBottomSheet> { + final _dragIndicatorKey = GlobalKey(); + + final _scrollController = ScrollController(); + + final _editorSheetKey = GlobalKey(); + late final Editor _editor; + + final _hasSelection = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode(id: Editor.createNodeId(), text: AttributedText("This is a pre-existing")), + ParagraphNode(id: Editor.createNodeId(), text: AttributedText("message")), + ParagraphNode(id: Editor.createNodeId(), text: AttributedText("It's tall for quick")), + ParagraphNode(id: Editor.createNodeId(), text: AttributedText("testing of")), + ParagraphNode(id: Editor.createNodeId(), text: AttributedText("intrinsic height that")), + ParagraphNode(id: Editor.createNodeId(), text: AttributedText("exceeds available space")), + ], + ), + composer: MutableDocumentComposer(), + ); + _editor.composer.selectionNotifier.addListener(_onSelectionChange); + } + + @override + void dispose() { + _editor.composer.selectionNotifier.removeListener(_onSelectionChange); + _editor.dispose(); + + _scrollController.dispose(); + + super.dispose(); + } + + void _onSelectionChange() { + _hasSelection.value = _editor.composer.selection != null; + + // If the editor doesn't have a selection then when it's collapsed it + // should be in preview mode. If the editor does have a selection, then + // when it's collapsed, it should be in intrinsic height mode. + widget.messagePageController.collapsedMode = _hasSelection.value + ? MessagePageSheetCollapsedMode.intrinsic + : MessagePageSheetCollapsedMode.preview; + } + + double _dragTouchOffsetFromIndicator = 0; + + void _onVerticalDragStart(DragStartDetails details) { + _dragTouchOffsetFromIndicator = _dragFingerOffsetFromIndicator(details.globalPosition); + + widget.messagePageController.onDragStart( + details.globalPosition.dy - _dragIndicatorOffsetFromTop + _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragUpdate(DragUpdateDetails details) { + widget.messagePageController.onDragUpdate( + details.globalPosition.dy - _dragIndicatorOffsetFromTop + _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragEnd(DragEndDetails details) { + widget.messagePageController.onDragEnd(); + } + + void _onVerticalDragCancel() { + widget.messagePageController.onDragEnd(); + } + + double get _dragIndicatorOffsetFromTop { + final editorSheetBox = _editorSheetKey.currentContext!.findRenderObject(); + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero, ancestor: editorSheetBox).dy; + } + + double _dragFingerOffsetFromIndicator(Offset globalDragOffset) { + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero).dy - globalDragOffset.dy; + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + key: _editorSheetKey, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.5), + border: Border(top: BorderSide(color: Colors.grey)), + ), + child: KeyboardScaffoldSafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDragHandle(), + Flexible(child: _buildSheetContent()), + ], + ), + ), + ); + } + + Widget _buildSheetContent() { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + // ^ Avoid the bottom notch when the keyboard is closed. + ), + child: BottomSheetEditorHeight( + previewHeight: 72, + child: _ChatEditor( + key: _editorKey, + editor: _editor, + messagePageController: widget.messagePageController, + scrollController: _scrollController, + ), + ), + ); + } + + // FIXME: Keyboard keeps closing without a bunch of global keys. Either + // document why, or figure out how to operate without all the keys. + final _editorKey = GlobalKey(); + + Widget _buildDragHandle() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onVerticalDragStart: _onVerticalDragStart, + onVerticalDragUpdate: _onVerticalDragUpdate, + onVerticalDragEnd: _onVerticalDragEnd, + onVerticalDragCancel: _onVerticalDragCancel, + behavior: HitTestBehavior.opaque, + // ^ Opaque to handle tough events in our invisible padding. + child: Padding( + padding: const EdgeInsets.all(18), + // ^ Expand the hit area with invisible padding. + child: Container( + key: _dragIndicatorKey, + width: 32, + height: 5, + decoration: BoxDecoration(color: Colors.grey, borderRadius: BorderRadius.circular(3)), + ), + ), + ), + ], + ); + } +} + +/// An editor for composing chat messages. +class _ChatEditor extends StatefulWidget { + const _ChatEditor({ + super.key, + required this.editor, + required this.messagePageController, + required this.scrollController, + }); + + final Editor editor; + final MessagePageController messagePageController; + final ScrollController scrollController; + + @override + State<_ChatEditor> createState() => _ChatEditorState(); +} + +class _ChatEditorState extends State<_ChatEditor> { + final _editorKey = GlobalKey(); + final _editorFocusNode = FocusNode(); + + late final KeyboardPanelController<_Panel> _keyboardPanelController; + late final SoftwareKeyboardController _softwareKeyboardController; + final _isImeConnected = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + _softwareKeyboardController = SoftwareKeyboardController(); + _keyboardPanelController = KeyboardPanelController(_softwareKeyboardController); + + widget.messagePageController.addListener(_onMessagePageControllerChange); + + _isImeConnected.addListener(_onImeConnectionChange); + + SuperKeyboard.instance.mobileGeometry.addListener(_onKeyboardChange); + } + + @override + void didUpdateWidget(_ChatEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.messagePageController != oldWidget.messagePageController) { + oldWidget.messagePageController.removeListener(_onMessagePageControllerChange); + widget.messagePageController.addListener(_onMessagePageControllerChange); + } + } + + @override + void dispose() { + SuperKeyboard.instance.mobileGeometry.removeListener(_onKeyboardChange); + + widget.messagePageController.removeListener(_onMessagePageControllerChange); + + _keyboardPanelController.dispose(); + _isImeConnected.dispose(); + + super.dispose(); + } + + void _onKeyboardChange() { + // On Android, we've found that when swiping to go back, the keyboard often + // closes without Flutter reporting the closure of the IME connection. + // Therefore, the keyboard closes, but editors and text fields retain focus, + // selection, and a supposedly open IME connection. + // + // Flutter issue: https://github.com/flutter/flutter/issues/165734 + // + // To hack around this bug in Flutter, when super_keyboard reports keyboard + // closure, and this controller thinks the keyboard is open, we give up + // focus so that our app state synchronizes with the closed IME connection. + final keyboardState = SuperKeyboard.instance.mobileGeometry.value.keyboardState; + if (_isImeConnected.value && (keyboardState == KeyboardState.closing || keyboardState == KeyboardState.closed)) { + _editorFocusNode.unfocus(); + } + } + + void _onImeConnectionChange() { + widget.messagePageController.collapsedMode = _isImeConnected.value + ? MessagePageSheetCollapsedMode.intrinsic + : MessagePageSheetCollapsedMode.preview; + } + + void _onMessagePageControllerChange() { + if (widget.messagePageController.isPreview) { + // Always scroll the editor to the top when in preview mode. + widget.scrollController.position.jumpTo(0); + } + } + + @override + Widget build(BuildContext context) { + return KeyboardPanelScaffold( + controller: _keyboardPanelController, + isImeConnected: _isImeConnected, + toolbarBuilder: (BuildContext context, _Panel? openPanel) { + return Container( + width: double.infinity, + height: 54, + color: Colors.white.withValues(alpha: 0.3), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Spacer(), + GestureDetector( + onTap: () { + _softwareKeyboardController.close(); + }, + child: Icon(Icons.keyboard_hide_outlined), + ), + ], + ), + ); + }, + keyboardPanelBuilder: (BuildContext context, _Panel? openPanel) { + return SizedBox(); + }, + contentBuilder: (BuildContext context, _Panel? openPanel) { + return SuperEditorFocusOnTap( + editorFocusNode: _editorFocusNode, + editor: widget.editor, + child: SuperEditorDryLayout( + controller: widget.scrollController, + superEditor: SuperEditor( + key: _editorKey, + focusNode: _editorFocusNode, + editor: widget.editor, + softwareKeyboardController: _softwareKeyboardController, + isImeConnected: _isImeConnected, + imePolicies: SuperEditorImePolicies(), + selectionPolicies: SuperEditorSelectionPolicies(), + shrinkWrap: false, + stylesheet: _chatStylesheet, + componentBuilders: [ + const HintComponentBuilder("Send a message...", _hintTextStyleBuilder), + ...defaultComponentBuilders, + ], + ), + ), + ); + }, + ); + } +} + +final _chatStylesheet = Stylesheet( + rules: [ + StyleRule(BlockSelector.all, (doc, docNode) { + return { + Styles.padding: const CascadingPadding.symmetric(horizontal: 24), + Styles.textStyle: const TextStyle(color: Colors.black, fontSize: 18, height: 1.4), + }; + }), + StyleRule(const BlockSelector("header1"), (doc, docNode) { + return {Styles.textStyle: const TextStyle(color: Color(0xFF333333), fontSize: 38, fontWeight: FontWeight.bold)}; + }), + StyleRule(const BlockSelector("header2"), (doc, docNode) { + return {Styles.textStyle: const TextStyle(color: Color(0xFF333333), fontSize: 26, fontWeight: FontWeight.bold)}; + }), + StyleRule(const BlockSelector("header3"), (doc, docNode) { + return {Styles.textStyle: const TextStyle(color: Color(0xFF333333), fontSize: 22, fontWeight: FontWeight.bold)}; + }), + StyleRule(const BlockSelector("paragraph"), (doc, docNode) { + return {Styles.padding: const CascadingPadding.only(bottom: 12)}; + }), + StyleRule(const BlockSelector("blockquote"), (doc, docNode) { + return { + Styles.textStyle: const TextStyle(color: Colors.grey, fontSize: 20, fontWeight: FontWeight.bold, height: 1.4), + }; + }), + StyleRule(BlockSelector.all.last(), (doc, docNode) { + return {Styles.padding: const CascadingPadding.only(bottom: 48)}; + }), + ], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, +); + +TextStyle _hintTextStyleBuilder(context) => TextStyle(color: Colors.grey); + +// FIXME: This widget is required because of the current shrink wrap behavior +// of Super Editor. If we set `shrinkWrap` to `false` then the bottom +// sheet always expands to max height. But if we set `shrinkWrap` to +// `true`, when we manually expand the bottom sheet, the only +// tappable area is wherever the document components actually appear. +// In the average case, that means only the top area of the bottom +// sheet can be tapped to place the caret. +// +// This widget should wrap Super Editor and make the whole area tappable. +/// A widget, that when pressed, gives focus to the [editorFocusNode], and places +/// the caret at the end of the content within an [editor]. +/// +/// It's expected that the [child] subtree contains the associated `SuperEditor`, +/// which owns the [editor] and [editorFocusNode]. +class SuperEditorFocusOnTap extends StatelessWidget { + const SuperEditorFocusOnTap({super.key, required this.editorFocusNode, required this.editor, required this.child}); + + final FocusNode editorFocusNode; + + final Editor editor; + + /// The SuperEditor that we're wrapping with this tap behavior. + final Widget child; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: editorFocusNode, + builder: (context, child) { + return ListenableBuilder( + listenable: editor.composer.selectionNotifier, + builder: (context, child) { + final shouldControlTap = editor.composer.selection == null || !editorFocusNode.hasFocus; + return GestureDetector( + onTap: editor.composer.selection == null || !editorFocusNode.hasFocus ? _selectEditor : null, + behavior: HitTestBehavior.opaque, + child: IgnorePointer( + ignoring: shouldControlTap, + // ^ Prevent the Super Editor from aggressively responding to + // taps, so that we can respond. + child: child, + ), + ); + }, + child: child, + ); + }, + child: child, + ); + } + + void _selectEditor() { + editorFocusNode.requestFocus(); + + final endNode = editor.document.last; + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition(nodeId: endNode.id, nodePosition: endNode.endPosition), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + } +} + +enum _Panel { thePanel } + +List<_Message> _createConversation() { + const conversation = <(_Actor, String)>[ + (_Actor.other, "Yo, you free this weekend?"), + (_Actor.other, "Got an idea 👀"), + (_Actor.me, "Oh? What’s up?"), + (_Actor.other, "Top Gun 2. IMAX. Mach 10. Let’s go."), + (_Actor.me, "LOL ok you sold that fast"), + (_Actor.me, "I’m in"), + (_Actor.other, "Nice. Saturday evening work?"), + (_Actor.me, "Yeah that should be good."), + (_Actor.me, "I just need to check one thing but pretty sure I’m free."), + (_Actor.other, "Cool cool."), + (_Actor.other, "Thinking AMC downtown — best seats + non-depressing popcorn."), + (_Actor.me, "😂 Accurate"), + (_Actor.me, "What time?"), + (_Actor.other, "7:45 show"), + (_Actor.other, "Want me to grab tickets?"), + (_Actor.me, "Yeah please! I’ll pay you back."), + (_Actor.other, "Done."), + (_Actor.other, "Also trying to avoid front row neck-snapping this time."), + (_Actor.me, "THANK you"), + (_Actor.me, "My spine still remembers last time"), + (_Actor.other, "Lol"), + (_Actor.other, "Got us the perfect row. Optimal jet-noise zone."), + (_Actor.me, "Legendary."), + (_Actor.me, "I’ll drive?"), + (_Actor.other, "Works for me. I’ll bring snacks."), + (_Actor.me, "Saturday = TOP GUN DAY 🛩️🔥"), + (_Actor.me, "It’s happening"), + (_Actor.other, "LET’S GOOOO"), + ]; + + return <_Message>[ + for (final pair in conversation) // + _Message( + pair.$1, + MutableDocument( + nodes: [ParagraphNode(id: Editor.createNodeId(), text: AttributedText(pair.$2))], + ), + ), + ]; +} + +class _Message { + const _Message(this.sender, this.message); + + final _Actor sender; + final MutableDocument message; +} + +enum _Actor { me, other } diff --git a/super_clones/ios_messenger/lib/main.dart b/super_clones/ios_messenger/lib/main.dart new file mode 100644 index 0000000000..b44b279f8e --- /dev/null +++ b/super_clones/ios_messenger/lib/main.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:ios_messenger/app.dart'; + +void main() { + runApp(const IOSMessengerApp()); +} diff --git a/super_clones/ios_messenger/pubspec.lock b/super_clones/ios_messenger/pubspec.lock new file mode 100644 index 0000000000..cd0706f5a3 --- /dev/null +++ b/super_clones/ios_messenger/pubspec.lock @@ -0,0 +1,713 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.dev" + source: hosted + version: "7.7.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + attributed_text: + dependency: transitive + description: + name: attributed_text + sha256: "177ea01f58a8d8df279f4066834375a2009bdd304d559c084bb06f784b258477" + url: "https://pub.dev" + source: hosted + version: "0.4.5" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_quill_delta: + dependency: transitive + description: + name: dart_quill_delta + sha256: "6aa89f0903ca3e70f5ceeb1d75d722f6ca583e87a2a8893c7b9f42f7a947f6e5" + url: "https://pub.dev" + source: hosted + version: "9.6.0" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + url: "https://pub.dev" + source: hosted + version: "2.0.32" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_test_robots: + dependency: transitive + description: + name: flutter_test_robots + sha256: "3b00f2081148bde55190997c2772f934ad2f4529cbcfc4ccfa593f8ddc117a28" + url: "https://pub.dev" + source: hosted + version: "0.0.24" + flutter_test_runners: + dependency: transitive + description: + name: flutter_test_runners + sha256: cc575117ed66a79185a26995399d7048341517a1bd21188cb43753739627832d + url: "https://pub.dev" + source: hosted + version: "0.0.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + follow_the_leader: + dependency: transitive + description: + name: follow_the_leader + sha256: "2e4c4ebe6b3f1942b2385904b118ba8ba117fae0b30c8c453be0b64a271dd07a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlord: + dependency: transitive + description: + name: overlord + sha256: "532f5685ac09ee805d97ce89794a4eeda41672c32955b4a835bdfce93e720a05" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + super_editor: + dependency: "direct main" + description: + path: "../../super_editor" + relative: true + source: path + version: "0.3.0-dev.40" + super_keyboard: + dependency: "direct main" + description: + name: super_keyboard + sha256: e3accebf33635f760efbd4d3c13f6484242a09e773ce8e711f4aa745d52b73b1 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + super_text_layout: + dependency: transitive + description: + name: super_text_layout + sha256: "166b0eb846d186b349986619ddcbf5227f33ee216b7758dfa1e37b1a26a785ef" + url: "https://pub.dev" + source: hosted + version: "0.1.19" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" + text_table: + dependency: transitive + description: + name: text_table + sha256: a42b35675be614274b884ee482d4bdf4bdf707bc65de18cb8f1ad288c1beb1f4 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: dff5e50339bf30b06d7950b50fda58164d3d8c40042b104ed041ddc520fbff28 + url: "https://pub.dev" + source: hosted + version: "6.3.25" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.35.0" diff --git a/super_clones/ios_messenger/pubspec.yaml b/super_clones/ios_messenger/pubspec.yaml new file mode 100644 index 0000000000..78e0ed6fc8 --- /dev/null +++ b/super_clones/ios_messenger/pubspec.yaml @@ -0,0 +1,60 @@ +name: ios_messenger +description: "A clone of the iOS Messenger app." +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +version: 1.0.0+1 + +environment: + sdk: ^3.9.2 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + super_editor: + path: ../../super_editor/ + super_keyboard: ^0.3.0 + +dev_dependencies: + flutter_lints: ^5.0.0 + flutter_test: + sdk: 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/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # 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/to/font-from-package diff --git a/super_clones/medium/.gitignore b/super_clones/medium/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/super_clones/medium/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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 +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/super_clones/medium/.metadata b/super_clones/medium/.metadata new file mode 100644 index 0000000000..9a613f0dcb --- /dev/null +++ b/super_clones/medium/.metadata @@ -0,0 +1,30 @@ +# 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: "17025dd88227cd9532c33fa78f5250d548d87e9a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: web + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_clones/medium/README.md b/super_clones/medium/README.md new file mode 100644 index 0000000000..3ea2507d58 --- /dev/null +++ b/super_clones/medium/README.md @@ -0,0 +1,2 @@ +# Medium +A Flutter clone of Medium. diff --git a/super_clones/medium/analysis_options.yaml b/super_clones/medium/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_clones/medium/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_clones/medium/lib/main.dart b/super_clones/medium/lib/main.dart new file mode 100644 index 0000000000..8e94089121 --- /dev/null +++ b/super_clones/medium/lib/main.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + // This is the theme of your application. + // + // TRY THIS: Try running your application with "flutter run". You'll see + // the application has a purple toolbar. Then, without quitting the app, + // try changing the seedColor in the colorScheme below to Colors.green + // and then invoke "hot reload" (save your changes or press the "hot + // reload" button in a Flutter-supported IDE, or press "r" if you used + // the command line to start the app). + // + // Notice that the counter didn't reset back to zero; the application + // state is not lost during the reload. To reset the state, use hot + // restart instead. + // + // This works for code too, not just values: Most code changes can be + // tested with just a hot reload. + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + // This widget is the home page of your application. It is stateful, meaning + // that it has a State object (defined below) that contains fields that affect + // how it looks. + + // This class is the configuration for the state. It holds the values (in this + // case the title) provided by the parent (in this case the App widget) and + // used by the build method of the State. Fields in a Widget subclass are + // always marked "final". + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + // This call to setState tells the Flutter framework that something has + // changed in this State, which causes it to rerun the build method below + // so that the display can reflect the updated values. If we changed + // _counter without calling setState(), then the build method would not be + // called again, and so nothing would appear to happen. + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + // This method is rerun every time setState is called, for instance as done + // by the _incrementCounter method above. + // + // The Flutter framework has been optimized to make rerunning build methods + // fast, so that you can just rebuild anything that needs updating rather + // than having to individually change instances of widgets. + return Scaffold( + appBar: AppBar( + // TRY THIS: Try changing the color here to a specific color (to + // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar + // change color while the other colors stay the same. + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + // Here we take the value from the MyHomePage object that was created by + // the App.build method, and use it to set our appbar title. + title: Text(widget.title), + ), + body: Center( + // Center is a layout widget. It takes a single child and positions it + // in the middle of the parent. + child: Column( + // Column is also a layout widget. It takes a list of children and + // arranges them vertically. By default, it sizes itself to fit its + // children horizontally, and tries to be as tall as its parent. + // + // Column has various properties to control how it sizes itself and + // how it positions its children. Here we use mainAxisAlignment to + // center the children vertically; the main axis here is the vertical + // axis because Columns are vertical (the cross axis would be + // horizontal). + // + // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" + // action in the IDE, or press "p" in the console), to see the + // wireframe for each widget. + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: const Icon(Icons.add), + ), // This trailing comma makes auto-formatting nicer for build methods. + ); + } +} diff --git a/super_clones/medium/pubspec.lock b/super_clones/medium/pubspec.lock new file mode 100644 index 0000000000..9999eda7a0 --- /dev/null +++ b/super_clones/medium/pubspec.lock @@ -0,0 +1,213 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.dev" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" +sdks: + dart: ">=3.6.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/super_clones/medium/pubspec.yaml b/super_clones/medium/pubspec.yaml new file mode 100644 index 0000000000..4950dcfac0 --- /dev/null +++ b/super_clones/medium/pubspec.yaml @@ -0,0 +1,59 @@ +name: medium +description: "A Flutter clone of Medium." +publish_to: "none" + +version: 1.0.0+1 + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^5.0.0 + +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/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # 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/to/font-from-package diff --git a/super_clones/medium/web/favicon.png b/super_clones/medium/web/favicon.png new file mode 100644 index 0000000000..8aaa46ac1a Binary files /dev/null and b/super_clones/medium/web/favicon.png differ diff --git a/super_clones/medium/web/icons/Icon-192.png b/super_clones/medium/web/icons/Icon-192.png new file mode 100644 index 0000000000..b749bfef07 Binary files /dev/null and b/super_clones/medium/web/icons/Icon-192.png differ diff --git a/super_clones/medium/web/icons/Icon-512.png b/super_clones/medium/web/icons/Icon-512.png new file mode 100644 index 0000000000..88cfd48dff Binary files /dev/null and b/super_clones/medium/web/icons/Icon-512.png differ diff --git a/super_clones/medium/web/icons/Icon-maskable-192.png b/super_clones/medium/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000..eb9b4d76e5 Binary files /dev/null and b/super_clones/medium/web/icons/Icon-maskable-192.png differ diff --git a/super_clones/medium/web/icons/Icon-maskable-512.png b/super_clones/medium/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000..d69c56691f Binary files /dev/null and b/super_clones/medium/web/icons/Icon-maskable-512.png differ diff --git a/super_clones/medium/web/index.html b/super_clones/medium/web/index.html new file mode 100644 index 0000000000..3b6344fa82 --- /dev/null +++ b/super_clones/medium/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + medium + + + + + + diff --git a/super_clones/medium/web/manifest.json b/super_clones/medium/web/manifest.json new file mode 100644 index 0000000000..b21b243b42 --- /dev/null +++ b/super_clones/medium/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "medium", + "short_name": "medium", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A Flutter clone of Medium.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/super_clones/obsidian/.gitignore b/super_clones/obsidian/.gitignore new file mode 100644 index 0000000000..6c319542b3 --- /dev/null +++ b/super_clones/obsidian/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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/ + +# 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 diff --git a/super_clones/obsidian/.metadata b/super_clones/obsidian/.metadata new file mode 100644 index 0000000000..53830e365c --- /dev/null +++ b/super_clones/obsidian/.metadata @@ -0,0 +1,30 @@ +# 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. + +version: + revision: 796c8ef79279f9c774545b3771238c3098dbefab + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 796c8ef79279f9c774545b3771238c3098dbefab + base_revision: 796c8ef79279f9c774545b3771238c3098dbefab + - platform: macos + create_revision: 796c8ef79279f9c774545b3771238c3098dbefab + base_revision: 796c8ef79279f9c774545b3771238c3098dbefab + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_clones/obsidian/README.md b/super_clones/obsidian/README.md new file mode 100644 index 0000000000..cd89d3a043 --- /dev/null +++ b/super_clones/obsidian/README.md @@ -0,0 +1,16 @@ +# super_editor_obsidian + +A Flutter clone of Obsidian + +## 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://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/super_clones/obsidian/analysis_options.yaml b/super_clones/obsidian/analysis_options.yaml new file mode 100644 index 0000000000..61b6c4de17 --- /dev/null +++ b/super_clones/obsidian/analysis_options.yaml @@ -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 diff --git a/super_clones/obsidian/lib/main.dart b/super_clones/obsidian/lib/main.dart new file mode 100644 index 0000000000..733722d1bb --- /dev/null +++ b/super_clones/obsidian/lib/main.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:macos_window_utils/macos_window_utils.dart'; +import 'package:super_editor_obsidian/sidebar.dart'; +import 'package:super_editor_obsidian/tabbed_editor.dart'; +import 'package:tab_kit/tab_kit.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await _configureMacWindow(); + + runApp(const MyApp()); +} + +Future _configureMacWindow() async { + await WindowManipulator.initialize(); + + // Let us display our tabs at the top of the window. + WindowManipulator.makeTitlebarTransparent(); + WindowManipulator.hideTitle(); + WindowManipulator.enableFullSizeContentView(); + + // Make the toolbar taller, to match our tab height. + WindowManipulator.addToolbar(); + WindowManipulator.setToolbarStyle(toolbarStyle: NSWindowToolbarStyle.unifiedCompact); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + brightness: Brightness.dark, + seedColor: Colors.deepPurple, + ), + useMaterial3: true, + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + late final NotebookTabController _editorTabController; + + @override + void initState() { + super.initState(); + + _editorTabController = NotebookTabController(initialTabs: [ + // const TabDescriptor(id: "1", title: "This is a document"), + ]) + ..addTab(const TabDescriptor(id: "1", title: "This is a document")); + } + + @override + void dispose() { + _editorTabController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Sidebar(), + Expanded( + child: TabbedEditor( + tabController: _editorTabController, + ), + ), + ], + ), + ); + } +} diff --git a/super_clones/obsidian/lib/sidebar.dart b/super_clones/obsidian/lib/sidebar.dart new file mode 100644 index 0000000000..5dafd962a4 --- /dev/null +++ b/super_clones/obsidian/lib/sidebar.dart @@ -0,0 +1,101 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart'; +import 'package:super_editor_obsidian/vault_menu.dart'; +import 'package:super_editor_obsidian/window.dart'; + +class Sidebar extends StatefulWidget { + const Sidebar({super.key}); + + @override + State createState() => _SidebarState(); +} + +class _SidebarState extends State { + List _lineItems = []; + + @override + void initState() { + super.initState(); + _loadMenu(); + } + + Future _loadMenu() async { + final vaultDirectory = Directory("/Users/matt/Projects/blog_flutterbountyhunters_com/blog_content"); + + final lineItems = []; + await for (final entity in vaultDirectory.list(recursive: true)) { + final indentLevel = entity.path + .replaceFirst(vaultDirectory.path, "") + .split(Platform.pathSeparator) + .fold(0, (previousValue, element) => element.isNotEmpty ? previousValue + 1 : previousValue) - + 1; + print("Relative path: '${entity.path.replaceFirst(vaultDirectory.path, "").split(Platform.pathSeparator)}'"); + + if (entity is Directory) { + lineItems.add( + LineItem( + indentLevel: indentLevel, isContainer: true, isOpen: false, name: basenameWithoutExtension(entity.path)), + ); + } else if (entity is File) { + lineItems.add( + LineItem(indentLevel: indentLevel, isContainer: false, name: basenameWithoutExtension(entity.path)), + ); + } + } + + if (!mounted) { + return; + } + + setState(() { + _lineItems = lineItems; + }); + + // _lineItems = [ + // LineItem(indentLevel: 0, isContainer: true, name: "drafts"), + // LineItem(indentLevel: 0, isContainer: true, name: "fbh-policies"), + // LineItem(indentLevel: 0, isContainer: true, name: "future-plans", isOpen: true), + // LineItem(indentLevel: 1, isContainer: true, name: "hello", isOpen: true), + // LineItem(indentLevel: 2, isContainer: false, name: "one", isActive: true), + // LineItem(indentLevel: 1, isContainer: false, name: "Build a smoother, cleaner, easier Flutter world"), + // LineItem(indentLevel: 1, isContainer: false, name: "Flutter in Motion Conference"), + // LineItem(indentLevel: 1, isContainer: false, name: "Golden all the things"), + // LineItem(indentLevel: 0, isContainer: true, name: "published"), + // LineItem(indentLevel: 0, isContainer: false, name: "Top level note"), + // ]; + } + + @override + Widget build(BuildContext context) { + return _buildScaffold( + appBar: const SizedBox(), + content: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: VaultMenu(lineItems: _lineItems), + ), + ), + ); + } + + Widget _buildScaffold({ + required Widget appBar, + required Widget content, + }) { + return SizedBox( + width: 360, + child: ScreenPartial( + partialAppBar: appBar, + content: DecoratedBox( + decoration: BoxDecoration( + border: Border(right: BorderSide(width: 1, color: Colors.white.withOpacity(0.1))), + ), + position: DecorationPosition.foreground, + child: content, + ), + ), + ); + } +} diff --git a/super_clones/obsidian/lib/tabbed_editor.dart b/super_clones/obsidian/lib/tabbed_editor.dart new file mode 100644 index 0000000000..3323a58c0b --- /dev/null +++ b/super_clones/obsidian/lib/tabbed_editor.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor_obsidian/window.dart'; +import 'package:tab_kit/tab_kit.dart'; + +class TabbedEditor extends StatefulWidget { + const TabbedEditor({ + super.key, + required this.tabController, + }); + + final NotebookTabController tabController; + + @override + State createState() => _TabbedEditorState(); +} + +class _TabbedEditorState extends State { + @override + Widget build(BuildContext context) { + return ScreenPartial( + partialAppBar: NotebookTabBar( + controller: widget.tabController, + paddingStart: 12, + style: NotebookTabBarStyle( + barBackground: Colors.transparent, + tabBackground: const Color(0xFF222222), + tabWidth: 200, + dividerColor: Colors.white.withOpacity(0.1), + ), + onAddTabPressed: () {}, + ), + content: const SizedBox(), + ); + } +} diff --git a/super_clones/obsidian/lib/vault_menu.dart b/super_clones/obsidian/lib/vault_menu.dart new file mode 100644 index 0000000000..438d294443 --- /dev/null +++ b/super_clones/obsidian/lib/vault_menu.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; + +class VaultMenu extends StatefulWidget { + const VaultMenu({ + super.key, + required this.lineItems, + }); + + final List lineItems; + + @override + State createState() => _VaultMenuState(); +} + +class _VaultMenuState extends State { + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (int i = 0; i < widget.lineItems.length; i += 1) ...[ + VaultLineItem( + indentLevel: widget.lineItems[i].indentLevel, + isContainer: widget.lineItems[i].isContainer, + isOpen: widget.lineItems[i].isOpen, + label: widget.lineItems[i].name, + isActive: widget.lineItems[i].isActive, + ), + const SizedBox(height: 1), + ], + ], + ); + } +} + +class LineItem { + LineItem({ + required this.indentLevel, + required this.isContainer, + this.isOpen = false, + this.icon, + this.name = "", + this.value = "", + this.isActive = false, + }) : assert(isContainer == true || isOpen == false, "Only containers can be marked as 'open'."); + + final int indentLevel; + + bool isContainer; + bool isOpen; + + final IconData? icon; + final String name; + final String value; + + final bool isActive; +} + +class VaultLineItem extends StatefulWidget { + const VaultLineItem({ + super.key, + this.height = 28, + required this.indentLevel, + this.indentPixelsPerLevel = 16, + required this.isContainer, + this.isOpen = false, + required this.label, + this.isActive = false, + this.onPressed, + }); + + final double height; + + final int indentLevel; + final double indentPixelsPerLevel; + final bool isContainer; + final bool isOpen; + + final String label; + + final bool isActive; + + final VoidCallback? onPressed; + + @override + State createState() => _VaultLineItemState(); +} + +class _VaultLineItemState extends State { + bool _isHighlighted = false; + + @override + void initState() { + super.initState(); + + _isHighlighted = widget.isActive; + } + + @override + void didUpdateWidget(VaultLineItem oldWidget) { + super.didUpdateWidget(oldWidget); + + _isHighlighted = widget.isActive; + } + + void _onHoverEnter(_) { + setState(() { + _isHighlighted = true; + }); + } + + void _onHoverExit(_) { + setState(() { + _isHighlighted = widget.isActive; + }); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: _onHoverEnter, + onExit: _onHoverExit, + child: GestureDetector( + onTap: widget.onPressed, + child: Container( + height: widget.height, + decoration: BoxDecoration( + color: _isHighlighted ? Colors.white.withOpacity(0.07) : Colors.transparent, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(width: 4), + _buildIndentLines(), + _buildChevron(), + const SizedBox(width: 4), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + widget.label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 12, + ), + ), + ), + ), + const SizedBox(width: 4), + ], + ), + ), + ), + ); + } + + Widget _buildIndentLines() { + return SizedBox( + width: widget.indentLevel * widget.indentPixelsPerLevel, + child: CustomPaint( + painter: _IndentPainter( + indentSpace: widget.indentPixelsPerLevel, + indentLevel: widget.indentLevel, + ), + ), + ); + } + + Widget _buildChevron() { + if (!widget.isContainer) { + return SizedBox(width: widget.indentPixelsPerLevel); + } + + return SizedBox( + width: widget.indentPixelsPerLevel, + child: Icon( + widget.isOpen ? Icons.keyboard_arrow_down : Icons.chevron_right, + color: Colors.white.withOpacity(0.3), + size: 16, + ), + ); + } +} + +class _IndentPainter extends CustomPainter { + const _IndentPainter({ + required this.indentSpace, + required this.indentLevel, + }); + + final double indentSpace; + final int indentLevel; + + @override + void paint(Canvas canvas, Size size) { + if (indentLevel == 0) { + return; + } + + final linesPath = Path(); + + // Draw vertical lines at each indentation level. + double x = indentSpace / 2; + for (int i = 0; i < indentLevel; i += 1) { + linesPath.addRect(Rect.fromLTWH(x, 0, 1, size.height)); + + if (i < indentLevel - 1) { + x += indentSpace; + } + } + + canvas.drawPath(linesPath, Paint()..color = Colors.white.withOpacity(0.1)); + } + + @override + bool shouldRepaint(_IndentPainter oldDelegate) { + return oldDelegate.indentSpace != indentSpace || oldDelegate.indentLevel != indentLevel; + } +} diff --git a/super_clones/obsidian/lib/window.dart b/super_clones/obsidian/lib/window.dart new file mode 100644 index 0000000000..a33902e291 --- /dev/null +++ b/super_clones/obsidian/lib/window.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; + +/// A horizontal portion of the screen, which includes part of the window's app +/// bar, along with content, which appears directly below that app bar partial. +class ScreenPartial extends StatelessWidget { + const ScreenPartial({ + super.key, + this.appBarHeight = 36, + this.partialAppBar, + required this.content, + }); + + final double appBarHeight; + final Widget? partialAppBar; + final Widget content; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + height: appBarHeight, + color: const Color(0xFF2f2f2f), + child: partialAppBar, + ), + Expanded( + child: Container( + color: const Color(0xFF222222), + child: content, + ), + ), + ], + ); + } +} diff --git a/super_clones/obsidian/macos/.gitignore b/super_clones/obsidian/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/super_clones/obsidian/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/super_clones/obsidian/macos/Flutter/Flutter-Debug.xcconfig b/super_clones/obsidian/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..4b81f9b2d2 --- /dev/null +++ b/super_clones/obsidian/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_clones/obsidian/macos/Flutter/Flutter-Release.xcconfig b/super_clones/obsidian/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5caa9d1579 --- /dev/null +++ b/super_clones/obsidian/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_clones/obsidian/macos/Flutter/GeneratedPluginRegistrant.swift b/super_clones/obsidian/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..e6397b03ad --- /dev/null +++ b/super_clones/obsidian/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import macos_window_utils + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) +} diff --git a/super_clones/obsidian/macos/Podfile b/super_clones/obsidian/macos/Podfile new file mode 100644 index 0000000000..036dad0ad2 --- /dev/null +++ b/super_clones/obsidian/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14.6' + +# 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__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/super_clones/obsidian/macos/Podfile.lock b/super_clones/obsidian/macos/Podfile.lock new file mode 100644 index 0000000000..7d456e4f2e --- /dev/null +++ b/super_clones/obsidian/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - FlutterMacOS (1.0.0) + - macos_window_utils (1.0.0): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + macos_window_utils: + :path: Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos + +SPEC CHECKSUMS: + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + macos_window_utils: 933f91f64805e2eb91a5bd057cf97cd097276663 + +PODFILE CHECKSUM: 16208599a12443d53889ba2270a4985981cfb204 + +COCOAPODS: 1.15.2 diff --git a/super_clones/obsidian/macos/Runner.xcodeproj/project.pbxproj b/super_clones/obsidian/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..a1db20f4f1 --- /dev/null +++ b/super_clones/obsidian/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,791 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + 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 */ + 2407722A3F283770D35318EB /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCC79DFA68AA69AA191ED340 /* Pods_RunnerTests.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 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 */; }; + 48BB86EE063B081CAE948F2F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4BD374B39C5AC559C65C7A53 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 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 */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* super_editor_obsidian.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = super_editor_obsidian.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 4BD374B39C5AC559C65C7A53 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5052B43E8682293F0FCBC241 /* 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 = ""; }; + 77F41F1674240B6A7FBE00F7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 7F0FC4633BB5649BAE35E731 /* 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 = ""; }; + 90CA7E542650D57544F6DAF7 /* 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 = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A29D0F9EC25F008D7D0DFF82 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + F80A21BF13BC68B0A1F35F5C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + FCC79DFA68AA69AA191ED340 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2407722A3F283770D35318EB /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 48BB86EE063B081CAE948F2F /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 5B91A66C416561CC8FFBF1BD /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* super_editor_obsidian.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 5B91A66C416561CC8FFBF1BD /* Pods */ = { + isa = PBXGroup; + children = ( + 7F0FC4633BB5649BAE35E731 /* Pods-Runner.debug.xcconfig */, + 90CA7E542650D57544F6DAF7 /* Pods-Runner.release.xcconfig */, + 5052B43E8682293F0FCBC241 /* Pods-Runner.profile.xcconfig */, + A29D0F9EC25F008D7D0DFF82 /* Pods-RunnerTests.debug.xcconfig */, + 77F41F1674240B6A7FBE00F7 /* Pods-RunnerTests.release.xcconfig */, + F80A21BF13BC68B0A1F35F5C /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4BD374B39C5AC559C65C7A53 /* Pods_Runner.framework */, + FCC79DFA68AA69AA191ED340 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + EA3342ABE5EF42F757F276F2 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + FE7C7E9AA0E2D10C88FB7B83 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 75E1E5C044FEAAB4E4D58F14 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* super_editor_obsidian.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 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 */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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; + alwaysOutOfDate = 1; + 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"; + }; + 75E1E5C044FEAAB4E4D58F14 /* [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; + }; + EA3342ABE5EF42F757F276F2 /* [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-RunnerTests-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; + }; + FE7C7E9AA0E2D10C88FB7B83 /* [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 */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 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 = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A29D0F9EC25F008D7D0DFF82 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.obsidian.superEditorObsidian.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/super_editor_obsidian.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/super_editor_obsidian"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 77F41F1674240B6A7FBE00F7 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.obsidian.superEditorObsidian.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/super_editor_obsidian.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/super_editor_obsidian"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F80A21BF13BC68B0A1F35F5C /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.obsidian.superEditorObsidian.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/super_editor_obsidian.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/super_editor_obsidian"; + }; + name = Profile; + }; + 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.14.6; + 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.14.6; + 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.14.6; + 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 */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 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 */; +} diff --git a/super_clones/obsidian/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/obsidian/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/obsidian/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/obsidian/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_clones/obsidian/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..490ceba3f2 --- /dev/null +++ b/super_clones/obsidian/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/obsidian/macos/Runner.xcworkspace/contents.xcworkspacedata b/super_clones/obsidian/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_clones/obsidian/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_clones/obsidian/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/obsidian/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/obsidian/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/obsidian/macos/Runner/AppDelegate.swift b/super_clones/obsidian/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..b3c1761412 --- /dev/null +++ b/super_clones/obsidian/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..82b6f9d9a3 Binary files /dev/null and b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000..13b35eba55 Binary files /dev/null and b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000..0a3f5fa40f Binary files /dev/null and b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000..bdb57226d5 Binary files /dev/null and b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000000..f083318e09 Binary files /dev/null and b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000..326c0e72c9 Binary files /dev/null and b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000..2f1632cfdd Binary files /dev/null and b/super_clones/obsidian/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/super_clones/obsidian/macos/Runner/Base.lproj/MainMenu.xib b/super_clones/obsidian/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/super_clones/obsidian/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/obsidian/macos/Runner/Configs/AppInfo.xcconfig b/super_clones/obsidian/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..7e206587f2 --- /dev/null +++ b/super_clones/obsidian/macos/Runner/Configs/AppInfo.xcconfig @@ -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 = super_editor_obsidian + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.obsidian.superEditorObsidian + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2023 com.flutterbountyhunters.obsidian. All rights reserved. diff --git a/super_clones/obsidian/macos/Runner/Configs/Debug.xcconfig b/super_clones/obsidian/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/super_clones/obsidian/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_clones/obsidian/macos/Runner/Configs/Release.xcconfig b/super_clones/obsidian/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/super_clones/obsidian/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_clones/obsidian/macos/Runner/Configs/Warnings.xcconfig b/super_clones/obsidian/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/super_clones/obsidian/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/super_clones/obsidian/macos/Runner/DebugProfile.entitlements b/super_clones/obsidian/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..5a5f4fc1ad --- /dev/null +++ b/super_clones/obsidian/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-write + + + diff --git a/super_clones/obsidian/macos/Runner/Info.plist b/super_clones/obsidian/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/super_clones/obsidian/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/super_clones/obsidian/macos/Runner/MainFlutterWindow.swift b/super_clones/obsidian/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..de79d64f7f --- /dev/null +++ b/super_clones/obsidian/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,22 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + + // Remove the title bar and expand content to fill the entire window. + self.setContentSize(NSSize(width: 1000, height: 650)) +// self.styleMask.update(with: StyleMask.fullSizeContentView) +// self.titleVisibility = TitleVisibility.hidden +// self.titlebarAppearsTransparent = true +// self.backgroundColor = NSColor.white + } +} diff --git a/super_clones/obsidian/macos/Runner/Release.entitlements b/super_clones/obsidian/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/super_clones/obsidian/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/super_clones/obsidian/macos/RunnerTests/RunnerTests.swift b/super_clones/obsidian/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..5418c9f539 --- /dev/null +++ b/super_clones/obsidian/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import FlutterMacOS +import Cocoa +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_clones/obsidian/pubspec.lock b/super_clones/obsidian/pubspec.lock new file mode 100644 index 0000000000..45809079cf --- /dev/null +++ b/super_clones/obsidian/pubspec.lock @@ -0,0 +1,221 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.dev" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + macos_window_utils: + dependency: "direct main" + description: + name: macos_window_utils + sha256: b78a210aa70ca7ccad6e7b7b810fb4689c507f4a46e299214900b2a1eb70ea23 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + path: + dependency: "direct main" + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + tab_kit: + dependency: "direct main" + description: + name: tab_kit + sha256: d4863a3f0821ef3e4dcc213c718e76cf86ee0a24b6558aeeb13b357e3b288090 + url: "https://pub.dev" + source: hosted + version: "0.0.1-dev.2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" +sdks: + dart: ">=3.4.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/super_clones/obsidian/pubspec.yaml b/super_clones/obsidian/pubspec.yaml new file mode 100644 index 0000000000..1f14ea9b13 --- /dev/null +++ b/super_clones/obsidian/pubspec.yaml @@ -0,0 +1,58 @@ +name: super_editor_obsidian +description: A Flutter clone of Obsidian +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +version: 1.0.0+1 + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + + macos_window_utils: ^1.1.3 + path: ^1.8.3 + tab_kit: ^0.0.1-dev.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +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 diff --git a/super_clones/obsidian/test/widget_test.dart b/super_clones/obsidian/test/widget_test.dart new file mode 100644 index 0000000000..090aedb229 --- /dev/null +++ b/super_clones/obsidian/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. 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'; + +import 'package:super_editor_obsidian/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/super_clones/quill/.gitignore b/super_clones/quill/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/super_clones/quill/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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 +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/super_clones/quill/.metadata b/super_clones/quill/.metadata new file mode 100644 index 0000000000..e5423595ba --- /dev/null +++ b/super_clones/quill/.metadata @@ -0,0 +1,42 @@ +# 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: "af84f6b8471c761d61332dc499880cd4e486799d" + channel: "master" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: af84f6b8471c761d61332dc499880cd4e486799d + base_revision: af84f6b8471c761d61332dc499880cd4e486799d + - platform: android + create_revision: af84f6b8471c761d61332dc499880cd4e486799d + base_revision: af84f6b8471c761d61332dc499880cd4e486799d + - platform: ios + create_revision: af84f6b8471c761d61332dc499880cd4e486799d + base_revision: af84f6b8471c761d61332dc499880cd4e486799d + - platform: macos + create_revision: af84f6b8471c761d61332dc499880cd4e486799d + base_revision: af84f6b8471c761d61332dc499880cd4e486799d + - platform: web + create_revision: af84f6b8471c761d61332dc499880cd4e486799d + base_revision: af84f6b8471c761d61332dc499880cd4e486799d + - platform: windows + create_revision: af84f6b8471c761d61332dc499880cd4e486799d + base_revision: af84f6b8471c761d61332dc499880cd4e486799d + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_clones/quill/README.md b/super_clones/quill/README.md new file mode 100644 index 0000000000..f39ff4f5dd --- /dev/null +++ b/super_clones/quill/README.md @@ -0,0 +1,14 @@ +# Feather + +A Flutter clone of Quill + +## Features TODO +At the time of writing, this clone is a pretty close approximation to the standard Quill editor. +However, the following updates should be made to more closely align with Quill. + + * Convert block types by line, not node. + * Given a multi-line code block, when the user selects the code and presses the code button to + turn the code back into a paragraph, only the selected lines are switched from code to a paragraph. + * Multiline blockquotes shouldn't be allowed (as per observation of the standard Quill editor). + * Back-to-back code blocks should automatically be combined into one code block. + * All nodes should support horizontal alignment (not just text nodes). \ No newline at end of file diff --git a/super_clones/quill/analysis_options.yaml b/super_clones/quill/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_clones/quill/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_clones/quill/android/.gitignore b/super_clones/quill/android/.gitignore new file mode 100644 index 0000000000..6f568019d3 --- /dev/null +++ b/super_clones/quill/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/super_clones/quill/android/app/build.gradle b/super_clones/quill/android/app/build.gradle new file mode 100644 index 0000000000..dbc3b6fe7d --- /dev/null +++ b/super_clones/quill/android/app/build.gradle @@ -0,0 +1,40 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.flutterbountyhunters.feather.feather" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.flutterbountyhunters.feather.feather" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/super_clones/quill/android/app/src/debug/AndroidManifest.xml b/super_clones/quill/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_clones/quill/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_clones/quill/android/app/src/main/AndroidManifest.xml b/super_clones/quill/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..f3b8c4d5bf --- /dev/null +++ b/super_clones/quill/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/quill/android/app/src/main/kotlin/com/flutterbountyhunters/feather/feather/MainActivity.kt b/super_clones/quill/android/app/src/main/kotlin/com/flutterbountyhunters/feather/feather/MainActivity.kt new file mode 100644 index 0000000000..d3710d68da --- /dev/null +++ b/super_clones/quill/android/app/src/main/kotlin/com/flutterbountyhunters/feather/feather/MainActivity.kt @@ -0,0 +1,5 @@ +package com.flutterbountyhunters.feather.feather + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/super_clones/quill/android/app/src/main/res/drawable-v21/launch_background.xml b/super_clones/quill/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/super_clones/quill/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_clones/quill/android/app/src/main/res/drawable/launch_background.xml b/super_clones/quill/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..304732f884 --- /dev/null +++ b/super_clones/quill/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_clones/quill/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/super_clones/quill/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..db77bb4b7b Binary files /dev/null and b/super_clones/quill/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/super_clones/quill/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/super_clones/quill/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..17987b79bb Binary files /dev/null and b/super_clones/quill/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/super_clones/quill/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/super_clones/quill/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..09d4391482 Binary files /dev/null and b/super_clones/quill/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/super_clones/quill/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/super_clones/quill/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d5f1c8d34e Binary files /dev/null and b/super_clones/quill/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/super_clones/quill/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/super_clones/quill/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..4d6372eebd Binary files /dev/null and b/super_clones/quill/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/super_clones/quill/android/app/src/main/res/values-night/styles.xml b/super_clones/quill/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/super_clones/quill/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_clones/quill/android/app/src/main/res/values/styles.xml b/super_clones/quill/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..cb1ef88056 --- /dev/null +++ b/super_clones/quill/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_clones/quill/android/app/src/profile/AndroidManifest.xml b/super_clones/quill/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_clones/quill/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_clones/quill/android/build.gradle b/super_clones/quill/android/build.gradle new file mode 100644 index 0000000000..d2ffbffa4c --- /dev/null +++ b/super_clones/quill/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/super_clones/quill/android/gradle.properties b/super_clones/quill/android/gradle.properties new file mode 100644 index 0000000000..2597170821 --- /dev/null +++ b/super_clones/quill/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/super_clones/quill/android/gradle/wrapper/gradle-wrapper.properties b/super_clones/quill/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..e1ca574ef0 --- /dev/null +++ b/super_clones/quill/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip diff --git a/super_clones/quill/android/settings.gradle b/super_clones/quill/android/settings.gradle new file mode 100644 index 0000000000..536165d35a --- /dev/null +++ b/super_clones/quill/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.3.0" apply false + id "org.jetbrains.kotlin.android" version "1.7.10" apply false +} + +include ":app" diff --git a/super_clones/quill/assets/fonts/EB_Garamond/EBGaramond-Italic-VariableFont_wght.ttf b/super_clones/quill/assets/fonts/EB_Garamond/EBGaramond-Italic-VariableFont_wght.ttf new file mode 100644 index 0000000000..60d3c5a65d Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/EBGaramond-Italic-VariableFont_wght.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/EBGaramond-VariableFont_wght.ttf b/super_clones/quill/assets/fonts/EB_Garamond/EBGaramond-VariableFont_wght.ttf new file mode 100644 index 0000000000..fded83872b Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/EBGaramond-VariableFont_wght.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/OFL.txt b/super_clones/quill/assets/fonts/EB_Garamond/OFL.txt new file mode 100644 index 0000000000..132cf64742 --- /dev/null +++ b/super_clones/quill/assets/fonts/EB_Garamond/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/super_clones/quill/assets/fonts/EB_Garamond/README.txt b/super_clones/quill/assets/fonts/EB_Garamond/README.txt new file mode 100644 index 0000000000..47f3e3f7c9 --- /dev/null +++ b/super_clones/quill/assets/fonts/EB_Garamond/README.txt @@ -0,0 +1,73 @@ +EB Garamond Variable Font +========================= + +This download contains EB Garamond as both variable fonts and static fonts. + +EB Garamond is a variable font with this axis: + wght + +This means all the styles are contained in these files: + EB_Garamond/EBGaramond-VariableFont_wght.ttf + EB_Garamond/EBGaramond-Italic-VariableFont_wght.ttf + +If your app fully supports variable fonts, you can now pick intermediate styles +that aren’t available as static fonts. Not all apps support variable fonts, and +in those cases you can use the static font files for EB Garamond: + EB_Garamond/static/EBGaramond-Regular.ttf + EB_Garamond/static/EBGaramond-Medium.ttf + EB_Garamond/static/EBGaramond-SemiBold.ttf + EB_Garamond/static/EBGaramond-Bold.ttf + EB_Garamond/static/EBGaramond-ExtraBold.ttf + EB_Garamond/static/EBGaramond-Italic.ttf + EB_Garamond/static/EBGaramond-MediumItalic.ttf + EB_Garamond/static/EBGaramond-SemiBoldItalic.ttf + EB_Garamond/static/EBGaramond-BoldItalic.ttf + EB_Garamond/static/EBGaramond-ExtraBoldItalic.ttf + +Get started +----------- + +1. Install the font files you want to use + +2. Use your app's font picker to view the font family and all the +available styles + +Learn more about variable fonts +------------------------------- + + https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts + https://variablefonts.typenetwork.com + https://medium.com/variable-fonts + +In desktop apps + + https://theblog.adobe.com/can-variable-fonts-illustrator-cc + https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts + +Online + + https://developers.google.com/fonts/docs/getting_started + https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide + https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts + +Installing fonts + + MacOS: https://support.apple.com/en-us/HT201749 + Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux + Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows + +Android Apps + + https://developers.google.com/fonts/docs/android + https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts + +License +------- +Please read the full license text (OFL.txt) to understand the permissions, +restrictions and requirements for usage, redistribution, and modification. + +You can use them in your products & projects – print or digital, +commercial or otherwise. + +This isn't legal advice, please consider consulting a lawyer and see the full +license for all details. diff --git a/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Bold.ttf b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Bold.ttf new file mode 100644 index 0000000000..b73dee0248 Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Bold.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-BoldItalic.ttf b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-BoldItalic.ttf new file mode 100644 index 0000000000..852be7c642 Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-BoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-ExtraBold.ttf b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-ExtraBold.ttf new file mode 100644 index 0000000000..ec163f4c75 Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-ExtraBold.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-ExtraBoldItalic.ttf b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-ExtraBoldItalic.ttf new file mode 100644 index 0000000000..ae09f36e9e Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-ExtraBoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Italic.ttf b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Italic.ttf new file mode 100644 index 0000000000..0f76a8ecf2 Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Italic.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Medium.ttf b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Medium.ttf new file mode 100644 index 0000000000..fd40af320a Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Medium.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-MediumItalic.ttf b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-MediumItalic.ttf new file mode 100644 index 0000000000..8e580f5e2b Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-MediumItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Regular.ttf b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Regular.ttf new file mode 100644 index 0000000000..d3d6f3fbed Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-Regular.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-SemiBold.ttf b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-SemiBold.ttf new file mode 100644 index 0000000000..bd724ce7d3 Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-SemiBold.ttf differ diff --git a/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-SemiBoldItalic.ttf b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-SemiBoldItalic.ttf new file mode 100644 index 0000000000..c0efa3685d Binary files /dev/null and b/super_clones/quill/assets/fonts/EB_Garamond/static/EBGaramond-SemiBoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Fira_Mono/FiraMono-Bold.ttf b/super_clones/quill/assets/fonts/Fira_Mono/FiraMono-Bold.ttf new file mode 100644 index 0000000000..23bc30f6c4 Binary files /dev/null and b/super_clones/quill/assets/fonts/Fira_Mono/FiraMono-Bold.ttf differ diff --git a/super_clones/quill/assets/fonts/Fira_Mono/FiraMono-Medium.ttf b/super_clones/quill/assets/fonts/Fira_Mono/FiraMono-Medium.ttf new file mode 100644 index 0000000000..793c60df15 Binary files /dev/null and b/super_clones/quill/assets/fonts/Fira_Mono/FiraMono-Medium.ttf differ diff --git a/super_clones/quill/assets/fonts/Fira_Mono/FiraMono-Regular.ttf b/super_clones/quill/assets/fonts/Fira_Mono/FiraMono-Regular.ttf new file mode 100644 index 0000000000..67bbd4287d Binary files /dev/null and b/super_clones/quill/assets/fonts/Fira_Mono/FiraMono-Regular.ttf differ diff --git a/super_clones/quill/assets/fonts/Fira_Mono/OFL.txt b/super_clones/quill/assets/fonts/Fira_Mono/OFL.txt new file mode 100644 index 0000000000..80141881ec --- /dev/null +++ b/super_clones/quill/assets/fonts/Fira_Mono/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2012-2013, The Mozilla Corporation and Telefonica S.A. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/super_clones/quill/assets/fonts/Open_Sans/OFL.txt b/super_clones/quill/assets/fonts/Open_Sans/OFL.txt new file mode 100644 index 0000000000..4fc617027b --- /dev/null +++ b/super_clones/quill/assets/fonts/Open_Sans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/super_clones/quill/assets/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf b/super_clones/quill/assets/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000000..8312b2ce94 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf b/super_clones/quill/assets/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000000..ac587b482b Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/README.txt b/super_clones/quill/assets/fonts/Open_Sans/README.txt new file mode 100644 index 0000000000..9b94759b51 --- /dev/null +++ b/super_clones/quill/assets/fonts/Open_Sans/README.txt @@ -0,0 +1,100 @@ +Open Sans Variable Font +======================= + +This download contains Open Sans as both variable fonts and static fonts. + +Open Sans is a variable font with these axes: + wdth + wght + +This means all the styles are contained in these files: + Open_Sans/OpenSans-VariableFont_wdth,wght.ttf + Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf + +If your app fully supports variable fonts, you can now pick intermediate styles +that aren’t available as static fonts. Not all apps support variable fonts, and +in those cases you can use the static font files for Open Sans: + Open_Sans/static/OpenSans_Condensed-Light.ttf + Open_Sans/static/OpenSans_Condensed-Regular.ttf + Open_Sans/static/OpenSans_Condensed-Medium.ttf + Open_Sans/static/OpenSans_Condensed-SemiBold.ttf + Open_Sans/static/OpenSans_Condensed-Bold.ttf + Open_Sans/static/OpenSans_Condensed-ExtraBold.ttf + Open_Sans/static/OpenSans_SemiCondensed-Light.ttf + Open_Sans/static/OpenSans_SemiCondensed-Regular.ttf + Open_Sans/static/OpenSans_SemiCondensed-Medium.ttf + Open_Sans/static/OpenSans_SemiCondensed-SemiBold.ttf + Open_Sans/static/OpenSans_SemiCondensed-Bold.ttf + Open_Sans/static/OpenSans_SemiCondensed-ExtraBold.ttf + Open_Sans/static/OpenSans-Light.ttf + Open_Sans/static/OpenSans-Regular.ttf + Open_Sans/static/OpenSans-Medium.ttf + Open_Sans/static/OpenSans-SemiBold.ttf + Open_Sans/static/OpenSans-Bold.ttf + Open_Sans/static/OpenSans-ExtraBold.ttf + Open_Sans/static/OpenSans_Condensed-LightItalic.ttf + Open_Sans/static/OpenSans_Condensed-Italic.ttf + Open_Sans/static/OpenSans_Condensed-MediumItalic.ttf + Open_Sans/static/OpenSans_Condensed-SemiBoldItalic.ttf + Open_Sans/static/OpenSans_Condensed-BoldItalic.ttf + Open_Sans/static/OpenSans_Condensed-ExtraBoldItalic.ttf + Open_Sans/static/OpenSans_SemiCondensed-LightItalic.ttf + Open_Sans/static/OpenSans_SemiCondensed-Italic.ttf + Open_Sans/static/OpenSans_SemiCondensed-MediumItalic.ttf + Open_Sans/static/OpenSans_SemiCondensed-SemiBoldItalic.ttf + Open_Sans/static/OpenSans_SemiCondensed-BoldItalic.ttf + Open_Sans/static/OpenSans_SemiCondensed-ExtraBoldItalic.ttf + Open_Sans/static/OpenSans-LightItalic.ttf + Open_Sans/static/OpenSans-Italic.ttf + Open_Sans/static/OpenSans-MediumItalic.ttf + Open_Sans/static/OpenSans-SemiBoldItalic.ttf + Open_Sans/static/OpenSans-BoldItalic.ttf + Open_Sans/static/OpenSans-ExtraBoldItalic.ttf + +Get started +----------- + +1. Install the font files you want to use + +2. Use your app's font picker to view the font family and all the +available styles + +Learn more about variable fonts +------------------------------- + + https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts + https://variablefonts.typenetwork.com + https://medium.com/variable-fonts + +In desktop apps + + https://theblog.adobe.com/can-variable-fonts-illustrator-cc + https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts + +Online + + https://developers.google.com/fonts/docs/getting_started + https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide + https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts + +Installing fonts + + MacOS: https://support.apple.com/en-us/HT201749 + Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux + Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows + +Android Apps + + https://developers.google.com/fonts/docs/android + https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts + +License +------- +Please read the full license text (OFL.txt) to understand the permissions, +restrictions and requirements for usage, redistribution, and modification. + +You can use them in your products & projects – print or digital, +commercial or otherwise. + +This isn't legal advice, please consider consulting a lawyer and see the full +license for all details. diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Bold.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Bold.ttf new file mode 100644 index 0000000000..98c74e0a42 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Bold.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-BoldItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-BoldItalic.ttf new file mode 100644 index 0000000000..855892833a Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-BoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-ExtraBold.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-ExtraBold.ttf new file mode 100644 index 0000000000..4eb3393528 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-ExtraBold.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-ExtraBoldItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-ExtraBoldItalic.ttf new file mode 100644 index 0000000000..75789b42dc Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-ExtraBoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Italic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Italic.ttf new file mode 100644 index 0000000000..29ff69386f Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Italic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Light.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Light.ttf new file mode 100644 index 0000000000..ea175cc30f Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Light.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-LightItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-LightItalic.ttf new file mode 100644 index 0000000000..edbfe0b782 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-LightItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Medium.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Medium.ttf new file mode 100644 index 0000000000..ae716936e9 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Medium.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-MediumItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-MediumItalic.ttf new file mode 100644 index 0000000000..6d1e09b240 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-MediumItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Regular.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Regular.ttf new file mode 100644 index 0000000000..67803bb642 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-Regular.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-SemiBold.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-SemiBold.ttf new file mode 100644 index 0000000000..e5ab464431 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-SemiBold.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-SemiBoldItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-SemiBoldItalic.ttf new file mode 100644 index 0000000000..cd23e154cf Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans-SemiBoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Bold.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Bold.ttf new file mode 100644 index 0000000000..525397da6a Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Bold.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-BoldItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-BoldItalic.ttf new file mode 100644 index 0000000000..d6c9bc0ac4 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-BoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-ExtraBold.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-ExtraBold.ttf new file mode 100644 index 0000000000..3e600b9ae9 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-ExtraBold.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-ExtraBoldItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-ExtraBoldItalic.ttf new file mode 100644 index 0000000000..0393650880 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-ExtraBoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Italic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Italic.ttf new file mode 100644 index 0000000000..fdf0a52e58 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Italic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Light.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Light.ttf new file mode 100644 index 0000000000..459be7b425 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Light.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-LightItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-LightItalic.ttf new file mode 100644 index 0000000000..5f05d08e4d Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-LightItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Medium.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Medium.ttf new file mode 100644 index 0000000000..802200d27a Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Medium.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-MediumItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-MediumItalic.ttf new file mode 100644 index 0000000000..b43786bb52 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-MediumItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Regular.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Regular.ttf new file mode 100644 index 0000000000..a2a83ac6cf Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-Regular.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-SemiBold.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-SemiBold.ttf new file mode 100644 index 0000000000..75bcd43c4a Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-SemiBold.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-SemiBoldItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-SemiBoldItalic.ttf new file mode 100644 index 0000000000..9fcaa52ea9 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_Condensed-SemiBoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Bold.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Bold.ttf new file mode 100644 index 0000000000..dc927fc95d Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Bold.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-BoldItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-BoldItalic.ttf new file mode 100644 index 0000000000..7601048e7d Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-BoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-ExtraBold.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-ExtraBold.ttf new file mode 100644 index 0000000000..d6864b1df3 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-ExtraBold.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-ExtraBoldItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-ExtraBoldItalic.ttf new file mode 100644 index 0000000000..ec7ade587b Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-ExtraBoldItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Italic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Italic.ttf new file mode 100644 index 0000000000..7fc00c82d3 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Italic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Light.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Light.ttf new file mode 100644 index 0000000000..5936496a3c Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Light.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-LightItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-LightItalic.ttf new file mode 100644 index 0000000000..7ced21a767 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-LightItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Medium.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Medium.ttf new file mode 100644 index 0000000000..25b1aadf0e Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Medium.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-MediumItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-MediumItalic.ttf new file mode 100644 index 0000000000..fd87f78598 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-MediumItalic.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Regular.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Regular.ttf new file mode 100644 index 0000000000..5b09b35bc1 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-Regular.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-SemiBold.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-SemiBold.ttf new file mode 100644 index 0000000000..fff3a37206 Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-SemiBold.ttf differ diff --git a/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-SemiBoldItalic.ttf b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-SemiBoldItalic.ttf new file mode 100644 index 0000000000..3874205d6d Binary files /dev/null and b/super_clones/quill/assets/fonts/Open_Sans/static/OpenSans_SemiCondensed-SemiBoldItalic.ttf differ diff --git a/super_clones/quill/ios/.gitignore b/super_clones/quill/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/super_clones/quill/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/super_clones/quill/ios/Flutter/AppFrameworkInfo.plist b/super_clones/quill/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..7c56964006 --- /dev/null +++ b/super_clones/quill/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/super_clones/quill/ios/Flutter/Debug.xcconfig b/super_clones/quill/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..ec97fc6f30 --- /dev/null +++ b/super_clones/quill/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/super_clones/quill/ios/Flutter/Release.xcconfig b/super_clones/quill/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..c4855bfe20 --- /dev/null +++ b/super_clones/quill/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/super_clones/quill/ios/Podfile b/super_clones/quill/ios/Podfile new file mode 100644 index 0000000000..d97f17e223 --- /dev/null +++ b/super_clones/quill/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# 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', '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 Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/super_clones/quill/ios/Runner.xcodeproj/project.pbxproj b/super_clones/quill/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..e61dc2ef74 --- /dev/null +++ b/super_clones/quill/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,619 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 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 */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F9S7W35N3F; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.feather.feather; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.feather.feather.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.feather.feather.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.feather.feather.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F9S7W35N3F; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.feather.feather; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = F9S7W35N3F; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.feather.feather; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/super_clones/quill/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/super_clones/quill/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/super_clones/quill/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_clones/quill/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/quill/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/quill/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/quill/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_clones/quill/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_clones/quill/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_clones/quill/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_clones/quill/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..8e3ca5dfe1 --- /dev/null +++ b/super_clones/quill/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/quill/ios/Runner.xcworkspace/contents.xcworkspacedata b/super_clones/quill/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/super_clones/quill/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_clones/quill/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/quill/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/quill/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/quill/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_clones/quill/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_clones/quill/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_clones/quill/ios/Runner/AppDelegate.swift b/super_clones/quill/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..626664468b --- /dev/null +++ b/super_clones/quill/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000..dc9ada4725 Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..7353c41ecf Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..6ed2d933e1 Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..4cd7b0099c Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..fe730945a0 Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..321773cd85 Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..502f463a9b Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..e9f5fea27c Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..84ac32ae7d Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..8953cba090 Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..0467bf12aa Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/super_clones/quill/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/super_clones/quill/ios/Runner/Base.lproj/LaunchScreen.storyboard b/super_clones/quill/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/super_clones/quill/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/quill/ios/Runner/Base.lproj/Main.storyboard b/super_clones/quill/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/super_clones/quill/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/quill/ios/Runner/Info.plist b/super_clones/quill/ios/Runner/Info.plist new file mode 100644 index 0000000000..906ee91f31 --- /dev/null +++ b/super_clones/quill/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Feather + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + feather + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/super_clones/quill/ios/Runner/Runner-Bridging-Header.h b/super_clones/quill/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/super_clones/quill/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/super_clones/quill/ios/RunnerTests/RunnerTests.swift b/super_clones/quill/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..86a7c3b1b6 --- /dev/null +++ b/super_clones/quill/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_clones/quill/lib/app.dart b/super_clones/quill/lib/app.dart new file mode 100644 index 0000000000..8297f82808 --- /dev/null +++ b/super_clones/quill/lib/app.dart @@ -0,0 +1,156 @@ +import 'package:feather/deltas/deltas_display.dart'; +import 'package:feather/editor/editor.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class FeatherApp extends StatelessWidget { + const FeatherApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + title: 'Feather', + home: HomeScreen(), + debugShowCheckedModeBanner: false, + ); + } +} + +class HomeScreen extends StatefulWidget { + const HomeScreen({ + super.key, + }); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + late final Editor _editor; + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + + bool _showDeltas = true; + + @override + void initState() { + super.initState(); + + _document = createDocumentWithVaryingStyles(); + _composer = MutableDocumentComposer(); + _editor = Editor( + editables: { + Editor.documentKey: _document, + Editor.composerKey: _composer, + }, + requestHandlers: [ + (editor, request) => request is ConvertTextBlockToFormatRequest // + ? ConvertTextBlockToFormatCommand(request.blockFormat) + : null, + (editor, request) => request is ToggleInlineFormatRequest // + ? ToggleInlineFormatCommand(request.inlineFormat) + : null, + (editor, request) => request is ToggleTextBlockFormatRequest // + ? ToggleTextBlockFormatCommand(request.blockFormat) + : null, + (editor, request) => request is ClearSelectedStylesRequest // + ? const ClearSelectedStylesCommand() + : null, + ...defaultRequestHandlers, + ], + reactionPipeline: [ + ...defaultEditorReactions, + _AlwaysTrailingParagraphReaction(), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SizedBox.expand( + child: Row( + children: [ + Expanded( + child: FeatherEditor( + editor: _editor, + isShowingDeltas: _showDeltas, + onShowDeltasChange: (bool showDeltas) { + setState(() { + _showDeltas = showDeltas; + }); + }), + ), + if (_showDeltas) // + SizedBox( + width: 400, + child: DeltasDisplay(editor: _editor), + ), + ], + ), + ), + ); + } +} + +MutableDocument createDocumentWithVaryingStyles() { + return deserializeMarkdownToDocument('''# Header 1 +This is regular text right below header 1. +## Header 2 +This is regular text right below header 2. +### Header 3 +#### Header 4 +##### Header 5 +###### Header 6 +Some **bold** text. + +> This is a blockquote. +> It can span multiple lines. + +* This is a bulleted list item. + +1. This is a numerical list item. + +``` +This is a code block. +It can span multiple lines. +``` + +The end. +'''); +} + +/// [EditReaction] that inserts an empty paragraph at the end of the document if ever one +/// isn't present. +/// +/// This reaction ensures that there's always a place for the caret to move below the +/// current block. This is especially important for a code block, in which pressing +/// Enter inserts a newline inside the code block - it doesn't insert a new paragraph +/// below the code block. +class _AlwaysTrailingParagraphReaction extends EditReaction { + @override + void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { + final document = editorContext.find(Editor.documentKey); + final lastNode = document.lastOrNull; + + if (lastNode != null && + lastNode is ParagraphNode && + (lastNode.getMetadataValue("blockType") == paragraphAttribution || + lastNode.getMetadataValue("blockType") == null) && + lastNode.text.text.isEmpty) { + // Already have a trailing empty paragraph. Fizzle. + return; + } + + // We need to insert a trailing empty paragraph. + requestDispatcher.execute([ + InsertNodeAtIndexRequest( + nodeIndex: document.nodeCount, + newNode: ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText(""), + ), + ), + ]); + } +} diff --git a/super_clones/quill/lib/deltas/deltas_display.dart b/super_clones/quill/lib/deltas/deltas_display.dart new file mode 100644 index 0000000000..3bc32a09a9 --- /dev/null +++ b/super_clones/quill/lib/deltas/deltas_display.dart @@ -0,0 +1,164 @@ +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:super_editor/super_editor.dart'; + +class DeltasDisplay extends StatefulWidget { + const DeltasDisplay({ + super.key, + required this.editor, + }); + + final Editor editor; + + @override + State createState() => _DeltasDisplayState(); +} + +class _DeltasDisplayState extends State implements EditListener { + final _scrollController = ScrollController(); + bool _autoScrollToBottom = true; + + Delta? _delta; + + @override + void initState() { + super.initState(); + widget.editor.addListener(this); + + onEdit([]); + } + + @override + void didUpdateWidget(DeltasDisplay oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.editor != oldWidget.editor) { + oldWidget.editor.removeListener(this); + widget.editor.addListener(this); + } + } + + @override + void dispose() { + widget.editor.removeListener(this); + _scrollController.dispose(); + super.dispose(); + } + + @override + void onEdit(List changeList) { + setState(() { + _delta = widget.editor.document.toQuillDeltas(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_autoScrollToBottom) { + _scrollToBottom(); + } + }); + }); + } + + void _scrollToBottom() { + _scrollController.position.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + height: double.infinity, + color: const Color(0xFF333333), + child: Column( + children: [ + _buildToolbar(), + Divider(height: 1, color: Colors.white.withValues(alpha: 0.1)), + Expanded( + child: _buildDeltaList(), + ), + ], + ), + ); + } + + Widget _buildToolbar() { + return SizedBox( + height: 48, + child: Row( + children: [ + const Spacer(), + IconButton( + icon: const Icon(Symbols.vertical_align_bottom), + color: _autoScrollToBottom ? Colors.lightBlue : Colors.white, + onPressed: () { + setState(() { + _autoScrollToBottom = !_autoScrollToBottom; + _scrollToBottom(); + }); + }, + ), + ], + ), + ); + } + + Widget _buildDeltaList() { + return ListView.builder( + controller: _scrollController, + itemCount: _delta!.operations.length, + itemBuilder: (context, index) { + final op = _delta!.operations[index]; + final data = op.data; + + final buffer = StringBuffer(); + if (op.isInsert) { + buffer.write("Insert: "); + } else if (op.isRetain) { + buffer.write("Retain: "); + } else if (op.isDelete) { + buffer.write("Delete: "); + } + + if (data is String) { + buffer.write("'${data.replaceAll("\n", "⏎")}'"); + } else { + buffer.write(data); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: Colors.white.withValues(alpha: 0.1))), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + buffer.toString(), + style: const TextStyle( + color: Colors.white, + fontFamily: "Monospace", + ), + ), + if (op.attributes != null) + Text( + op.attributes?.entries.map((entry) => "${entry.key}: ${entry.value}").join(", ") ?? "", + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontFamily: "Monospace", + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/super_clones/quill/lib/editor/blockquote_component.dart b/super_clones/quill/lib/editor/blockquote_component.dart new file mode 100644 index 0000000000..8a28b4a415 --- /dev/null +++ b/super_clones/quill/lib/editor/blockquote_component.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A [ComponentBuilder] that builds a blockquote component for the Feather app. +/// +/// The Feather blockquote is styled differently from the standard Super Editor +/// blockquote, and therefore requires its own component and builder. +class FeatherBlockquoteComponentBuilder extends BlockquoteComponentBuilder { + const FeatherBlockquoteComponentBuilder(); + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + if (componentViewModel is! BlockquoteComponentViewModel) { + return null; + } + + return FeatherBlockquoteComponent( + textKey: componentContext.componentKey, + text: componentViewModel.text, + textAlign: componentViewModel.textAlignment, + styleBuilder: componentViewModel.textStyleBuilder, + backgroundColor: componentViewModel.backgroundColor, + borderRadius: componentViewModel.borderRadius, + textSelection: componentViewModel.selection, + selectionColor: componentViewModel.selectionColor, + highlightWhenEmpty: componentViewModel.highlightWhenEmpty, + composingRegion: componentViewModel.composingRegion, + showComposingRegionUnderline: componentViewModel.showComposingRegionUnderline, + ); + } +} + +/// A Super Editor component that displays a blockquote with a vertical line +/// on left edge of the block. +class FeatherBlockquoteComponent extends StatelessWidget { + const FeatherBlockquoteComponent({ + super.key, + required this.textKey, + required this.text, + this.textAlign = TextAlign.left, + required this.styleBuilder, + this.textSelection, + this.selectionColor = Colors.lightBlueAccent, + required this.backgroundColor, + required this.borderRadius, + this.highlightWhenEmpty = false, + this.composingRegion, + this.showComposingRegionUnderline = false, + this.showDebugPaint = false, + }); + + final GlobalKey textKey; + final AttributedText text; + final TextAlign textAlign; + final AttributionStyleBuilder styleBuilder; + final TextSelection? textSelection; + final Color selectionColor; + final Color backgroundColor; + final BorderRadius borderRadius; + final bool highlightWhenEmpty; + final TextRange? composingRegion; + final bool showComposingRegionUnderline; + final bool showDebugPaint; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: const BoxDecoration( + border: Border( + left: BorderSide(color: Color(0xFFCCCCCC), width: 4), + ), + ), + child: TextComponent( + key: textKey, + text: text, + textAlign: textAlign, + textStyleBuilder: styleBuilder, + textSelection: textSelection, + selectionColor: selectionColor, + highlightWhenEmpty: highlightWhenEmpty, + underlines: _createUnderlines(), + showDebugPaint: showDebugPaint, + ), + ), + ); + } + + List _createUnderlines() { + return [ + if (composingRegion != null && showComposingRegionUnderline) + Underlines( + style: const StraightUnderlineStyle(), + underlines: [composingRegion!], + ), + ]; + } +} diff --git a/super_clones/quill/lib/editor/code_component.dart b/super_clones/quill/lib/editor/code_component.dart new file mode 100644 index 0000000000..efcedbc601 --- /dev/null +++ b/super_clones/quill/lib/editor/code_component.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class FeatherCodeComponentBuilder implements ComponentBuilder { + const FeatherCodeComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + if (node is! ParagraphNode) { + return null; + } + if (node.getMetadataValue('blockType') != codeAttribution) { + return null; + } + + final textDirection = getParagraphDirection(node.text.text); + + TextAlign textAlign = (textDirection == TextDirection.ltr) ? TextAlign.left : TextAlign.right; + final textAlignName = node.getMetadataValue('textAlign'); + switch (textAlignName) { + case 'left': + textAlign = TextAlign.left; + break; + case 'center': + textAlign = TextAlign.center; + break; + case 'right': + textAlign = TextAlign.right; + break; + case 'justify': + textAlign = TextAlign.justify; + break; + } + + return CodeBlockComponentViewModel( + nodeId: node.id, + text: node.text, + textStyleBuilder: noStyleBuilder, + backgroundColor: const Color(0x00000000), + borderRadius: BorderRadius.zero, + textDirection: textDirection, + textAlignment: textAlign, + selectionColor: const Color(0x00000000), + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! CodeBlockComponentViewModel) { + return null; + } + + return CodeBlockComponent( + textKey: componentContext.componentKey, + text: componentViewModel.text, + textAlign: componentViewModel.textAlignment, + styleBuilder: componentViewModel.textStyleBuilder, + backgroundColor: componentViewModel.backgroundColor, + borderRadius: componentViewModel.borderRadius, + textSelection: componentViewModel.selection, + selectionColor: componentViewModel.selectionColor, + highlightWhenEmpty: componentViewModel.highlightWhenEmpty, + underlines: componentViewModel.createUnderlines(), + ); + } +} + +class CodeBlockComponentViewModel extends SingleColumnLayoutComponentViewModel with TextComponentViewModel { + CodeBlockComponentViewModel({ + required super.nodeId, + super.createdAt, + super.maxWidth, + super.padding = EdgeInsets.zero, + required this.text, + required this.textStyleBuilder, + this.inlineWidgetBuilders = const [], + this.textDirection = TextDirection.ltr, + this.textAlignment = TextAlign.left, + this.maxLines, + this.overflow = TextOverflow.clip, + required this.backgroundColor, + required this.borderRadius, + this.selection, + required this.selectionColor, + this.highlightWhenEmpty = false, + TextRange? composingRegion, + bool showComposingRegionUnderline = false, + UnderlineStyle spellingErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Color(0xFFFF0000)), + List spellingErrors = const [], + }) { + this.composingRegion = composingRegion; + this.showComposingRegionUnderline = showComposingRegionUnderline; + + this.spellingErrorUnderlineStyle = spellingErrorUnderlineStyle; + this.spellingErrors = spellingErrors; + } + + @override + AttributedText text; + @override + AttributionStyleBuilder textStyleBuilder; + @override + InlineWidgetBuilderChain inlineWidgetBuilders; + @override + TextDirection textDirection; + @override + TextAlign textAlignment; + @override + int? maxLines; + @override + TextOverflow overflow; + @override + TextSelection? selection; + @override + Color selectionColor; + @override + bool highlightWhenEmpty; + + Color backgroundColor; + BorderRadius borderRadius; + + @override + void applyStyles(Map styles) { + super.applyStyles(styles); + backgroundColor = styles[Styles.backgroundColor] ?? Colors.transparent; + borderRadius = styles[Styles.borderRadius] ?? BorderRadius.zero; + } + + @override + CodeBlockComponentViewModel copy() { + return CodeBlockComponentViewModel( + nodeId: nodeId, + maxWidth: maxWidth, + padding: padding, + text: text.copy(), + textStyleBuilder: textStyleBuilder, + inlineWidgetBuilders: inlineWidgetBuilders, + textDirection: textDirection, + textAlignment: textAlignment, + maxLines: maxLines, + overflow: overflow, + backgroundColor: backgroundColor, + borderRadius: borderRadius, + selection: selection, + selectionColor: selectionColor, + highlightWhenEmpty: highlightWhenEmpty, + composingRegion: composingRegion, + showComposingRegionUnderline: showComposingRegionUnderline, + spellingErrorUnderlineStyle: spellingErrorUnderlineStyle, + spellingErrors: List.from(spellingErrors), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is BlockquoteComponentViewModel && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + text == other.text && + textDirection == other.textDirection && + textAlignment == other.textAlignment && + maxLines == other.maxLines && + overflow == other.overflow && + backgroundColor == other.backgroundColor && + borderRadius == other.borderRadius && + selection == other.selection && + selectionColor == other.selectionColor && + highlightWhenEmpty == other.highlightWhenEmpty && + composingRegion == other.composingRegion && + showComposingRegionUnderline == other.showComposingRegionUnderline; + + @override + int get hashCode => + super.hashCode ^ + nodeId.hashCode ^ + text.hashCode ^ + textDirection.hashCode ^ + textAlignment.hashCode ^ + maxLines.hashCode ^ + overflow.hashCode ^ + backgroundColor.hashCode ^ + borderRadius.hashCode ^ + selection.hashCode ^ + selectionColor.hashCode ^ + highlightWhenEmpty.hashCode ^ + composingRegion.hashCode ^ + showComposingRegionUnderline.hashCode; +} + +class CodeBlockComponent extends StatelessWidget { + const CodeBlockComponent({ + super.key, + required this.textKey, + required this.text, + this.textAlign = TextAlign.left, + required this.styleBuilder, + this.textSelection, + this.selectionColor = Colors.lightBlueAccent, + required this.backgroundColor, + required this.borderRadius, + this.highlightWhenEmpty = false, + this.underlines = const [], + this.showDebugPaint = false, + }); + + final GlobalKey textKey; + final AttributedText text; + final TextAlign textAlign; + final AttributionStyleBuilder styleBuilder; + final TextSelection? textSelection; + final Color selectionColor; + final Color backgroundColor; + final BorderRadius borderRadius; + final bool highlightWhenEmpty; + + final List underlines; + + final bool showDebugPaint; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: const Color(0xFF222222), + ), + child: TextComponent( + key: textKey, + text: text, + textAlign: textAlign, + textStyleBuilder: styleBuilder, + textSelection: textSelection, + selectionColor: selectionColor, + highlightWhenEmpty: highlightWhenEmpty, + underlines: underlines, + showDebugPaint: showDebugPaint, + ), + ), + ); + } +} diff --git a/super_clones/quill/lib/editor/editor.dart b/super_clones/quill/lib/editor/editor.dart new file mode 100644 index 0000000000..94e43324bb --- /dev/null +++ b/super_clones/quill/lib/editor/editor.dart @@ -0,0 +1,750 @@ +import 'dart:collection'; +import 'dart:math'; + +import 'package:feather/editor/blockquote_component.dart'; +import 'package:feather/editor/code_component.dart'; +import 'package:feather/editor/toolbar.dart'; +import 'package:feather/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/super_editor.dart'; + +class FeatherEditor extends StatefulWidget { + const FeatherEditor({ + super.key, + required this.editor, + required this.isShowingDeltas, + required this.onShowDeltasChange, + }); + + final Editor editor; + final bool isShowingDeltas; + final void Function(bool showDeltas) onShowDeltasChange; + + @override + State createState() => _FeatherEditorState(); +} + +class _FeatherEditorState extends State { + final _editorFocusNode = FocusNode(); + + @override + void dispose() { + _editorFocusNode.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: _borderColor), + borderRadius: BorderRadius.circular(2), + ), + child: Column( + children: [ + FormattingToolbar( + editorFocusNode: _editorFocusNode, + editor: widget.editor, + isShowingDeltas: widget.isShowingDeltas, + onShowDeltasChange: widget.onShowDeltasChange, + ), + const Divider(thickness: 1, height: 1, color: _borderColor), + Expanded( + child: _buildEditor(), + ), + ], + ), + ); + } + + Widget _buildEditor() { + return ColoredBox( + // TODO: Without transparent color, the tap gesture isn't picked up and the + // user can't place the caret. This should probably be handled in SuperEditor + // somewhere. + color: Colors.transparent, + child: SuperEditor( + focusNode: _editorFocusNode, + editor: widget.editor, + stylesheet: featherStylesheet, + componentBuilders: const [ + FeatherBlockquoteComponentBuilder(), + FeatherCodeComponentBuilder(), + ...defaultComponentBuilders, + ], + selectionPolicies: const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: false, + clearSelectionWhenImeConnectionCloses: false, + ), + keyboardActions: [ + // When pressing Enter in a code block, insert a newline in the + // code block instead of inserting a new empty paragraph. + enterToInsertNewlineInCodeBlock, + ...defaultImeKeyboardActions, + ], + ), + ); + } +} + +const _borderColor = Color(0xFFDDDDDD); + +/// Clears styles applied to selected text. +/// +/// If the selection is collapsed (just the caret), then the block-level styles +/// are cleared, e.g., Header 1. +/// +/// If the selection is expanded, then text styles are removed only from the +/// selected text. The block styles are left as-is. +class ClearSelectedStylesRequest implements EditRequest { + const ClearSelectedStylesRequest(); +} + +class ClearSelectedStylesCommand extends EditCommand { + const ClearSelectedStylesCommand(); + + @override + void execute(EditContext context, CommandExecutor executor) { + final composer = context.find(Editor.composerKey); + final selection = composer.selection; + if (selection == null) { + return; + } + + final document = context.find(Editor.documentKey); + if (selection.isCollapsed) { + // Remove block style. + final selectedNode = document.getNodeById(selection.extent.nodeId); + if (selectedNode is! TextNode) { + // Can't remove text block styles from a non-text node. + return; + } + + executor.executeCommand( + ReplaceNodeCommand( + existingNodeId: selectedNode.id, + newNode: ParagraphNode( + id: selectedNode.id, + text: selectedNode.text, + metadata: {}, // <-- empty metadata clears all block styles. + ), + ), + ); + + return; + } + + // The selection is expanded. Remove text styles. + executor.executeCommand( + ClearTextAttributionsCommand(selection), + ); + } +} + +class ClearTextAttributionsRequest implements EditRequest { + const ClearTextAttributionsRequest(this.documentRange); + + final DocumentRange documentRange; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ClearTextAttributionsRequest && runtimeType == other.runtimeType && documentRange == other.documentRange; + + @override + int get hashCode => documentRange.hashCode; +} + +class ClearTextAttributionsCommand extends EditCommand { + const ClearTextAttributionsCommand(this.documentRange); + + final DocumentRange documentRange; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.find(Editor.documentKey); + final nodes = document.getNodesInside(documentRange.start, documentRange.end); + if (nodes.isEmpty) { + return; + } + + // Normalize the DocumentRange so we know which DocumentPosition + // belongs to the first node, and which belongs to the last node. + final normalizedRange = documentRange.normalize(document); + + // ignore: prefer_collection_literals + final nodesAndSelections = LinkedHashMap(); + + for (final textNode in nodes) { + if (textNode is! TextNode) { + continue; + } + + int startOffset = -1; + int endOffset = -1; + + if (textNode == nodes.first && textNode == nodes.last) { + // Handle selection within a single node + startOffset = (normalizedRange.start.nodePosition as TextPosition).offset; + + // -1 because TextPosition's offset indexes the character after the + // selection, not the final character in the selection. + endOffset = (normalizedRange.end.nodePosition as TextPosition).offset - 1; + } else if (textNode == nodes.first) { + // Handle partial node selection in first node. + startOffset = (normalizedRange.start.nodePosition as TextPosition).offset; + endOffset = max(textNode.text.length - 1, 0); + } else if (textNode == nodes.last) { + // Handle partial node selection in last node. + startOffset = 0; + + // -1 because TextPosition's offset indexes the character after the + // selection, not the final character in the selection. + endOffset = (normalizedRange.end.nodePosition as TextPosition).offset - 1; + } else { + // Handle full node selection. + startOffset = 0; + endOffset = max(textNode.text.length - 1, 0); + } + + final selectionRange = TextRange(start: startOffset, end: endOffset); + + nodesAndSelections.putIfAbsent(textNode, () => selectionRange); + } + + // Remove attributions. + for (final entry in nodesAndSelections.entries) { + final node = entry.key; + final range = entry.value.toSpanRange(); + + final spans = node.text.getAttributionSpansInRange( + attributionFilter: (a) => true, + range: range, + resizeSpansToFitInRange: true, + ); + for (final span in spans) { + document.replaceNode( + oldNode: node, + newNode: node.copyTextNodeWith( + text: AttributedText( + node.text.text, + node.text.spans.copy() + ..removeAttribution( + attributionToRemove: span.attribution, + start: span.start, + end: span.end, + ), + ), + ), + ); + + executor.logChanges([ + DocumentEdit( + AttributionChangeEvent( + nodeId: node.id, + change: AttributionChange.removed, + range: range, + attributions: {span.attribution}, + ), + ), + ]); + } + } + } +} + +class ToggleInlineFormatRequest implements EditRequest { + const ToggleInlineFormatRequest(this.inlineFormat); + + final Attribution inlineFormat; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ToggleInlineFormatRequest && runtimeType == other.runtimeType && inlineFormat == other.inlineFormat; + + @override + int get hashCode => inlineFormat.hashCode; +} + +class ToggleInlineFormatCommand extends EditCommand { + const ToggleInlineFormatCommand(this.inlineFormat); + + final Attribution inlineFormat; + + @override + void execute(EditContext context, CommandExecutor executor) { + final composer = context.find(Editor.composerKey); + final selection = composer.selection; + if (selection == null) { + // No selected content to toggle. + return; + } + if (selection.isCollapsed) { + // No selected content to toggle. + return; + } + + executor.executeCommand( + ToggleTextAttributionsCommand( + documentRange: selection, + attributions: {inlineFormat}, + ), + ); + } +} + +class ToggleTextBlockFormatRequest implements EditRequest { + const ToggleTextBlockFormatRequest(this.blockFormat); + + final FeatherTextBlock blockFormat; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ToggleTextBlockFormatRequest && runtimeType == other.runtimeType && blockFormat == other.blockFormat; + + @override + int get hashCode => blockFormat.hashCode; +} + +class ToggleTextBlockFormatCommand extends EditCommand { + ToggleTextBlockFormatCommand(this.blockFormat); + + final FeatherTextBlock blockFormat; + + @override + void execute(EditContext context, CommandExecutor executor) { + final composer = context.find(Editor.composerKey); + final selection = composer.selection; + if (selection == null) { + // Nothing is selected. + return; + } + if (selection.base.nodeId != selection.extent.nodeId) { + // Selection spans multiple nodes. As a policy we only apply block formats + // one at a time. + return; + } + + final document = context.find(Editor.documentKey); + final selectedNode = document.getNodeById(selection.extent.nodeId); + if (selectedNode is! TextNode) { + // Can't apply a block level text format to a non-text node. + return; + } + + final selectedTextBlockType = FeatherTextBlock.fromNode(selectedNode); + switch (selectedTextBlockType) { + case FeatherTextBlock.header1: + case FeatherTextBlock.header2: + case FeatherTextBlock.header3: + case FeatherTextBlock.header4: + case FeatherTextBlock.header5: + case FeatherTextBlock.header6: + case FeatherTextBlock.paragraph: + case FeatherTextBlock.blockquote: + case FeatherTextBlock.code: + // The selected node is a ParagraphNode. Toggle the desired block + // format on that paragraph. + _toggleFromParagraph(executor, selectedNode as ParagraphNode); + return; + case FeatherTextBlock.orderedListItem: + case FeatherTextBlock.unorderedListItem: + // The selected node is a ListItemNode. + _toggleFromListItem(executor, selectedNode as ListItemNode); + return; + case FeatherTextBlock.task: + // The selected node is a TaskNode. + _toggleFromTask(executor, selectedNode as TaskNode); + return; + } + } + + void _toggleFromParagraph(CommandExecutor executor, ParagraphNode selectedNode) { + final desiredSuperEditorBlockAttribution = blockFormat.asAttribution; + if (selectedNode.metadata["blockType"] == desiredSuperEditorBlockAttribution) { + // The paragraph is already of the desired type. Remove the format because + // this is a toggle command. + executor.executeCommand( + ChangeParagraphBlockTypeCommand(nodeId: selectedNode.id, blockType: null), + ); + + return; + } + + if (desiredSuperEditorBlockAttribution != null) { + // The desired block type is a paragraph block type. The selected node + // is already a paragraph. Update the block type, as desired. + executor.executeCommand( + ChangeParagraphBlockTypeCommand(nodeId: selectedNode.id, blockType: desiredSuperEditorBlockAttribution), + ); + return; + } + + // The selected node is a ParagraphNode, but the desired block type is + // a different node type, e.g., ListItemNode. Replace the ParagraphNode + // with the desired node type. + executor.executeCommand( + ReplaceNodeCommand( + existingNodeId: selectedNode.id, + newNode: blockFormat.createNode( + id: selectedNode.id, + text: selectedNode.text, + metadata: selectedNode.metadata, + ), + ), + ); + } + + void _toggleFromListItem(CommandExecutor executor, ListItemNode selectedNode) { + if (selectedNode.type == ListItemType.unordered && blockFormat == FeatherTextBlock.unorderedListItem) { + // This node is already of the specified type. Therefore, we need to + // toggle to a regular paragraph. + executor.executeCommand(ConvertListItemToParagraphCommand(nodeId: selectedNode.id)); + return; + } + + if (selectedNode.type == ListItemType.ordered && blockFormat == FeatherTextBlock.orderedListItem) { + // This node is already of the specified type. Therefore, we need to + // toggle to a regular paragraph. + executor.executeCommand(ConvertListItemToParagraphCommand(nodeId: selectedNode.id)); + return; + } + + if (blockFormat == FeatherTextBlock.orderedListItem || blockFormat == FeatherTextBlock.unorderedListItem) { + // This node is already a list item, but it's not the desired type of list item. + final newListItemType = blockFormat == FeatherTextBlock.orderedListItem // + ? ListItemType.ordered + : ListItemType.unordered; + executor.executeCommand(ChangeListItemTypeCommand(nodeId: selectedNode.id, newType: newListItemType)); + return; + } + + // This node is a ListItemNode, but the desired node type is different. Replace the existing + // node with the desired type of node. + executor.executeCommand( + ReplaceNodeCommand( + existingNodeId: selectedNode.id, + newNode: blockFormat.createNode( + id: selectedNode.id, + text: selectedNode.text, + ), + ), + ); + } + + void _toggleFromTask(CommandExecutor executor, TaskNode selectedNode) { + if (blockFormat == FeatherTextBlock.task) { + // The node is already a task node. Toggle it to a regular paragraph. + executor.executeCommand(ConvertTaskToParagraphCommand(nodeId: selectedNode.id)); + return; + } + + // This node is a TaskNode, but the desired format wants a different type of + // node. Replace this node with the desired node. + executor.executeCommand( + ReplaceNodeCommand( + existingNodeId: selectedNode.id, + newNode: blockFormat.createNode( + id: selectedNode.id, + text: selectedNode.text, + ), + ), + ); + } +} + +class ConvertTextBlockToFormatRequest implements EditRequest { + const ConvertTextBlockToFormatRequest(this.blockFormat); + + final FeatherTextBlock blockFormat; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ConvertTextBlockToFormatRequest && runtimeType == other.runtimeType && blockFormat == other.blockFormat; + + @override + int get hashCode => blockFormat.hashCode; +} + +class ConvertTextBlockToFormatCommand extends EditCommand { + ConvertTextBlockToFormatCommand(this.blockFormat); + + final FeatherTextBlock blockFormat; + + @override + void execute(EditContext context, CommandExecutor executor) { + final composer = context.find(Editor.composerKey); + final selection = composer.selection; + if (selection == null) { + // Nothing is selected. + return; + } + if (selection.base.nodeId != selection.extent.nodeId) { + // Selection spans multiple nodes. As a policy we only apply block formats + // one at a time. + return; + } + + final document = context.find(Editor.documentKey); + final selectedNode = document.getNodeById(selection.extent.nodeId); + if (selectedNode is! TextNode) { + // Can't apply a block level text format to a non-text node. + return; + } + + final selectedTextBlockType = FeatherTextBlock.fromNode(selectedNode); + switch (selectedTextBlockType) { + case FeatherTextBlock.header1: + case FeatherTextBlock.header2: + case FeatherTextBlock.header3: + case FeatherTextBlock.header4: + case FeatherTextBlock.header5: + case FeatherTextBlock.header6: + case FeatherTextBlock.paragraph: + case FeatherTextBlock.blockquote: + case FeatherTextBlock.code: + // The selected node is a ParagraphNode. + _applyToParagraph(executor, selectedNode as ParagraphNode); + return; + case FeatherTextBlock.orderedListItem: + case FeatherTextBlock.unorderedListItem: + // The selected node is a ListItemNode. + _applyToListItem(executor, selectedNode as ListItemNode); + return; + case FeatherTextBlock.task: + // The selected node is a TaskNode. + _applyToTask(executor, selectedNode as TaskNode); + return; + } + } + + void _applyToParagraph(CommandExecutor executor, ParagraphNode selectedNode) { + final desiredSuperEditorBlockAttribution = blockFormat.asAttribution; + if (selectedNode.metadata["blockType"] == desiredSuperEditorBlockAttribution) { + // The paragraph is already the desired type. + return; + } + + if (desiredSuperEditorBlockAttribution != null) { + // The desired block type is a paragraph block type. The selected node + // is already a paragraph. Update the block type, as desired. + executor.executeCommand( + ChangeParagraphBlockTypeCommand(nodeId: selectedNode.id, blockType: desiredSuperEditorBlockAttribution), + ); + return; + } + + // The selected node is a ParagraphNode, but the desired block type is + // a different node type, e.g., ListItemNode. Replace the ParagraphNode + // with the desired node type. + executor.executeCommand( + ReplaceNodeCommand( + existingNodeId: selectedNode.id, + newNode: blockFormat.createNode( + id: selectedNode.id, + text: selectedNode.text, + metadata: selectedNode.metadata, + ), + ), + ); + } + + void _applyToListItem(CommandExecutor executor, ListItemNode selectedNode) { + if (blockFormat == FeatherTextBlock.orderedListItem || blockFormat == FeatherTextBlock.unorderedListItem) { + // This node is already a list item, but it's not the desired type of list item. + final newListItemType = blockFormat == FeatherTextBlock.orderedListItem // + ? ListItemType.ordered + : ListItemType.unordered; + executor.executeCommand(ChangeListItemTypeCommand(nodeId: selectedNode.id, newType: newListItemType)); + return; + } + + // This node is a ListItemNode, but the desired node type is different. Replace the existing + // node with the desired type of node. + executor.executeCommand( + ReplaceNodeCommand( + existingNodeId: selectedNode.id, + newNode: blockFormat.createNode( + id: selectedNode.id, + text: selectedNode.text, + ), + ), + ); + } + + void _applyToTask(CommandExecutor executor, TaskNode selectedNode) { + // This node is a TaskNode, but the desired format wants a different type of + // node. Replace this node with the desired node. + executor.executeCommand( + ReplaceNodeCommand( + existingNodeId: selectedNode.id, + newNode: blockFormat.createNode( + id: selectedNode.id, + text: selectedNode.text, + ), + ), + ); + } +} + +ExecutionInstruction enterToInsertNewlineInCodeBlock({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.enter && keyEvent.logicalKey != LogicalKeyboardKey.numpadEnter) { + return ExecutionInstruction.continueExecution; + } + + final selection = editContext.composer.selection; + if (selection == null || (selection.base.nodeId != selection.extent.nodeId)) { + return ExecutionInstruction.continueExecution; + } + final selectedNode = editContext.document.getNodeById(selection.extent.nodeId)!; + if (selectedNode is! ParagraphNode || selectedNode.metadata["blockType"] != codeAttribution) { + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + InsertNewlineAtCaretRequest(), + ]); + + return ExecutionInstruction.haltExecution; +} + +enum FeatherTextBlock { + header1, + header2, + header3, + header4, + header5, + header6, + paragraph, + blockquote, + code, + orderedListItem, + unorderedListItem, + task; + + static FeatherTextBlock fromNode(TextNode node) { + if (node is ParagraphNode) { + switch (node.metadata["blockType"]) { + case header1Attribution: + return header1; + case header2Attribution: + return header2; + case header3Attribution: + return header3; + case header4Attribution: + return header4; + case header5Attribution: + return header5; + case header6Attribution: + return header6; + case blockquoteAttribution: + return blockquote; + case codeAttribution: + return code; + default: + return paragraph; + } + } + + if (node is ListItemNode) { + switch (node.type) { + case ListItemType.ordered: + return orderedListItem; + case ListItemType.unordered: + return unorderedListItem; + } + } + + if (node is TaskNode) { + return task; + } + + throw Exception("Unknown text block type: $node"); + } + + TextNode createNode({ + required String id, + required AttributedText text, + Map? metadata, + }) { + switch (this) { + case FeatherTextBlock.header1: + case FeatherTextBlock.header2: + case FeatherTextBlock.header3: + case FeatherTextBlock.header4: + case FeatherTextBlock.header5: + case FeatherTextBlock.header6: + case FeatherTextBlock.paragraph: + case FeatherTextBlock.blockquote: + case FeatherTextBlock.code: + return ParagraphNode( + id: id, + text: text, + metadata: Map.from(metadata ?? {})..["blockType"] = asAttribution, + ); + case FeatherTextBlock.orderedListItem: + return ListItemNode( + id: id, + itemType: ListItemType.ordered, + text: text, + metadata: Map.from(metadata ?? {})..["blockType"] = null, + ); + case FeatherTextBlock.unorderedListItem: + return ListItemNode( + id: id, + itemType: ListItemType.unordered, + text: text, + metadata: Map.from(metadata ?? {})..["blockType"] = null, + ); + case FeatherTextBlock.task: + return TaskNode( + id: id, + text: text, + isComplete: false, + metadata: Map.from(metadata ?? {})..["blockType"] = null, + ); + } + } + + Attribution? get asAttribution { + switch (this) { + case FeatherTextBlock.header1: + return header1Attribution; + case FeatherTextBlock.header2: + return header2Attribution; + case FeatherTextBlock.header3: + return header3Attribution; + case FeatherTextBlock.header4: + return header4Attribution; + case FeatherTextBlock.header5: + return header5Attribution; + case FeatherTextBlock.header6: + return header6Attribution; + case FeatherTextBlock.paragraph: + return paragraphAttribution; + case FeatherTextBlock.blockquote: + return blockquoteAttribution; + case FeatherTextBlock.code: + return codeAttribution; + case FeatherTextBlock.orderedListItem: + case FeatherTextBlock.unorderedListItem: + case FeatherTextBlock.task: + // These block formats map to DocumentNode types, not paragraph + // block-level attributions. + return null; + } + } +} diff --git a/super_clones/quill/lib/editor/toolbar.dart b/super_clones/quill/lib/editor/toolbar.dart new file mode 100644 index 0000000000..1b09f455e8 --- /dev/null +++ b/super_clones/quill/lib/editor/toolbar.dart @@ -0,0 +1,1259 @@ +import 'dart:math'; + +import 'package:feather/infrastructure/popovers/color_selector.dart'; +import 'package:feather/editor/editor.dart'; +import 'package:feather/infrastructure/popovers/icon_selector.dart'; +import 'package:feather/infrastructure/popovers/text_item_selector.dart'; +import 'package:feather/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +class FormattingToolbar extends StatefulWidget { + const FormattingToolbar({ + super.key, + required this.editorFocusNode, + required this.editor, + required this.isShowingDeltas, + required this.onShowDeltasChange, + }); + + final FocusNode editorFocusNode; + final Editor editor; + + final bool isShowingDeltas; + final void Function(bool showDeltas) onShowDeltasChange; + + @override + State createState() => _FormattingToolbarState(); +} + +class _FormattingToolbarState extends State { + static const _tapRegionGroupId = 'feather_toolbar'; + + late DocumentComposer _composer; + late Document _document; + late final EditListener _editListener; + + final _fullySelectedTextFormats = {}; + + final FocusNode _urlFocusNode = FocusNode(); + final PopoverController _linkPopoverController = PopoverController(); + ImeAttributedTextEditingController? _urlController; + + final FocusNode _imageFocusNode = FocusNode(); + final PopoverController _imagePopoverController = PopoverController(); + ImeAttributedTextEditingController? _imageController; + + @override + void initState() { + super.initState(); + + _editListener = FunctionalEditListener(_onEdit); + widget.editor.addListener(_editListener); + + _composer = widget.editor.composer; + _composer.selectionNotifier.addListener(_onSelectionChange); + _document = widget.editor.document; + + _urlController = ImeAttributedTextEditingController() // + ..onPerformActionPressed = _onUrlFieldPerformAction + ..text = AttributedText("https://"); + + _imageController = ImeAttributedTextEditingController() // + ..onPerformActionPressed = _onImageFieldPerformAction + ..text = AttributedText("https://"); + } + + @override + void didUpdateWidget(FormattingToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.editor != oldWidget.editor) { + oldWidget.editor.removeListener(_editListener); + widget.editor.addListener(_editListener); + } + + final newComposer = widget.editor.composer; + if (newComposer != _composer) { + _composer.selectionNotifier.removeListener(_onSelectionChange); + _composer = newComposer; + _composer.selectionNotifier.addListener(_onSelectionChange); + } + + _document = widget.editor.document; + } + + @override + void dispose() { + _urlFocusNode.dispose(); + _linkPopoverController.dispose(); + + _composer.selectionNotifier.removeListener(_onSelectionChange); + widget.editor.removeListener(_editListener); + + super.dispose(); + } + + void _onEdit(List changes) { + if (changes.whereType().isEmpty) { + return; + } + + // It's possible that even without a selection change, the document + // styles changed out from under our selection. Re-compute the fully + // selected text formats. + _updateFormatButtonStates(); + } + + void _onSelectionChange() { + _updateFormatButtonStates(); + } + + /// Inspects the selected text and updates all toolbar format buttons based on + /// any formatting throughout the currently selected text. + void _updateFormatButtonStates() { + final selection = _composer.selection; + final fullySelectedTextFormats = _findFullySelectedTextFormats(selection); + + setState(() { + _fullySelectedTextFormats + ..clear() + ..addAll(fullySelectedTextFormats); + }); + } + + Set _findFullySelectedTextFormats(DocumentSelection? selection) { + if (selection == null) { + return {}; + } + if (selection.isCollapsed) { + return {}; + } + + return _document.getAllAttributions(selection); + } + + void _showUrlPopover() { + _linkPopoverController.open(); + _urlFocusNode.requestFocus(); + } + + void _onUrlFieldPerformAction(TextInputAction action) { + if (action == TextInputAction.done) { + _applyLink(); + } + } + + /// Applies the link entered on the URL textfield to the current + /// selected range. + void _applyLink() { + final url = _urlController!.text.text; + + final selection = widget.editor.composer.selection!; + final baseOffset = (selection.base.nodePosition as TextPosition).offset; + final extentOffset = (selection.extent.nodePosition as TextPosition).offset; + final selectionStart = min(baseOffset, extentOffset); + final selectionEnd = max(baseOffset, extentOffset); + final selectionRange = TextRange(start: selectionStart, end: selectionEnd - 1); + + final textNode = widget.editor.document.getNodeById(selection.extent.nodeId) as TextNode; + final text = textNode.text; + + final trimmedRange = _trimTextRangeWhitespace(text, selectionRange); + + final linkAttribution = LinkAttribution.fromUri(Uri.parse(url)); + + widget.editor.execute([ + AddTextAttributionsRequest( + documentRange: DocumentRange( + start: DocumentPosition( + nodeId: textNode.id, + nodePosition: TextNodePosition(offset: trimmedRange.start), + ), + end: DocumentPosition( + nodeId: textNode.id, + nodePosition: TextNodePosition(offset: trimmedRange.end), + ), + ), + attributions: {linkAttribution}, + ), + ]); + + // Clear the field and hide the URL bar + _urlController!.clearTextAndSelection(); + _urlFocusNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + _linkPopoverController.close(); + setState(() {}); + } + + void _showImagePopover() { + _imagePopoverController.open(); + _imageFocusNode.requestFocus(); + } + + void _onImageFieldPerformAction(TextInputAction action) { + if (action == TextInputAction.done) { + _applyImageUrl(); + } + } + + void _applyImageUrl() { + final url = _imageController!.text.text; + + final selection = widget.editor.composer.selection; + if (selection != null) { + widget.editor.execute([ + if (!selection.isCollapsed) // + DeleteContentRequest(documentRange: selection), + InsertNodeAtCaretRequest( + node: ImageNode( + id: Editor.createNodeId(), + imageUrl: url, + ), + ), + ]); + } + + // Clear the field and hide the URL bar + _imageController!.clearTextAndSelection(); + _imageFocusNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + _imagePopoverController.close(); + setState(() {}); + } + + /// Given [text] and a [range] within the [text], the [range] is + /// shortened on both sides to remove any trailing whitespace and + /// the new range is returned. + SpanRange _trimTextRangeWhitespace(AttributedText text, TextRange range) { + int startOffset = range.start; + int endOffset = range.end; + + while (startOffset < range.end && text.text[startOffset] == ' ') { + startOffset += 1; + } + while (endOffset > startOffset && text.text[endOffset] == ' ') { + endOffset -= 1; + } + + // Add 1 to the end offset because SpanRange treats the end offset to be exclusive. + return SpanRange(startOffset, endOffset + 1); + } + + void _indent() { + final selection = _composer.selection; + if (selection == null) { + return; + } + + final extentNode = _document.getNodeById(selection.extent.nodeId); + if (extentNode is! TextNode) { + return; + } + + if (extentNode is ParagraphNode) { + widget.editor.execute([ + IndentParagraphRequest(extentNode.id), + ]); + } else if (extentNode is ListItemNode) { + widget.editor.execute([ + IndentListItemRequest(nodeId: extentNode.id), + ]); + } else if (extentNode is TaskNode) { + widget.editor.execute([ + IndentTaskRequest(extentNode.id), + ]); + } + } + + void _unindent() { + final selection = _composer.selection; + if (selection == null) { + return; + } + + final extentNode = _document.getNodeById(selection.extent.nodeId); + if (extentNode is! TextNode) { + return; + } + + if (extentNode is ParagraphNode) { + widget.editor.execute([ + UnIndentParagraphRequest(extentNode.id), + ]); + } else if (extentNode is ListItemNode) { + widget.editor.execute([ + UnIndentListItemRequest(nodeId: extentNode.id), + ]); + } else if (extentNode is TaskNode) { + widget.editor.execute([ + UnIndentTaskRequest(extentNode.id), + ]); + } + } + + @override + Widget build(BuildContext context) { + final selection = _composer.selection; + DocumentNode? extentNode; + FeatherTextBlock? selectedBlockFormat; + if (selection != null) { + extentNode = _document.getNodeById(selection.extent.nodeId); + if (extentNode is TextNode) { + selectedBlockFormat = + selection.base.nodeId == selection.extent.nodeId ? FeatherTextBlock.fromNode(extentNode) : null; + } + } + + return IconTheme( + data: const IconThemeData( + size: 20, + ), + child: Wrap( + children: [ + _ToggleInlineFormatButton( + editor: widget.editor, + icon: Icons.format_bold, + format: boldAttribution, + selectedFormats: _fullySelectedTextFormats, + ), + _ToggleInlineFormatButton( + editor: widget.editor, + icon: Icons.format_italic, + format: italicsAttribution, + selectedFormats: _fullySelectedTextFormats, + ), + _ToggleInlineFormatButton( + editor: widget.editor, + icon: Icons.format_underline, + format: underlineAttribution, + selectedFormats: _fullySelectedTextFormats, + ), + _ToggleInlineFormatButton( + editor: widget.editor, + icon: Icons.strikethrough_s, + format: strikethroughAttribution, + selectedFormats: _fullySelectedTextFormats, + ), + _buildSpacer(), + _ToggleBlockFormatButton( + editor: widget.editor, + icon: Icons.format_quote, + format: FeatherTextBlock.blockquote, + selectedBlockFormat: selectedBlockFormat, + ), + _ToggleBlockFormatButton( + editor: widget.editor, + icon: Icons.code, + format: FeatherTextBlock.code, + selectedBlockFormat: selectedBlockFormat, + ), + _buildSpacer(), + _buildLinkButton(), + _buildImageButton(), + const IconButton( + icon: Icon(Icons.video_file), + onPressed: null, + ), + const IconButton( + icon: Icon(Symbols.function), + onPressed: null, + ), + _buildSpacer(), + _ToggleBlockFormatButton( + editor: widget.editor, + icon: Symbols.format_h1, + format: FeatherTextBlock.header1, + selectedBlockFormat: selectedBlockFormat, + ), + _ToggleBlockFormatButton( + editor: widget.editor, + icon: Symbols.format_h2, + iconSize: 14, + format: FeatherTextBlock.header2, + selectedBlockFormat: selectedBlockFormat, + ), + _buildSpacer(), + _ToggleBlockFormatButton( + editor: widget.editor, + icon: Icons.format_list_numbered, + format: FeatherTextBlock.orderedListItem, + selectedBlockFormat: selectedBlockFormat, + ), + _ToggleBlockFormatButton( + editor: widget.editor, + icon: Icons.format_list_bulleted, + format: FeatherTextBlock.unorderedListItem, + selectedBlockFormat: selectedBlockFormat, + ), + _ToggleBlockFormatButton( + editor: widget.editor, + icon: Icons.checklist, + format: FeatherTextBlock.task, + selectedBlockFormat: selectedBlockFormat, + ), + _buildSpacer(), + _ToggleInlineFormatButton( + editor: widget.editor, + icon: Icons.subscript, + format: subscriptAttribution, + selectedFormats: _fullySelectedTextFormats, + ), + _ToggleInlineFormatButton( + editor: widget.editor, + icon: Icons.superscript, + format: superscriptAttribution, + selectedFormats: _fullySelectedTextFormats, + ), + _buildSpacer(), + IconButton( + onPressed: _unindent, + icon: const Icon(Icons.format_indent_decrease), + ), + IconButton( + onPressed: _indent, + icon: const Icon(Icons.format_indent_increase), + ), + _buildSpacer(), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.format_textdirection_l_to_r), + ), + _buildSpacer(), + _NamedTextSizeSelector( + editorFocusNode: widget.editorFocusNode, + editor: widget.editor, + ), + _buildSpacer(), + _HeaderSelector( + editorFocusNode: widget.editorFocusNode, + editor: widget.editor, + ), + _buildSpacer(), + _TextColorButton( + editorFocusNode: widget.editorFocusNode, + editor: widget.editor, + ), + _HighlightColorButton( + editorFocusNode: widget.editorFocusNode, + editor: widget.editor, + ), + _buildSpacer(), + _FontFamilySelector( + editorFocusNode: widget.editorFocusNode, + editor: widget.editor, + ), + _buildSpacer(), + _AlignmentButton( + editorFocusNode: widget.editorFocusNode, + editor: widget.editor, + ), + _buildSpacer(), + IconButton( + onPressed: () { + widget.editor.execute([ + const ClearSelectedStylesRequest(), + ]); + }, + icon: const Icon(Icons.format_clear), + ), + _buildSpacer(), + IconButton( + icon: Icon(widget.isShowingDeltas ? Symbols.close : Symbols.menu_open), + onPressed: () { + widget.onShowDeltasChange(!widget.isShowingDeltas); + }, + ), + ], + ), + ); + } + + Widget _buildSpacer() => const SizedBox(width: 24); + + /// Builds the link button, which upon tap shows a popover for the user + /// to enter a URL. + Widget _buildLinkButton() { + return PopoverScaffold( + parentFocusNode: widget.editorFocusNode, + tapRegionGroupId: _tapRegionGroupId, + onTapOutside: (controller) => _linkPopoverController.close(), + controller: _linkPopoverController, + buttonBuilder: (context) => IconButton( + onPressed: _showUrlPopover, + icon: const Icon(Icons.link), + ), + popoverBuilder: (context) => _buildLinkPopover(), + ); + } + + Widget _buildLinkPopover() { + return Material( + shape: const StadiumBorder(), + elevation: 5, + clipBehavior: Clip.hardEdge, + child: Container( + width: 400, + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: SuperTextField( + focusNode: _urlFocusNode, + textController: _urlController, + minLines: 1, + maxLines: 1, + inputSource: TextInputSource.ime, + hintBehavior: HintBehavior.displayHintUntilTextEntered, + hintBuilder: (context) { + return const Text( + "enter a url...", + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ); + }, + textStyleBuilder: (_) { + return const TextStyle( + color: Colors.black, + fontSize: 16, + ); + }, + ), + ), + IconButton( + icon: const Icon(Icons.close), + iconSize: 20, + splashRadius: 16, + padding: EdgeInsets.zero, + onPressed: () { + setState(() { + _urlFocusNode.unfocus(); + _urlController!.clearTextAndSelection(); + }); + }, + ), + ], + ), + ), + ); + } + + /// Builds the image button, which upon tap shows a popover for the user + /// to enter a URL for an image. + Widget _buildImageButton() { + return PopoverScaffold( + parentFocusNode: widget.editorFocusNode, + tapRegionGroupId: _tapRegionGroupId, + onTapOutside: (controller) => _imagePopoverController.close(), + controller: _imagePopoverController, + buttonBuilder: (context) => IconButton( + onPressed: _showImagePopover, + icon: const Icon(Icons.image), + ), + popoverBuilder: (context) => _buildImagePopover(), + ); + } + + Widget _buildImagePopover() { + return Material( + shape: const StadiumBorder(), + elevation: 5, + clipBehavior: Clip.hardEdge, + child: Container( + width: 400, + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: SuperTextField( + focusNode: _imageFocusNode, + textController: _imageController, + minLines: 1, + maxLines: 1, + inputSource: TextInputSource.ime, + hintBehavior: HintBehavior.displayHintUntilTextEntered, + hintBuilder: (context) { + return const Text( + "enter a url...", + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ); + }, + textStyleBuilder: (_) { + return const TextStyle( + color: Colors.black, + fontSize: 16, + ); + }, + ), + ), + IconButton( + icon: const Icon(Icons.close), + iconSize: 20, + splashRadius: 16, + padding: EdgeInsets.zero, + onPressed: () { + setState(() { + _imageFocusNode.unfocus(); + _imageController!.clearTextAndSelection(); + }); + }, + ), + ], + ), + ), + ); + } +} + +class _ToggleInlineFormatButton extends StatelessWidget { + const _ToggleInlineFormatButton({ + required this.editor, + required this.icon, + required this.format, + required this.selectedFormats, + }); + + final Editor editor; + final IconData icon; + final Attribution format; + final Set selectedFormats; + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(icon), + color: selectedFormats.contains(format) ? Colors.blue : Colors.black, + onPressed: () { + editor.execute([ + ToggleInlineFormatRequest(format), + ]); + }, + ); + } +} + +class _ToggleBlockFormatButton extends StatelessWidget { + const _ToggleBlockFormatButton({ + required this.editor, + required this.icon, + this.iconSize, + required this.format, + required this.selectedBlockFormat, + }); + + final Editor editor; + final IconData icon; + final double? iconSize; + final FeatherTextBlock format; + final FeatherTextBlock? selectedBlockFormat; + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(icon), + iconSize: iconSize, + color: format == selectedBlockFormat ? Colors.blue : Colors.black, + onPressed: () { + editor.execute([ + ToggleTextBlockFormatRequest(format), + ]); + }, + ); + } +} + +class _NamedTextSizeSelector extends StatefulWidget { + const _NamedTextSizeSelector({ + required this.editorFocusNode, + required this.editor, + }); + + final FocusNode editorFocusNode; + final Editor editor; + + @override + State createState() => _NamedTextSizeSelectorState(); +} + +class _NamedTextSizeSelectorState extends State<_NamedTextSizeSelector> { + static const _defaultSizeName = "Normal"; + static const _sizeNames = ["Huge", "Large", _defaultSizeName, "Small"]; + + void _onChangeSizeRequested(String? newSizeName) { + if (newSizeName == null) { + return; + } + + final selection = widget.editor.composer.selection; + if (selection == null) { + return; + } + if (selection.base.nodeId != selection.extent.nodeId) { + return; + } + + final selectedNode = widget.editor.document.getNodeById(selection.extent.nodeId); + if (selectedNode is! TextNode) { + return; + } + + widget.editor.execute([ + AddTextAttributionsRequest( + documentRange: selection, + attributions: { + NamedFontSizeAttribution(newSizeName), + }, + ), + ]); + + // Rebuild to update the selected font on the toolbar. + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final selectedFontSizeName = _getAllAttributions(widget.editor.document, widget.editor.composer) + .whereType() + .firstOrNull + ?.fontSizeName ?? + _defaultSizeName; + final textItem = TextItem(id: selectedFontSizeName, label: selectedFontSizeName); + + return TextItemSelector( + parentFocusNode: widget.editorFocusNode, + tapRegionGroupId: "selector_font-size-name", + selectedText: textItem, + items: _sizeNames.map((name) => TextItem(id: name, label: name)).toList(), + onSelected: (value) => _onChangeSizeRequested(value?.id), + buttonSize: const Size(97, 30), + popoverGeometry: const PopoverGeometry( + constraints: BoxConstraints.tightFor(width: 247), + aligner: FunctionalPopoverAligner(popoverAligner), + ), + itemBuilder: (context, item, isActive, onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 32), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(right: 20.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + child: item == textItem + ? const Icon( + Icons.check, + size: 18, + ) + : null, + ), + Text( + item.id, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.black, + fontFamily: item.id, + fontSize: themeFontSizeByName[item.id], + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _HeaderSelector extends StatefulWidget { + const _HeaderSelector({ + required this.editorFocusNode, + required this.editor, + }); + + final FocusNode editorFocusNode; + final Editor editor; + + @override + State createState() => _HeaderSelectorState(); +} + +class _HeaderSelectorState extends State<_HeaderSelector> { + static const _headingLevelNames = [ + "Heading 1", + "Heading 2", + "Heading 3", + "Heading 4", + "Heading 5", + "Heading 6", + "Normal", + ]; + static const _headerLevelFormats = { + "Heading 1": FeatherTextBlock.header1, + "Heading 2": FeatherTextBlock.header2, + "Heading 3": FeatherTextBlock.header3, + "Heading 4": FeatherTextBlock.header4, + "Heading 5": FeatherTextBlock.header5, + "Heading 6": FeatherTextBlock.header6, + "Normal": FeatherTextBlock.paragraph, + }; + static final _headerLevelNames = { + header1Attribution: "Heading 1", + header2Attribution: "Heading 2", + header3Attribution: "Heading 3", + header4Attribution: "Heading 4", + header5Attribution: "Heading 5", + header6Attribution: "Heading 6", + paragraphAttribution: "Normal", + null: "Normal", + }; + + void _onChangeHeadingLevelRequested(String? newHeadingLevel) { + final selection = widget.editor.composer.selection; + if (selection == null) { + return; + } + if (selection.base.nodeId != selection.extent.nodeId) { + return; + } + + final selectedNode = widget.editor.document.getNodeById(selection.extent.nodeId); + if (selectedNode is! TextNode) { + return; + } + + widget.editor.execute([ + ConvertTextBlockToFormatRequest(_headerLevelFormats[newHeadingLevel]!), + ]); + + // Rebuild to update the selected font on the toolbar. + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final composer = widget.editor.composer; + final selection = composer.selection; + var selectedHeaderLevel = "Normal"; + if (selection != null && selection.base.nodeId == selection.extent.nodeId) { + final selectedNode = widget.editor.document.getNodeById(selection.extent.nodeId); + if (selectedNode is ParagraphNode) { + selectedHeaderLevel = _headerLevelNames[selectedNode.getMetadataValue("blockType")] ?? "Normal"; + } + } + final textItem = TextItem(id: selectedHeaderLevel, label: selectedHeaderLevel); + + return TextItemSelector( + parentFocusNode: widget.editorFocusNode, + // tapRegionGroupId: _tapRegionGroupId, + selectedText: textItem, + items: _headingLevelNames.map((headingName) => TextItem(id: headingName, label: headingName)).toList(), + onSelected: (value) => _onChangeHeadingLevelRequested(value?.id), + buttonSize: const Size(97, 30), + popoverGeometry: const PopoverGeometry( + constraints: BoxConstraints.tightFor(width: 247), + aligner: FunctionalPopoverAligner(popoverAligner), + ), + itemBuilder: (context, item, isActive, onTap) => DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 32), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(right: 20.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + child: item == textItem + ? const Icon( + Icons.check, + size: 18, + ) + : null, + ), + Text( + item.id, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.black, + fontFamily: item.id, + fontSize: themeHeaderFontSizeByName[item.id], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TextColorButton extends StatefulWidget { + const _TextColorButton({ + required this.editorFocusNode, + required this.editor, + }); + + final FocusNode editorFocusNode; + final Editor editor; + + @override + State<_TextColorButton> createState() => _TextColorButtonState(); +} + +class _TextColorButtonState extends State<_TextColorButton> { + void _onChangeTextColorRequested(Color? newColor) { + final selection = widget.editor.composer.selection; + if (selection == null) { + return; + } + + final colorAttributions = widget.editor.document.getAttributionsByType(selection); + + widget.editor.execute([ + for (final existingAttribution in colorAttributions) // + RemoveTextAttributionsRequest(documentRange: selection, attributions: {existingAttribution}), + if (newColor != null) // + AddTextAttributionsRequest( + documentRange: selection, + attributions: {ColorAttribution(newColor)}, + ), + ]); + + // Rebuild to update the color on the toolbar button. + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return ColorSelector( + parentFocusNode: widget.editorFocusNode, + // tapRegionGroupId: _tapRegionGroupId, + onSelected: _onChangeTextColorRequested, + showClearButton: true, + colorButtonBuilder: (_, color) => _buildTextColorIcon(color), + ); + } + + Widget _buildTextColorIcon(Color? color) { + return Stack( + children: [ + const Icon(Icons.format_color_text), + Positioned( + bottom: 0, + left: 1, + child: Container( + width: 16, + height: 4, + color: color ?? Colors.black, + ), + ), + ], + ); + } +} + +class _HighlightColorButton extends StatefulWidget { + const _HighlightColorButton({ + required this.editorFocusNode, + required this.editor, + }); + + final FocusNode editorFocusNode; + final Editor editor; + + @override + State<_HighlightColorButton> createState() => _HighlightColorButtonState(); +} + +class _HighlightColorButtonState extends State<_HighlightColorButton> { + void _onChangeHighlightColorRequested(Color? newColor) { + final selection = widget.editor.composer.selection; + if (selection == null) { + return; + } + + final colorAttributions = widget.editor.document.getAttributionsByType(selection); + + widget.editor.execute([ + for (final existingAttribution in colorAttributions) // + RemoveTextAttributionsRequest(documentRange: selection, attributions: {existingAttribution}), + if (newColor != null) // + AddTextAttributionsRequest( + documentRange: selection, + attributions: {BackgroundColorAttribution(newColor)}, + ), + ]); + + // Rebuild to update the color on the toolbar button. + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return ColorSelector( + parentFocusNode: widget.editorFocusNode, + onSelected: _onChangeHighlightColorRequested, + showClearButton: true, + colorButtonBuilder: (_, color) => _buildHighlightColorIcon(color), + ); + } + + Widget _buildHighlightColorIcon(Color? color) { + return Stack( + children: [ + const Icon(Icons.texture), + Positioned( + bottom: 0, + left: 1, + child: Container( + width: 16, + height: 4, + color: color ?? Colors.black, + ), + ), + ], + ); + } +} + +class _FontFamilySelector extends StatefulWidget { + const _FontFamilySelector({ + required this.editorFocusNode, + required this.editor, + }); + + final FocusNode editorFocusNode; + final Editor editor; + + @override + State<_FontFamilySelector> createState() => _FontFamilySelectorState(); +} + +class _FontFamilySelectorState extends State<_FontFamilySelector> { + static const _availableFonts = ["Sans Serif", "Serif", "Monospace"]; + + void _onChangeFontFamilyRequested(String? newFontFamily) { + final selection = widget.editor.composer.selection; + if (selection == null) { + return; + } + + final fontFamilyAttributions = widget.editor.document.getAttributionsByType(selection); + + widget.editor.execute([ + for (final existingAttribution in fontFamilyAttributions) // + RemoveTextAttributionsRequest(documentRange: selection, attributions: {existingAttribution}), + if (newFontFamily != null) // + AddTextAttributionsRequest( + documentRange: selection, + attributions: {FontFamilyAttribution(newFontFamily)}, + ), + ]); + + // Rebuild to update the selected font on the toolbar. + setState(() {}); + } + + @override + Widget build(BuildContext context) { + const defaultFont = 'Sans Serif'; + + final selectedFont = _getAllAttributions(widget.editor.document, widget.editor.composer) + .whereType() + .firstOrNull + ?.fontFamily ?? + defaultFont; + final textItem = TextItem(id: selectedFont, label: selectedFont); + + return TextItemSelector( + parentFocusNode: widget.editorFocusNode, + selectedText: textItem, + items: _availableFonts.map((fontFamily) => TextItem(id: fontFamily, label: fontFamily)).toList(), + onSelected: (value) => _onChangeFontFamilyRequested(value?.id), + buttonSize: const Size(97, 30), + popoverGeometry: const PopoverGeometry( + constraints: BoxConstraints.tightFor(width: 247), + aligner: FunctionalPopoverAligner(popoverAligner), + ), + itemBuilder: (context, item, isActive, onTap) => DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 32), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(right: 20.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 24, + child: item == textItem + ? const Icon( + Icons.check, + size: 18, + ) + : null, + ), + Text( + item.id, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.black, + fontFamily: item.id, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _AlignmentButton extends StatefulWidget { + const _AlignmentButton({ + required this.editorFocusNode, + required this.editor, + }); + + final FocusNode editorFocusNode; + final Editor editor; + + @override + State<_AlignmentButton> createState() => _AlignmentButtonState(); +} + +class _AlignmentButtonState extends State<_AlignmentButton> { + @override + Widget build(BuildContext context) { + final alignment = _getCurrentTextAlignment(); + + return IconSelector( + parentFocusNode: widget.editorFocusNode, + selectedIcon: IconItem( + id: alignment.name, + icon: _getTextAlignIcon(alignment), + ), + icons: const [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify] + .map( + (alignment) => IconItem( + icon: _getTextAlignIcon(alignment), + id: alignment.name, + ), + ) + .toList(), + onSelected: (selectedItem) { + if (selectedItem == null) { + return; + } + final newAlignment = TextAlign.values.firstWhere((e) => e.name == selectedItem.id); + + final composer = widget.editor.composer; + final selection = composer.selection; + if (selection == null) { + return; + } + + widget.editor.execute([ + ChangeParagraphAlignmentRequest( + nodeId: selection.extent.nodeId, + alignment: newAlignment, + ), + ]); + }, + ); + } + + IconData _getTextAlignIcon(TextAlign align) { + switch (align) { + case TextAlign.left: + case TextAlign.start: + return Icons.format_align_left; + case TextAlign.center: + return Icons.format_align_center; + case TextAlign.right: + case TextAlign.end: + return Icons.format_align_right; + case TextAlign.justify: + return Icons.format_align_justify; + } + } + + /// Returns the text alignment of the currently selected text node. + /// + /// Throws an exception if the currently selected node is not a text node. + TextAlign _getCurrentTextAlignment() { + final composer = widget.editor.composer; + final selection = composer.selection; + if (selection == null) { + return TextAlign.left; + } + + final document = widget.editor.document; + final selectedNode = document.getNodeById(selection.extent.nodeId); + if (selectedNode == null) { + // Default to "left" when there's no selection. This only effects the + // icon that's displayed on the toolbar. + return TextAlign.left; + } + + final align = selectedNode.getMetadataValue('textAlign'); + switch (align) { + case 'left': + return TextAlign.left; + case 'center': + return TextAlign.center; + case 'right': + return TextAlign.right; + case 'justify': + return TextAlign.justify; + default: + return TextAlign.left; + } + } +} + +/// Returns all attributions of the currently selected range, if the selection is expanded, +/// or the current composer attributes, if the selection is collapsed. +Set _getAllAttributions(Document document, DocumentComposer composer) { + final selection = composer.selection; + if (selection == null) { + return {}; + } + + if (selection.isCollapsed) { + return composer // + .preferences + .currentAttributions; + } + + return document.getAllAttributions(selection); +} diff --git a/super_clones/quill/lib/infrastructure/popovers/color_selector.dart b/super_clones/quill/lib/infrastructure/popovers/color_selector.dart new file mode 100644 index 0000000000..a919b3af58 --- /dev/null +++ b/super_clones/quill/lib/infrastructure/popovers/color_selector.dart @@ -0,0 +1,353 @@ +import 'package:feather/infrastructure/popovers/selectable_grid.dart'; +import 'package:feather/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A selection control, which displays a button with the selected color, and upon tap, displays a +/// color picker with the available colors, from which the user can select a different color. +/// +/// Includes the following keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" color selection up/down. +/// * Pressing LEFT/RIGHT moves the "active" color selection left/right. +/// * Pressing ENTER selects the currently active color. +class ColorSelector extends StatefulWidget { + const ColorSelector({ + super.key, + this.parentFocusNode, + this.tapRegionGroupId, + this.boundaryKey, + this.selectedColor, + this.colors = defaultColors, + this.columnCount = 10, + this.showClearButton = false, + required this.onSelected, + required this.colorButtonBuilder, + }); + + /// The [FocusNode], to which the color picker's [FocusNode] will be added as a child. + /// + /// See [PopoverScaffold.parentFocusNode] for more information. + final FocusNode? parentFocusNode; + + /// A group ID for a tap region that is shared with the color picker. + /// + /// Tapping on a [TapRegion] with the same [tapRegionGroupId] + /// won't invoke [onTapOutside]. + final String? tapRegionGroupId; + + /// A [GlobalKey] to a widget that determines the bounds where the color picker can be displayed. + /// + /// See [PopoverScaffold.boundaryKey] for more information. + final GlobalKey? boundaryKey; + + /// The currently selected color or `null` if no color is selected. + final Color? selectedColor; + + /// The colors that will be displayed in the color picker. + /// + /// Each color is displayed as a circle. + final List colors; + + /// Defines the number of columns that the color picker should have. + final int columnCount; + + /// Whether or not the color picker should display a "Clear" button. + /// + /// Pressing that button call [onSelected] with a `null` value. + final bool showClearButton; + + /// Called when the user selects an item on the color picker. + final void Function(Color? value) onSelected; + + /// Builds the button of this [ColorSelector]. + final Widget Function(BuildContext context, Color?) colorButtonBuilder; + + @override + State createState() => _ColorSelectorState(); +} + +class _ColorSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the color picker. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + void _onItemSelected(Color? value) { + _popoverController.close(); + widget.onSelected(value); + } + + /// Decides a foreground color for a [background] color based on the brightness of the [background]. + /// + /// Returns [Colors.white] if [background] is a dark color and [Colors.black] otherwise. + Color _getColorForCheckIcon(Color background) { + // Adapted from https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color/3943023#3943023. + final intensity = (0.299 * background.red) + (0.587 * background.green) + (0.114 * background.blue); + return intensity > 130 ? Colors.black : Colors.white; + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + tapRegionGroupId: widget.tapRegionGroupId, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + boundaryKey: widget.boundaryKey, + popoverBuilder: (context) => Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.hardEdge, + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showClearButton) // + _buildClearButton(), + if (widget.showClearButton) // + const SizedBox(height: 3), + _buildColorGrid(), + _buildCustomColorsButton(), + _buildFooterButtons(), + ], + ), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return TextButton( + onPressed: () => _popoverController.open(), + style: defaultToolbarButtonStyle, + child: widget.colorButtonBuilder(context, widget.selectedColor), + ); + } + + Widget _buildClearButton() { + return SizedBox( + width: 243, + height: 32, + child: TextButton.icon( + onPressed: () => _onItemSelected(null), + style: ButtonStyle( + foregroundColor: WidgetStateProperty.all(Colors.black), + backgroundColor: WidgetStateProperty.resolveWith(getButtonColor), + padding: WidgetStateProperty.all(const EdgeInsets.all(5)), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + ), + ), + icon: const Icon(Icons.format_color_reset), + label: const SizedBox( + width: double.infinity, + child: Text( + 'None', + textAlign: TextAlign.left, + ), + ), + ), + ); + } + + Widget _buildColorGrid() { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: (widget.colors.length / widget.columnCount * 20), + maxWidth: 243, + ), + child: SelectableGrid( + focusNode: _popoverFocusNode, + value: null, + items: widget.colors, + itemBuilder: _buildPopoverGridItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + columnCount: widget.columnCount, + mainAxisExtent: 18, + ), + ); + } + + Widget _buildPopoverGridItem(BuildContext context, Color item, bool isActive, VoidCallback onTap) { + return InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: Stack( + alignment: Alignment.center, + children: [ + Container( + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: item, + shape: BoxShape.circle, + boxShadow: [ + if (isActive) // + const BoxShadow( + blurRadius: 3, + ) + ], + ), + ), + Icon( + Icons.check, + size: 15, + color: _getColorForCheckIcon(item), + ) + ], + ), + ); + } + + Widget _buildCustomColorsButton() { + return SizedBox( + width: 243, + height: 24, + child: TextButton( + onPressed: () {}, + style: ButtonStyle( + foregroundColor: WidgetStateProperty.all(Colors.black), + backgroundColor: WidgetStateProperty.resolveWith(getButtonColor), + padding: WidgetStateProperty.all(const EdgeInsets.all(5)), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + ), + ), + child: const SizedBox( + width: double.infinity, + child: Text( + 'Custom Colors', + textAlign: TextAlign.left, + ), + ), + ), + ); + } + + Widget _buildFooterButtons() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () {}, + style: defaultToolbarButtonStyle, + child: const Icon(Icons.add_circle_outline), + ), + TextButton( + onPressed: () {}, + style: defaultToolbarButtonStyle, + child: const Icon(Icons.colorize), + ), + ], + ); + } +} + +const defaultColors = [ + Colors.black, + Color(0xFF434343), + Color(0xFF666666), + Color(0xFF999999), + Color(0xFFB7B7B7), + Color(0xFFCCCCCC), + Color(0xFFD9D9D9), + Color(0xFFEFEFEF), + Color(0xFFF3F3F3), + Color(0xFFDFE0E3), + // + Color(0xFF980201), + Color(0xFFFF0000), + Color(0xFFFF9900), + Color(0xFFFFFF00), + Color(0xFF01FF00), + Color(0xFF02FFFF), + Color(0xFF4A86E8), + Color(0xFF0602FF), + Color(0xFF9901FF), + Color(0xFFFF00FF), + // + Color(0xFFE6B8AF), + Color(0xFFF4CCCC), + Color(0xFFFCE5CD), + Color(0xFFFFF2CC), + Color(0xFFD9EAD3), + Color(0xFFD0E0E3), + Color(0xFFC9DAF8), + Color(0xFFCFE2F3), + Color(0xFFD9D2E9), + Color(0xFFEAD1DC), + // + Color(0xFFDD7E6A), + Color(0xFFEA9999), + Color(0xFFF9CB9C), + Color(0xFFFFE599), + Color(0xFFB6D7A8), + Color(0xFFA2C4C9), + Color(0xFFA4C2F4), + Color(0xFF9FC5E8), + Color(0xFFB4A7D6), + Color(0xFFD5A6BD), + // + Color(0xFFCC4125), + Color(0xFFE06666), + Color(0xFFF6B26B), + Color(0xFFFFD965), + Color(0xFF93C47E), + Color(0xFF76A5AF), + Color(0xFF6D9EEB), + Color(0xFF6FA8DC), + Color(0xFF8E7CC3), + Color(0xFFC27BA0), + // + Color(0xFFA61C01), + Color(0xFFCC0200), + Color(0xFFE69139), + Color(0xFFF1C233), + Color(0xFF6AA84F), + Color(0xFF45808E), + Color(0xFF3C78D8), + Color(0xFF3D85C6), + Color(0xFF674EA7), + Color(0xFFA64D78), + // + Color(0xFF85200C), + Color(0xFF990201), + Color(0xFFB45F07), + Color(0xFFBF9001), + Color(0xFF38761D), + Color(0xFF144F5C), + Color(0xFF1155CC), + Color(0xFF0B5394), + Color(0xFF351D75), + Color(0xFF741B46), + // + Color(0xFF5B0E03), + Color(0xFF660202), + Color(0xFF783E03), + Color(0xFF7F6001), + Color(0xFF274E13), + Color(0xFF0C343D), + Color(0xFF1B4487), + Color(0xFF093763), + Color(0xFF20124D), + Color(0xFF4C1230), + // +]; diff --git a/super_clones/quill/lib/infrastructure/popovers/icon_selector.dart b/super_clones/quill/lib/infrastructure/popovers/icon_selector.dart new file mode 100644 index 0000000000..45d5c6084d --- /dev/null +++ b/super_clones/quill/lib/infrastructure/popovers/icon_selector.dart @@ -0,0 +1,158 @@ +import 'package:feather/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A selection control, which displays a button with the selected icon, and upon tap, displays a +/// popover list of available icons, from which the user can select a different icon. +/// +/// Includes the following keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" icon selection up/down. +/// * Pressing UP with the first icon active moves the active icon selection to the last icon. +/// * Pressing DOWN with the last icon active moves the active icon selection to the first icon. +/// * Pressing ENTER selects the currently active icon. +class IconSelector extends StatefulWidget { + const IconSelector({ + super.key, + this.parentFocusNode, + this.tapRegionGroupId, + this.boundaryKey, + this.selectedIcon, + required this.icons, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// See [PopoverScaffold.parentFocusNode] for more information. + final FocusNode? parentFocusNode; + + /// A group ID for a tap region that is shared with the popover list. + /// + /// Tapping on a [TapRegion] with the same [tapRegionGroupId] + /// won't invoke [onTapOutside]. + final String? tapRegionGroupId; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// See [PopoverScaffold.boundaryKey] for more information. + final GlobalKey? boundaryKey; + + /// The currently selected icon or `null` if no icon is selected. + final IconItem? selectedIcon; + + /// The icons that will be displayed in the popover list. + final List icons; + + /// Called when the user selects an icon on the popover list. + final void Function(IconItem? value) onSelected; + + @override + State createState() => _IconSelectorState(); +} + +class _IconSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + tapRegionGroupId: widget.tapRegionGroupId, + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + popoverGeometry: const PopoverGeometry( + constraints: BoxConstraints(minHeight: 40), + aligner: FunctionalPopoverAligner(popoverAligner), + ), + popoverBuilder: (context) => Material( + elevation: 8, + borderRadius: BorderRadius.circular(4), + clipBehavior: Clip.hardEdge, + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: ItemSelectionList( + axis: Axis.horizontal, + focusNode: _popoverFocusNode, + value: widget.selectedIcon, + items: widget.icons, + itemBuilder: _buildItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + ), + ), + ), + ); + } + + Widget _buildItem(BuildContext context, IconItem item, bool isActive, VoidCallback onTap) { + return Container( + height: 30, + width: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + color: item == widget.selectedIcon + ? toolbarButtonSelectedColor + : isActive + ? Colors.grey.withValues(alpha: 0.2) + : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Icon(item.icon), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return TextButton( + onPressed: () => _popoverController.open(), + style: defaultToolbarButtonStyle, + child: widget.selectedIcon == null // + ? const SizedBox() + : Icon(widget.selectedIcon!.icon), + ); + } + + void _onItemSelected(IconItem? value) { + _popoverController.close(); + widget.onSelected(value); + } +} + +/// An option that is displayed as an icon by a [IconSelector]. +/// +/// Two [IconItem]s are considered to be equal if they have the same [id]. +class IconItem { + const IconItem({ + required this.id, + required this.icon, + }); + + /// The value that identifies this item. + final String id; + + /// The icon that is displayed. + final IconData icon; + + @override + bool operator ==(Object other) => + identical(this, other) || other is IconItem && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/super_clones/quill/lib/infrastructure/popovers/selectable_grid.dart b/super_clones/quill/lib/infrastructure/popovers/selectable_grid.dart new file mode 100644 index 0000000000..1b60db813c --- /dev/null +++ b/super_clones/quill/lib/infrastructure/popovers/selectable_grid.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// A grid where the user can navigate between its items and select one of them. +/// +/// Includes the following keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing LEFT/RIGHT moves the "active" item selection left/right. +/// * Pressing ENTER selects the currently active item. +class SelectableGrid extends StatefulWidget { + const SelectableGrid({ + super.key, + this.focusNode, + required this.value, + required this.items, + required this.itemBuilder, + required this.columnCount, + this.mainAxisExtent, + this.onItemActivated, + required this.onItemSelected, + this.onCancel, + }); + + /// The [FocusNode] of the grid. + final FocusNode? focusNode; + + /// The currently selected value or `null` if no item is selected. + final GridItemType? value; + + /// The items that will be displayed on the grid. + /// + /// For each item, [itemBuilder] is called to build its visual representation. + final List items; + + /// Builds each item on the grid. + /// + /// This method is called for each item in [items], to build its visual representation. + /// + /// The provided `onTap` must be called when the item is tapped. + final SelectableGridItemBuilder itemBuilder; + + /// How many columns the grid must have. + final int columnCount; + + /// The extent of each item on the grid. + final double? mainAxisExtent; + + /// Called when the user activates an item on the grid. + /// + /// The activation can be performed by: + /// 1. Pressing UP ARROW or DOWN ARROW. + /// 2. Pressing LEFT ARROW or RIGHT ARROW. + final ValueChanged? onItemActivated; + + /// Called when the user selects an item on the grid. + /// + /// The selection can be performed by: + /// 1. Tapping on an item in the grid. + /// 2. Pressing ENTER when the grid has an active item. + final ValueChanged onItemSelected; + + /// Called when the user presses ESCAPE. + final VoidCallback? onCancel; + + @override + State> createState() => _SelectableGridState(); +} + +class _SelectableGridState extends State> + with SingleTickerProviderStateMixin { + final ScrollController _scrollController = ScrollController(); + + /// Holds keys to each item on the grid. + /// + /// Used to scroll the grid to reveal the active item. + final List _itemKeys = []; + + int? _activeIndex; + + @override + void initState() { + super.initState(); + _activateSelectedItem(); + } + + @override + void dispose() { + _scrollController.dispose(); + + super.dispose(); + } + + void _activateSelectedItem() { + final selectedItem = widget.value; + + if (selectedItem == null) { + _activeIndex = null; + return; + } + + int selectedItemIndex = widget.items.indexOf(selectedItem); + if (selectedItemIndex < 0) { + // A selected item was provided, but it isn't included in the list of items. + _activeIndex = null; + return; + } + + // The grid was just displayed. + // Jump to the active item without animation. + _activateItem(selectedItemIndex, animationDuration: Duration.zero); + } + + /// Activates the item at [itemIndex] and ensure it's visible on screen. + /// + /// The active item is selected when the user presses ENTER. + void _activateItem(int? itemIndex, {required Duration animationDuration}) { + _activeIndex = itemIndex; + if (itemIndex != null) { + widget.onItemActivated?.call(widget.items[itemIndex]); + } + + // This method might be called before the widget was rendered. + // For example, when the widget is created with a selected item, + // this item is immediately activated, before the rendering pipeline is + // executed. Therefore, the RenderBox won't be available at the same frame. + // + // Scrolls on the next frame to let the popover be laid-out first, + // so we can access its RenderBox. + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (!mounted) { + return; + } + _scrollToShowActiveItem(animationDuration); + }); + } + + /// Scrolls the scrollable to display the selected item. + void _scrollToShowActiveItem(Duration animationDuration) { + if (_activeIndex == null) { + return; + } + + final key = _itemKeys[_activeIndex!]; + + final childRenderBox = key.currentContext?.findRenderObject() as RenderBox?; + if (childRenderBox == null) { + return; + } + + childRenderBox.showOnScreen( + rect: Offset.zero & childRenderBox.size, + duration: animationDuration, + curve: Curves.easeIn, + ); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + if (!const [ + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.escape, + ].contains(event.logicalKey)) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onCancel?.call(); + return KeyEventResult.handled; + } + + if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { + if (_activeIndex == null) { + // The user pressed ENTER without an active item. + // Clear the selected item. + widget.onItemSelected(null); + return KeyEventResult.handled; + } + + widget.onItemSelected(widget.items[_activeIndex!]); + + return KeyEventResult.handled; + } + + // The user pressed an arrow key. Update the active item. + int? newActiveIndex; + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_activeIndex == null || _activeIndex! >= widget.items.length - 1) { + // We don't have an active item or we are at the end of the list. Activate the first item. + newActiveIndex = 0; + } else { + // Activate the next item. + newActiveIndex = _activeIndex! + 1; + } + } + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_activeIndex == null || _activeIndex! <= 0) { + // We don't have an active item or we are at the beginning of the list. Activate the last item. + newActiveIndex = widget.items.length - 1; + } else { + // Activate the previous item. + newActiveIndex = _activeIndex! - 1; + } + } + + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + newActiveIndex = (_activeIndex ?? 0) + widget.columnCount; + if (newActiveIndex >= widget.items.length - 1) { + // We don't have an active item or we are at the end of the list. Activate the first item. + newActiveIndex = 0; + } + } + + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + newActiveIndex = (_activeIndex ?? 0) - widget.columnCount; + if (newActiveIndex <= 0) { + // We don't have an active item or we are at the beginning of the list. Activate the last item. + newActiveIndex = widget.items.length - 1; + } + } + + setState(() { + _activateItem(newActiveIndex, animationDuration: const Duration(milliseconds: 100)); + }); + + return KeyEventResult.handled; + } + + @override + Widget build(BuildContext context) { + _itemKeys.clear(); + + for (int i = 0; i < widget.items.length; i++) { + _itemKeys.add(GlobalKey()); + } + return Focus( + focusNode: widget.focusNode, + onKeyEvent: _onKeyEvent, + child: GridView.builder( + clipBehavior: Clip.none, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.columnCount, + mainAxisSpacing: 2, + mainAxisExtent: widget.mainAxisExtent, + ), + itemCount: widget.items.length, + itemBuilder: (context, index) { + return widget.itemBuilder( + context, + widget.items[index], + _activeIndex == index, + () => widget.onItemSelected(widget.items[index]), + ); + }, + ), + ); + } +} + +/// Builds a grid item. +/// +/// [isActive] is `true` if [item] is the currently active item on the grid, or `false` otherwise. +/// +/// The active item is the currently focused item in the grid, which can be selected by pressing ENTER. +/// +/// The provided [onTap] must be called when the button is tapped. +typedef SelectableGridItemBuilder = Widget Function(BuildContext context, T item, bool isActive, VoidCallback onTap); diff --git a/super_clones/quill/lib/infrastructure/popovers/text_item_selector.dart b/super_clones/quill/lib/infrastructure/popovers/text_item_selector.dart new file mode 100644 index 0000000000..2ca884176c --- /dev/null +++ b/super_clones/quill/lib/infrastructure/popovers/text_item_selector.dart @@ -0,0 +1,237 @@ +import 'package:feather/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A selection control, which displays a button with the selected text, and upon tap, displays a +/// popover list of available texts, from which the user can select a different text. +/// +/// Includes the following keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" text selection up/down. +/// * Pressing UP with the first text active moves the active text selection to the last text. +/// * Pressing DOWN with the last text active moves the active text selection to the first text. +/// * Pressing ENTER selects the currently active text. +class TextItemSelector extends StatefulWidget { + const TextItemSelector({ + super.key, + required this.parentFocusNode, + this.tapRegionGroupId, + this.boundaryKey, + this.selectedText, + required this.items, + this.popoverGeometry, + this.buttonSize, + this.itemBuilder = defaultPopoverListItemBuilder, + this.separatorBuilder, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// See [PopoverScaffold.parentFocusNode] for more information. + final FocusNode parentFocusNode; + + /// A group ID for a tap region that is shared with the popover list. + /// + /// Tapping on a [TapRegion] with the same [tapRegionGroupId] + /// won't invoke [onTapOutside]. + final String? tapRegionGroupId; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// See [PopoverScaffold.boundaryKey] for more information. + final GlobalKey? boundaryKey; + + /// The currently selected text or `null` if no text is selected. + /// + /// This value is used to build the button. + final TextItem? selectedText; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [TextItem.label] is displayed. + final List items; + + /// Builds each item on the list. + /// + /// Defaults to [defaultPopoverListItemBuilder]. + final SelectableListItemBuilder itemBuilder; + + /// Builds a separator between each item. + /// + /// If `null`, no separator is displayed. + final IndexedWidgetBuilder? separatorBuilder; + + /// The desired size of the button. + /// + /// If `null` a default fixed size is used. + final Size? buttonSize; + + /// Controls the size and position of the popover. + /// + /// The popover is first sized, then positioned. + final PopoverGeometry? popoverGeometry; + + /// Called when the user selects an item on the popover list. + final void Function(TextItem? value) onSelected; + + @override + State createState() => _TextItemSelectorState(); +} + +class _TextItemSelectorState extends State { + final PopoverController _popoverController = PopoverController(); + final WidgetStatesController _buttonStatesController = WidgetStatesController(); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _popoverController.addListener(_onPopoverVisibilityChange); + } + + @override + void dispose() { + _focusNode.dispose(); + _popoverController.dispose(); + _buttonStatesController.dispose(); + super.dispose(); + } + + void _onItemSelected(TextItem? value) { + if (value == null) { + return; + } + widget.onSelected(value); + _popoverController.close(); + } + + void _onPopoverVisibilityChange() { + _buttonStatesController.update(WidgetState.pressed, _popoverController.shouldShow); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + popoverFocusNode: _focusNode, + parentFocusNode: widget.parentFocusNode, + tapRegionGroupId: widget.tapRegionGroupId, + controller: _popoverController, + buttonBuilder: _buildButton, + popoverBuilder: _buildPopover, + popoverGeometry: + widget.popoverGeometry ?? const PopoverGeometry(aligner: FunctionalPopoverAligner(popoverAligner)), + ); + } + + Widget _buildButton(BuildContext context) { + final size = WidgetStateProperty.all(widget.buttonSize ?? const Size(97, 30)); + return TapRegion( + groupId: widget.tapRegionGroupId, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + TextButton( + statesController: _buttonStatesController, + onPressed: () => _popoverController.toggle(), + style: defaultToolbarButtonStyle.copyWith( + fixedSize: size, + minimumSize: size, + maximumSize: size, + ), + child: SizedBox( + width: (widget.buttonSize?.width ?? 97) - 30, + child: Text( + widget.selectedText?.label ?? '', + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: TextStyle( + color: Colors.black.withValues(alpha: 0.7), + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 10), + const Positioned( + right: 0, + child: Icon(Icons.arrow_drop_down), + ), + ], + ), + ); + } + + Widget _buildPopover(BuildContext context) { + return Material( + elevation: 8, + borderRadius: BorderRadius.circular(4), + clipBehavior: Clip.hardEdge, + color: Colors.white, + child: SizedBox( + child: ItemSelectionList( + focusNode: _focusNode, + value: widget.selectedText, + items: widget.items, + itemBuilder: widget.itemBuilder, + separatorBuilder: widget.separatorBuilder, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + ), + ), + ); + } +} + +Widget defaultPopoverListItemBuilder(BuildContext context, TextItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 32), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + children: [ + Text( + item.label, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ); +} + +/// An option that is displayed as text by a [TextItemSelector]. +/// +/// Two [TextItem]s are considered to be equal if they have the same [id]. +class TextItem { + const TextItem({ + required this.id, + required this.label, + }); + + /// The value that identifies this item. + final String id; + + /// The text that is displayed. + final String label; + + @override + bool operator ==(Object other) => + identical(this, other) || other is TextItem && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/super_clones/quill/lib/main.dart b/super_clones/quill/lib/main.dart new file mode 100644 index 0000000000..1dd5f72e76 --- /dev/null +++ b/super_clones/quill/lib/main.dart @@ -0,0 +1,6 @@ +import 'package:feather/app.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(const FeatherApp()); +} diff --git a/super_clones/quill/lib/theme.dart b/super_clones/quill/lib/theme.dart new file mode 100644 index 0000000000..c8bbb20660 --- /dev/null +++ b/super_clones/quill/lib/theme.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/super_editor.dart'; + +/// The background color of the window panes, such as the background of the +/// app header/ribbon. +const windowBackgroundColor = Color(0xFFf9fbfd); + +/// The color of the icons that appear next to the document title. +const titleActionIconColor = Color(0xFF444746); + +/// The horizontal padding of the primary app menu buttons, e.g., "File", "Edit". +const menuButtonHorizontalPadding = 8.0; + +/// The background color of the app toolbar, i.e., the toolbar with options for font +/// family, font size, text alignment. +const toolbarBackgroundColor = Color(0xFFedf2fa); + +/// The background color of a selected button on the toolbar, i.e., the color of a +/// bold button when the selection is bold. +const toolbarButtonSelectedColor = Color(0xFFd3e3fd); + +/// The background color of a hovered button on the toolbar. +const toolbarButtonHoveredColor = Color(0xFFE1E6ED); + +/// The background color of a pressed button on the toolbar. +const toolbarButtonPressedColor = Color(0xFFDAE0E6); + +/// The color of the vertical divider of the toolbar. +const toolbarDividerColor = Color(0xFFC7C7C7); + +/// Computes the background color for toolbar buttons. +Color? getButtonColor(Set states) { + if (states.contains(WidgetState.pressed)) { + return toolbarButtonPressedColor; + } + + if (states.contains(WidgetState.selected)) { + return toolbarButtonSelectedColor; + } + + if (states.contains(WidgetState.hovered)) { + return toolbarButtonHoveredColor; + } + + return Colors.transparent; +} + +const themeFontSizeByName = { + "Huge": 32, + "Large": 19, + "Normal": 13, + "Small": 10, +}; + +const themeHeaderFontSizeByName = { + "Heading 1": 26, + "Heading 2": 19, + "Heading 3": 15, + "Heading 4": 13, + "Heading 5": 11, + "Heading 6": 9, + null: 13, // Paragraph text size +}; + +final defaultToolbarButtonStyle = ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith(getButtonColor), + overlayColor: WidgetStateProperty.all(Colors.transparent), + foregroundColor: WidgetStateProperty.all(Colors.black), + fixedSize: WidgetStateProperty.all(const Size(30, 30)), + minimumSize: WidgetStateProperty.all(const Size(30, 30)), + maximumSize: WidgetStateProperty.all(const Size(30, 30)), + iconSize: WidgetStateProperty.all(18), + padding: WidgetStateProperty.all(EdgeInsets.zero), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4.0), + ), + ), + shadowColor: WidgetStateProperty.all(Colors.transparent), +); + +final featherStylesheet = defaultStylesheet.copyWith( + addRulesAfter: featherStyles, + inlineTextStyler: (Set attributions, TextStyle existingStyle) { + var newStyle = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.contains(const NamedFontSizeAttribution("Huge"))) { + newStyle = newStyle.copyWith( + fontSize: 32, + ); + } + if (attributions.contains(const NamedFontSizeAttribution("Large"))) { + newStyle = newStyle.copyWith( + fontSize: 19, + ); + } + if (attributions.contains(const NamedFontSizeAttribution("Small"))) { + newStyle = newStyle.copyWith( + fontSize: 10, + ); + } + + return newStyle; + }); + +final featherStyles = [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.all(0), + Styles.textStyle: const TextStyle( + fontFamily: "Sans Serif", + fontSize: 13, + height: 1.4, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(top: 16), + Styles.textStyle: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + fontSize: 19, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header4"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header5"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header6"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("blockquote"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.black, + fontSize: 13, + fontWeight: FontWeight.normal, + ), + }; + }, + ), + StyleRule( + const BlockSelector("code"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.white, + fontFamily: "Monospace", + fontSize: 13, + fontWeight: FontWeight.normal, + ), + }; + }, + ), +]; + +FollowerAlignment popoverAligner(Rect globalLeaderRect, Size followerSize, Size screenSize, GlobalKey? boundaryKey) { + return const FollowerAlignment( + leaderAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + followerOffset: Offset(0, 1), + ); +} diff --git a/super_clones/quill/macos/.gitignore b/super_clones/quill/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/super_clones/quill/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/super_clones/quill/macos/Flutter/Flutter-Debug.xcconfig b/super_clones/quill/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..4b81f9b2d2 --- /dev/null +++ b/super_clones/quill/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_clones/quill/macos/Flutter/Flutter-Release.xcconfig b/super_clones/quill/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5caa9d1579 --- /dev/null +++ b/super_clones/quill/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_clones/quill/macos/Flutter/GeneratedPluginRegistrant.swift b/super_clones/quill/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..8236f5728c --- /dev/null +++ b/super_clones/quill/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/super_clones/quill/macos/Podfile b/super_clones/quill/macos/Podfile new file mode 100644 index 0000000000..c795730db8 --- /dev/null +++ b/super_clones/quill/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14' + +# 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__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/super_clones/quill/macos/Podfile.lock b/super_clones/quill/macos/Podfile.lock new file mode 100644 index 0000000000..e2d4e2a53a --- /dev/null +++ b/super_clones/quill/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - FlutterMacOS (1.0.0) + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + url_launcher_macos: de10e46d8d8b9e3a7b8a133e8de92b104379f05e + +PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 + +COCOAPODS: 1.16.2 diff --git a/super_clones/quill/macos/Runner.xcodeproj/project.pbxproj b/super_clones/quill/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..093f5ff848 --- /dev/null +++ b/super_clones/quill/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + 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 */ + 18F21EFB5E09EFC425F2A67F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2BC5683E35F8CCBC13C48050 /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 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 */; }; + B2581CDBAD665B1CFB1CE92D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4AC64B853DA66EBC8DBBCB1C /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 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 */ + 2BC5683E35F8CCBC13C48050 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* feather.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = feather.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 389E83C5B293F2DB0D34E8E1 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 4AC64B853DA66EBC8DBBCB1C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 65C8695AB1FA0A06BB1F1E7E /* 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 = ""; }; + 6828AFA0C502C2FAB178B22E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 893BFFAE5C4C372A2CA82DF2 /* 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 = ""; }; + 8E0092687091B38D84CD0477 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A0BA15855AF790650B2341CE /* 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B2581CDBAD665B1CFB1CE92D /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 18F21EFB5E09EFC425F2A67F /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + C169800E7C3AC48A7EA7DD9E /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* feather.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + C169800E7C3AC48A7EA7DD9E /* Pods */ = { + isa = PBXGroup; + children = ( + 65C8695AB1FA0A06BB1F1E7E /* Pods-Runner.debug.xcconfig */, + 893BFFAE5C4C372A2CA82DF2 /* Pods-Runner.release.xcconfig */, + A0BA15855AF790650B2341CE /* Pods-Runner.profile.xcconfig */, + 8E0092687091B38D84CD0477 /* Pods-RunnerTests.debug.xcconfig */, + 389E83C5B293F2DB0D34E8E1 /* Pods-RunnerTests.release.xcconfig */, + 6828AFA0C502C2FAB178B22E /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2BC5683E35F8CCBC13C48050 /* Pods_Runner.framework */, + 4AC64B853DA66EBC8DBBCB1C /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 2C6223EADA86AF5FAC601BFA /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + FF9B26D6B7C0FA75BE0C8577 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + B12C2D7B3AD01B1DBAFF03C5 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* feather.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 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 */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 2C6223EADA86AF5FAC601BFA /* [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-RunnerTests-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; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + 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"; + }; + B12C2D7B3AD01B1DBAFF03C5 /* [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; + }; + FF9B26D6B7C0FA75BE0C8577 /* [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 */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 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 = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8E0092687091B38D84CD0477 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.feather.feather.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/feather.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/feather"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 389E83C5B293F2DB0D34E8E1 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.feather.feather.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/feather.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/feather"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6828AFA0C502C2FAB178B22E /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.feather.feather.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/feather.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/feather"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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 */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 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 */; +} diff --git a/super_clones/quill/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/quill/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/quill/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/quill/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_clones/quill/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..9a9b46021e --- /dev/null +++ b/super_clones/quill/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/quill/macos/Runner.xcworkspace/contents.xcworkspacedata b/super_clones/quill/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_clones/quill/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_clones/quill/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/quill/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/quill/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/quill/macos/Runner/AppDelegate.swift b/super_clones/quill/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..b3c1761412 --- /dev/null +++ b/super_clones/quill/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..82b6f9d9a3 Binary files /dev/null and b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000..13b35eba55 Binary files /dev/null and b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000..0a3f5fa40f Binary files /dev/null and b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000..bdb57226d5 Binary files /dev/null and b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000000..f083318e09 Binary files /dev/null and b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000..326c0e72c9 Binary files /dev/null and b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000..2f1632cfdd Binary files /dev/null and b/super_clones/quill/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/super_clones/quill/macos/Runner/Base.lproj/MainMenu.xib b/super_clones/quill/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/super_clones/quill/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/quill/macos/Runner/Configs/AppInfo.xcconfig b/super_clones/quill/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..4344257bb2 --- /dev/null +++ b/super_clones/quill/macos/Runner/Configs/AppInfo.xcconfig @@ -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 = feather + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.feather.feather + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 com.flutterbountyhunters.feather. All rights reserved. diff --git a/super_clones/quill/macos/Runner/Configs/Debug.xcconfig b/super_clones/quill/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/super_clones/quill/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_clones/quill/macos/Runner/Configs/Release.xcconfig b/super_clones/quill/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/super_clones/quill/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_clones/quill/macos/Runner/Configs/Warnings.xcconfig b/super_clones/quill/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/super_clones/quill/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/super_clones/quill/macos/Runner/DebugProfile.entitlements b/super_clones/quill/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..08c3ab17cc --- /dev/null +++ b/super_clones/quill/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/super_clones/quill/macos/Runner/Info.plist b/super_clones/quill/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/super_clones/quill/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/super_clones/quill/macos/Runner/MainFlutterWindow.swift b/super_clones/quill/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..d2c4b68bff --- /dev/null +++ b/super_clones/quill/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,22 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + + // Remove the title bar and expand content to fill the entire window. + self.setContentSize(NSSize(width: 1000, height: 650)) + self.styleMask.update(with: StyleMask.fullSizeContentView) + self.titleVisibility = TitleVisibility.hidden + self.titlebarAppearsTransparent = true + self.backgroundColor = NSColor.white + } +} diff --git a/super_clones/quill/macos/Runner/Release.entitlements b/super_clones/quill/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/super_clones/quill/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/super_clones/quill/macos/RunnerTests/RunnerTests.swift b/super_clones/quill/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..61f3bd1fc5 --- /dev/null +++ b/super_clones/quill/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_clones/quill/pubspec.lock b/super_clones/quill/pubspec.lock new file mode 100644 index 0000000000..19e50368e7 --- /dev/null +++ b/super_clones/quill/pubspec.lock @@ -0,0 +1,743 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + attributed_text: + dependency: "direct overridden" + description: + path: "../../attributed_text" + relative: true + source: path + version: "0.4.5" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "4b8701e48a58f7712492c9b1f7ba0bb9d525644dd66d023b62e1fc8cdb560c8a" + url: "https://pub.dev" + source: hosted + version: "1.14.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_quill_delta: + dependency: "direct main" + description: + name: dart_quill_delta + sha256: "6aa89f0903ca3e70f5ceeb1d75d722f6ca583e87a2a8893c7b9f42f7a947f6e5" + url: "https://pub.dev" + source: hosted + version: "9.6.0" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + url: "https://pub.dev" + source: hosted + version: "2.0.28" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_test_robots: + dependency: transitive + description: + name: flutter_test_robots + sha256: "3b00f2081148bde55190997c2772f934ad2f4529cbcfc4ccfa593f8ddc117a28" + url: "https://pub.dev" + source: hosted + version: "0.0.24" + flutter_test_runners: + dependency: transitive + description: + name: flutter_test_runners + sha256: cc575117ed66a79185a26995399d7048341517a1bd21188cb43753739627832d + url: "https://pub.dev" + source: hosted + version: "0.0.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + follow_the_leader: + dependency: "direct main" + description: + name: follow_the_leader + sha256: "2e4c4ebe6b3f1942b2385904b118ba8ba117fae0b30c8c453be0b64a271dd07a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + http: + dependency: transitive + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + material_symbols_icons: + dependency: "direct main" + description: + name: material_symbols_icons + sha256: "8f4abdb6bc714526ccf66e825b7391d7ca65239484ad92be71980fe73a57521c" + url: "https://pub.dev" + source: hosted + version: "4.2780.0" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlord: + dependency: "direct main" + description: + name: overlord + sha256: "532f5685ac09ee805d97ce89794a4eeda41672c32955b4a835bdfce93e720a05" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + super_editor: + dependency: "direct main" + description: + path: "../../super_editor" + relative: true + source: path + version: "0.3.0-dev.40" + super_keyboard: + dependency: transitive + description: + name: super_keyboard + sha256: e3accebf33635f760efbd4d3c13f6484242a09e773ce8e711f4aa745d52b73b1 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + super_text_layout: + dependency: "direct main" + description: + path: "../../super_text_layout" + relative: true + source: path + version: "0.1.19" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" + text_table: + dependency: transitive + description: + name: text_table + sha256: a42b35675be614274b884ee482d4bdf4bdf707bc65de18cb8f1ad288c1beb1f4 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17cd5e205ea615e2c6ea7a77323a11712dffa0720a8a90540db57a01347f9ad9" + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.27.0" diff --git a/super_clones/quill/pubspec.yaml b/super_clones/quill/pubspec.yaml new file mode 100644 index 0000000000..23a664e4f7 --- /dev/null +++ b/super_clones/quill/pubspec.yaml @@ -0,0 +1,70 @@ +name: feather +description: "A Flutter clone of Quill" +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +version: 1.0.0+1 + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.6 + super_editor: any + super_text_layout: any + collection: ^1.18.0 + overlord: ^0.4.2 + material_symbols_icons: ^4.2762.0 + dart_quill_delta: ^9.4.6 + follow_the_leader: ^0.5.1 + +dependency_overrides: + super_editor: + path: ../../super_editor + super_text_layout: + path: ../../super_text_layout + attributed_text: + path: ../../attributed_text + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^3.0.0 + +flutter: + 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 + + fonts: + - family: Sans Serif + fonts: + - asset: assets/fonts/Open_Sans/static/OpenSans-Medium.ttf + - asset: assets/fonts/Open_Sans/static/OpenSans-Italic.ttf + style: italic + - asset: assets/fonts/Open_Sans/static/OpenSans-ExtraBold.ttf + weight: 700 + - asset: assets/fonts/Open_Sans/static/OpenSans-ExtraBoldItalic.ttf + style: italic + weight: 700 + - family: Serif + fonts: + - asset: assets/fonts/EB_Garamond/static/EBGaramond-Regular.ttf + - asset: assets/fonts/EB_Garamond/static/EBGaramond-Italic.ttf + style: italic + - asset: assets/fonts/EB_Garamond/static/EBGaramond-Bold.ttf + weight: 700 + - asset: assets/fonts/EB_Garamond/static/EBGaramond-BoldItalic.ttf + style: italic + weight: 700 + - family: Monospace + fonts: + - asset: assets/fonts/Fira_Mono/FiraMono-Regular.ttf + - asset: assets/fonts/Fira_Mono/FiraMono-Bold.ttf + weight: 700 diff --git a/super_clones/quill/quill_js/index.html b/super_clones/quill/quill_js/index.html new file mode 100644 index 0000000000..724765a7a8 --- /dev/null +++ b/super_clones/quill/quill_js/index.html @@ -0,0 +1,50 @@ + + + + +
+

Hello World!

+

Some initial bold text

+


+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/super_clones/quill/web/favicon.png b/super_clones/quill/web/favicon.png new file mode 100644 index 0000000000..8aaa46ac1a Binary files /dev/null and b/super_clones/quill/web/favicon.png differ diff --git a/super_clones/quill/web/icons/Icon-192.png b/super_clones/quill/web/icons/Icon-192.png new file mode 100644 index 0000000000..b749bfef07 Binary files /dev/null and b/super_clones/quill/web/icons/Icon-192.png differ diff --git a/super_clones/quill/web/icons/Icon-512.png b/super_clones/quill/web/icons/Icon-512.png new file mode 100644 index 0000000000..88cfd48dff Binary files /dev/null and b/super_clones/quill/web/icons/Icon-512.png differ diff --git a/super_clones/quill/web/icons/Icon-maskable-192.png b/super_clones/quill/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000..eb9b4d76e5 Binary files /dev/null and b/super_clones/quill/web/icons/Icon-maskable-192.png differ diff --git a/super_clones/quill/web/icons/Icon-maskable-512.png b/super_clones/quill/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000..d69c56691f Binary files /dev/null and b/super_clones/quill/web/icons/Icon-maskable-512.png differ diff --git a/super_clones/quill/web/index.html b/super_clones/quill/web/index.html new file mode 100644 index 0000000000..321e707d36 --- /dev/null +++ b/super_clones/quill/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + feather + + + + + + diff --git a/super_clones/quill/web/manifest.json b/super_clones/quill/web/manifest.json new file mode 100644 index 0000000000..1f3043aa0e --- /dev/null +++ b/super_clones/quill/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "feather", + "short_name": "feather", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A Flutter clone of Quill", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/super_clones/quill/windows/.gitignore b/super_clones/quill/windows/.gitignore new file mode 100644 index 0000000000..d492d0d98c --- /dev/null +++ b/super_clones/quill/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/super_clones/quill/windows/CMakeLists.txt b/super_clones/quill/windows/CMakeLists.txt new file mode 100644 index 0000000000..bf184552b3 --- /dev/null +++ b/super_clones/quill/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(feather LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "feather") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/super_clones/quill/windows/flutter/CMakeLists.txt b/super_clones/quill/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000000..903f4899d6 --- /dev/null +++ b/super_clones/quill/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/super_clones/quill/windows/flutter/generated_plugin_registrant.cc b/super_clones/quill/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..4f7884874d --- /dev/null +++ b/super_clones/quill/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/super_clones/quill/windows/flutter/generated_plugin_registrant.h b/super_clones/quill/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..dc139d85a9 --- /dev/null +++ b/super_clones/quill/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/super_clones/quill/windows/flutter/generated_plugins.cmake b/super_clones/quill/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..88b22e5c77 --- /dev/null +++ b/super_clones/quill/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/super_clones/quill/windows/runner/CMakeLists.txt b/super_clones/quill/windows/runner/CMakeLists.txt new file mode 100644 index 0000000000..394917c053 --- /dev/null +++ b/super_clones/quill/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/super_clones/quill/windows/runner/Runner.rc b/super_clones/quill/windows/runner/Runner.rc new file mode 100644 index 0000000000..6bb19a3a7d --- /dev/null +++ b/super_clones/quill/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.flutterbountyhunters.feather" "\0" + VALUE "FileDescription", "feather" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "feather" "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 com.flutterbountyhunters.feather. All rights reserved." "\0" + VALUE "OriginalFilename", "feather.exe" "\0" + VALUE "ProductName", "feather" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/super_clones/quill/windows/runner/flutter_window.cpp b/super_clones/quill/windows/runner/flutter_window.cpp new file mode 100644 index 0000000000..955ee3038f --- /dev/null +++ b/super_clones/quill/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/super_clones/quill/windows/runner/flutter_window.h b/super_clones/quill/windows/runner/flutter_window.h new file mode 100644 index 0000000000..6da0652f05 --- /dev/null +++ b/super_clones/quill/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/super_clones/quill/windows/runner/main.cpp b/super_clones/quill/windows/runner/main.cpp new file mode 100644 index 0000000000..9647185f97 --- /dev/null +++ b/super_clones/quill/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"feather", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/super_clones/quill/windows/runner/resource.h b/super_clones/quill/windows/runner/resource.h new file mode 100644 index 0000000000..66a65d1e4a --- /dev/null +++ b/super_clones/quill/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/super_clones/quill/windows/runner/resources/app_icon.ico b/super_clones/quill/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000..c04e20caf6 Binary files /dev/null and b/super_clones/quill/windows/runner/resources/app_icon.ico differ diff --git a/super_clones/quill/windows/runner/runner.exe.manifest b/super_clones/quill/windows/runner/runner.exe.manifest new file mode 100644 index 0000000000..153653e8d6 --- /dev/null +++ b/super_clones/quill/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/super_clones/quill/windows/runner/utils.cpp b/super_clones/quill/windows/runner/utils.cpp new file mode 100644 index 0000000000..3a0b46511a --- /dev/null +++ b/super_clones/quill/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/super_clones/quill/windows/runner/utils.h b/super_clones/quill/windows/runner/utils.h new file mode 100644 index 0000000000..3879d54755 --- /dev/null +++ b/super_clones/quill/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/super_clones/quill/windows/runner/win32_window.cpp b/super_clones/quill/windows/runner/win32_window.cpp new file mode 100644 index 0000000000..60608d0fe5 --- /dev/null +++ b/super_clones/quill/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/super_clones/quill/windows/runner/win32_window.h b/super_clones/quill/windows/runner/win32_window.h new file mode 100644 index 0000000000..e901dde684 --- /dev/null +++ b/super_clones/quill/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/super_clones/slack/.gitignore b/super_clones/slack/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/super_clones/slack/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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 +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/super_clones/slack/.metadata b/super_clones/slack/.metadata new file mode 100644 index 0000000000..c1df7879f1 --- /dev/null +++ b/super_clones/slack/.metadata @@ -0,0 +1,36 @@ +# 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: "17025dd88227cd9532c33fa78f5250d548d87e9a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: android + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: ios + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: macos + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_clones/slack/README.md b/super_clones/slack/README.md new file mode 100644 index 0000000000..be5dfdd3bf --- /dev/null +++ b/super_clones/slack/README.md @@ -0,0 +1,2 @@ +# Slack Clone +A Super Editor clone of Slack. diff --git a/super_clones/slack/analysis_options.yaml b/super_clones/slack/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_clones/slack/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_clones/slack/android/.gitignore b/super_clones/slack/android/.gitignore new file mode 100644 index 0000000000..55afd919c6 --- /dev/null +++ b/super_clones/slack/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/super_clones/slack/android/app/build.gradle b/super_clones/slack/android/app/build.gradle new file mode 100644 index 0000000000..7fa295965b --- /dev/null +++ b/super_clones/slack/android/app/build.gradle @@ -0,0 +1,44 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.flutterbountyhunters.clones.slack.slack" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.flutterbountyhunters.clones.slack.slack" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/super_clones/slack/android/app/src/debug/AndroidManifest.xml b/super_clones/slack/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_clones/slack/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_clones/slack/android/app/src/main/AndroidManifest.xml b/super_clones/slack/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..0cf4b86dfa --- /dev/null +++ b/super_clones/slack/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/slack/android/app/src/main/kotlin/com/flutterbountyhunters/clones/slack/slack/MainActivity.kt b/super_clones/slack/android/app/src/main/kotlin/com/flutterbountyhunters/clones/slack/slack/MainActivity.kt new file mode 100644 index 0000000000..ee1ae8e0ab --- /dev/null +++ b/super_clones/slack/android/app/src/main/kotlin/com/flutterbountyhunters/clones/slack/slack/MainActivity.kt @@ -0,0 +1,5 @@ +package com.flutterbountyhunters.clones.slack.slack + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/super_clones/slack/android/app/src/main/res/drawable-v21/launch_background.xml b/super_clones/slack/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/super_clones/slack/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_clones/slack/android/app/src/main/res/drawable/launch_background.xml b/super_clones/slack/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..304732f884 --- /dev/null +++ b/super_clones/slack/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_clones/slack/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/super_clones/slack/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..db77bb4b7b Binary files /dev/null and b/super_clones/slack/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/super_clones/slack/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/super_clones/slack/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..17987b79bb Binary files /dev/null and b/super_clones/slack/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/super_clones/slack/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/super_clones/slack/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..09d4391482 Binary files /dev/null and b/super_clones/slack/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/super_clones/slack/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/super_clones/slack/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d5f1c8d34e Binary files /dev/null and b/super_clones/slack/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/super_clones/slack/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/super_clones/slack/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..4d6372eebd Binary files /dev/null and b/super_clones/slack/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/super_clones/slack/android/app/src/main/res/values-night/styles.xml b/super_clones/slack/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/super_clones/slack/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_clones/slack/android/app/src/main/res/values/styles.xml b/super_clones/slack/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..cb1ef88056 --- /dev/null +++ b/super_clones/slack/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_clones/slack/android/app/src/profile/AndroidManifest.xml b/super_clones/slack/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_clones/slack/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_clones/slack/android/build.gradle b/super_clones/slack/android/build.gradle new file mode 100644 index 0000000000..d2ffbffa4c --- /dev/null +++ b/super_clones/slack/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/super_clones/slack/android/gradle.properties b/super_clones/slack/android/gradle.properties new file mode 100644 index 0000000000..2597170821 --- /dev/null +++ b/super_clones/slack/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/super_clones/slack/android/gradle/wrapper/gradle-wrapper.properties b/super_clones/slack/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..7bb2df6ba6 --- /dev/null +++ b/super_clones/slack/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip diff --git a/super_clones/slack/android/settings.gradle b/super_clones/slack/android/settings.gradle new file mode 100644 index 0000000000..b9e43bd376 --- /dev/null +++ b/super_clones/slack/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.1.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/super_clones/slack/ios/.gitignore b/super_clones/slack/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/super_clones/slack/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/super_clones/slack/ios/Flutter/AppFrameworkInfo.plist b/super_clones/slack/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..7c56964006 --- /dev/null +++ b/super_clones/slack/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/super_clones/slack/ios/Flutter/Debug.xcconfig b/super_clones/slack/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..ec97fc6f30 --- /dev/null +++ b/super_clones/slack/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/super_clones/slack/ios/Flutter/Release.xcconfig b/super_clones/slack/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..c4855bfe20 --- /dev/null +++ b/super_clones/slack/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/super_clones/slack/ios/Podfile b/super_clones/slack/ios/Podfile new file mode 100644 index 0000000000..e549ee22f3 --- /dev/null +++ b/super_clones/slack/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# 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', '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 Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/super_clones/slack/ios/Podfile.lock b/super_clones/slack/ios/Podfile.lock new file mode 100644 index 0000000000..117f88d319 --- /dev/null +++ b/super_clones/slack/ios/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - Flutter (1.0.0) + - super_keyboard (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - super_keyboard (from `.symlinks/plugins/super_keyboard/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + super_keyboard: + :path: ".symlinks/plugins/super_keyboard/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + super_keyboard: 016de6ce9ab826f9a0b185608209d6a3b556d577 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + +PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 + +COCOAPODS: 1.16.2 diff --git a/super_clones/slack/ios/Runner.xcodeproj/project.pbxproj b/super_clones/slack/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..1837da352d --- /dev/null +++ b/super_clones/slack/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,731 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1721CF2C7B2DE47B85EB6474 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6DC663A35AEBF88A943B730E /* Pods_Runner.framework */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 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 */; }; + C9F9937B1DB4A88025402FD8 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6B40A89C7C750286C42A7AC9 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0033C3BFB6C9BB98048A6D09 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 0B5BB34EFFB0CDE0823E90A6 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 26742AF784EB82D973824347 /* 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 = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B07EFF09F750A7EDDE1F71D /* 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 = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 526E3729E5535AB39544E73C /* 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 = ""; }; + 61A75F5474A78F29C2CD30D5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 6B40A89C7C750286C42A7AC9 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6DC663A35AEBF88A943B730E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 29AB25826E6EC20B34596B90 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C9F9937B1DB4A88025402FD8 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1721CF2C7B2DE47B85EB6474 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 7247937D9FF0AB87E7690B7C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 6DC663A35AEBF88A943B730E /* Pods_Runner.framework */, + 6B40A89C7C750286C42A7AC9 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 9B93EB0E832AA60A457DF7DC /* Pods */, + 7247937D9FF0AB87E7690B7C /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + 9B93EB0E832AA60A457DF7DC /* Pods */ = { + isa = PBXGroup; + children = ( + 526E3729E5535AB39544E73C /* Pods-Runner.debug.xcconfig */, + 3B07EFF09F750A7EDDE1F71D /* Pods-Runner.release.xcconfig */, + 26742AF784EB82D973824347 /* Pods-Runner.profile.xcconfig */, + 0033C3BFB6C9BB98048A6D09 /* Pods-RunnerTests.debug.xcconfig */, + 61A75F5474A78F29C2CD30D5 /* Pods-RunnerTests.release.xcconfig */, + 0B5BB34EFFB0CDE0823E90A6 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 409694B1DF8A7D6330BC8DE0 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 29AB25826E6EC20B34596B90 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 534CE5B38134D9E27F6A4D8F /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + A30EB33DC51C43E48C43DA6C /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 409694B1DF8A7D6330BC8DE0 /* [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-RunnerTests-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; + }; + 534CE5B38134D9E27F6A4D8F /* [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; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + A30EB33DC51C43E48C43DA6C /* [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; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2X9AB296W2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.slack.slack; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0033C3BFB6C9BB98048A6D09 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.slack.slack.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 61A75F5474A78F29C2CD30D5 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.slack.slack.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0B5BB34EFFB0CDE0823E90A6 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.slack.slack.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2X9AB296W2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.slack.slack; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2X9AB296W2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.slack.slack; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/super_clones/slack/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/super_clones/slack/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/super_clones/slack/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_clones/slack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/slack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/slack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/slack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_clones/slack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_clones/slack/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_clones/slack/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_clones/slack/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..e3773d42e2 --- /dev/null +++ b/super_clones/slack/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/slack/ios/Runner.xcworkspace/contents.xcworkspacedata b/super_clones/slack/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_clones/slack/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_clones/slack/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/slack/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/slack/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/slack/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_clones/slack/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_clones/slack/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_clones/slack/ios/Runner/AppDelegate.swift b/super_clones/slack/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..626664468b --- /dev/null +++ b/super_clones/slack/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000..dc9ada4725 Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..7353c41ecf Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..6ed2d933e1 Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..4cd7b0099c Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..fe730945a0 Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..321773cd85 Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..502f463a9b Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..e9f5fea27c Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..84ac32ae7d Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..8953cba090 Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..0467bf12aa Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/super_clones/slack/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/super_clones/slack/ios/Runner/Base.lproj/LaunchScreen.storyboard b/super_clones/slack/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/super_clones/slack/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/slack/ios/Runner/Base.lproj/Main.storyboard b/super_clones/slack/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/super_clones/slack/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/slack/ios/Runner/Info.plist b/super_clones/slack/ios/Runner/Info.plist new file mode 100644 index 0000000000..65ac5dc08f --- /dev/null +++ b/super_clones/slack/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Slack + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + slack + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/super_clones/slack/ios/Runner/Runner-Bridging-Header.h b/super_clones/slack/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/super_clones/slack/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/super_clones/slack/ios/RunnerTests/RunnerTests.swift b/super_clones/slack/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..86a7c3b1b6 --- /dev/null +++ b/super_clones/slack/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_clones/slack/lib/chat_thread.dart b/super_clones/slack/lib/chat_thread.dart new file mode 100644 index 0000000000..8b0e706290 --- /dev/null +++ b/super_clones/slack/lib/chat_thread.dart @@ -0,0 +1,360 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:slack/domain/message.dart'; +import 'package:slack/domain/user.dart'; +import 'package:slack/fake_data/fake_users.dart'; +import 'package:slack/styles.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A vertical list of messages. +/// +/// Each message contains a user avatar, the message content and the user name. +class ChatThread extends StatelessWidget { + const ChatThread({ + super.key, + required this.messages, + required this.backgroundColor, + this.scrollController, + }); + + /// The messages to display. + final List messages; + + /// The background color of each message. + final Color backgroundColor; + + /// The `ScrollController` that controls the list scrolling. + final ScrollController? scrollController; + + String _formatDate(DateTime date) { + final today = DateTime.now(); + if (date.day == today.day && date.month == today.month && date.year == today.year) { + return 'Today'; + } + + // Get the full month name + final month = DateFormat('MMMM').format(date); + + final day = date.day; + final suffix = _getDaySuffix(day); + + return '$month $day$suffix'; + } + + String _getDaySuffix(int day) { + if (day >= 11 && day <= 13) { + return 'th'; + } + switch (day % 10) { + case 1: + return 'st'; + case 2: + return 'nd'; + case 3: + return 'rd'; + default: + return 'th'; + } + } + + @override + Widget build(BuildContext context) { + final reversedList = messages.reversed.toList(); + return ListView.separated( + controller: scrollController, + physics: const ClampingScrollPhysics(), + reverse: true, + itemBuilder: (context, index) => _buildMessage(reversedList, index), + separatorBuilder: (context, index) { + final message = reversedList[index]; + final previousMessage = index < reversedList.length - 1 // + ? reversedList[index + 1] + : null; + return _buildSeparator(message, previousMessage); + }, + itemCount: reversedList.length, + ); + } + + Widget _buildMessage(List messages, int index) { + if (index == messages.length - 1) { + // This is the oldest message in the list. + // Show the date above it. + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDividerForDate(messages[index].sentAt), + _MessageTile( + messages: messages, + index: index, + backgroundColor: backgroundColor, + ), + ], + ); + } + + if (index == 0) { + // This is the newest message in the list. + // Add some space below it. + return Padding( + padding: const EdgeInsets.only(bottom: 20.0), + child: _MessageTile( + messages: messages, + index: index, + backgroundColor: backgroundColor, + ), + ); + } + + return _MessageTile( + messages: messages, + index: index, + backgroundColor: backgroundColor, + ); + } + + Widget _buildSeparator(Message message, Message? nextMessage) { + if (nextMessage == null) { + // This is the last message in the list. + // Don't show the separator. + return const SizedBox(height: 0); + } + if (message.userId == nextMessage.userId && _isSameDate(message.sentAt, nextMessage.sentAt)) { + // The message is from the same user and date as the previous message. + // Don't show the separator and don't add more space between messages. + return const SizedBox(height: 0); + } + if (message.userId != nextMessage.userId && _isSameDate(message.sentAt, nextMessage.sentAt)) { + // The message is on the same day as the previous message. + // Don't show the separator. + return const SizedBox(height: 5); + } + return _buildDividerForDate(message.sentAt); + } + + Widget _buildDividerForDate(DateTime date) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 15.0), + child: Row( + children: [ + Expanded( + child: const Divider(color: dividerColor), + ), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: dividerColor, + width: 1, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, + ), + child: Text(_formatDate(date)), + ), + Expanded( + child: const Divider(color: dividerColor), + ), + ], + ), + ); + } +} + +/// Displays a single message from a list. +/// +/// Shows the user avatar, the message content and the user's name. +class _MessageTile extends StatefulWidget { + const _MessageTile({ + required this.messages, + required this.index, + required this.backgroundColor, + }); + + /// List containing all messages. + /// + /// We take the whole list instead of a single message because + /// render the message differently depending on the previous message. + final List messages; + + /// The index of the message to be displayed. + final int index; + + final Color backgroundColor; + + @override + State<_MessageTile> createState() => _MessageTileState(); +} + +class _MessageTileState extends State<_MessageTile> { + late Message _message; + late final Editor _editor; + final GlobalKey _documentLayoutKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + _syncMessageWithLatestWidget(); + } + + @override + void didUpdateWidget(_MessageTile oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.messages[widget.index] != _message) { + _editor.dispose(); + _syncMessageWithLatestWidget(); + } + } + + void _syncMessageWithLatestWidget() { + _message = widget.messages[widget.index]; + _editor = createDefaultDocumentEditor( + document: MutableDocument(nodes: _message.content.toList()), + composer: MutableDocumentComposer(), + ); + } + + @override + void dispose() { + _editor.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // The list is reversed, so the previous message is at index + 1. + final previousMessage = widget.index < widget.messages.length - 1 // + ? widget.messages[widget.index + 1] + : null; + + final user = fakeUsers.where((e) => e.id == _message.userId).first; + final sameUserAsLastMessage = previousMessage != null && previousMessage.userId == _message.userId; + final isSameDate = _isSameDate(_message.sentAt, previousMessage?.sentAt ?? DateTime.now()); + + final shouldDisplayAvatar = !sameUserAsLastMessage || !isSameDate; + + return ColoredBox( + color: widget.backgroundColor, + child: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 8.0, top: 2, bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + shouldDisplayAvatar + ? _buildUserAvatar(user.avatarUrl) + : const SizedBox( + width: 46, + height: 0, + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (shouldDisplayAvatar) // + _buildMessageHeader(user), + _buildMessageContent(), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildUserAvatar(String avatarUrl) { + return Padding( + padding: const EdgeInsets.only(top: 8.0, right: 10), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + avatarUrl, + height: 36, + width: 36, + ), + ), + ); + } + + Widget _buildMessageHeader(User user) { + return Padding( + padding: const EdgeInsets.only(left: 10.0, top: 5.0), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + user.displayName, + selectionColor: Colors.blue, + style: const TextStyle( + fontSize: 15, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 15), + _buildMessageTime(_message.sentAt), + ], + ), + ); + } + + Widget _buildMessageContent() { + return IntrinsicWidth( + child: IgnorePointer( + child: SuperEditorDryLayout( + superEditor: SuperReader( + editor: _editor, + documentLayoutKey: _documentLayoutKey, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric( + horizontal: 10.0, + vertical: 0.0, + ), + selectedTextColorStrategy: makeSelectedTextBlack, + addRulesAfter: messageListStyles, + inlineTextStyler: _inlineStyler, + ), + ), + ), + ), + ); + } + + Widget _buildMessageTime(DateTime dateTime) { + return Text( + DateFormat('h:mm a').format(dateTime), + style: const TextStyle( + fontSize: 14, + color: Colors.white54, + ), + ); + } + + TextStyle _inlineStyler(Set attributions, TextStyle existingStyle) { + TextStyle style = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.contains(stableTagComposingAttribution)) { + style = style.copyWith( + color: Colors.blue, + ); + } + + if (attributions.whereType().isNotEmpty) { + style = style.copyWith( + color: Colors.orange, + ); + } + + return style; + } +} + +bool _isSameDate(DateTime date1, DateTime date2) { + return date1.year == date2.year && date1.month == date2.month && date1.day == date2.day; +} diff --git a/super_clones/slack/lib/domain/message.dart b/super_clones/slack/lib/domain/message.dart new file mode 100644 index 0000000000..8854c7f41b --- /dev/null +++ b/super_clones/slack/lib/domain/message.dart @@ -0,0 +1,12 @@ +import 'package:super_editor/super_editor.dart'; + +class Message { + Message({ + required this.userId, + required this.sentAt, + required this.content, + }); + final String userId; + final DateTime sentAt; + final Document content; +} diff --git a/super_clones/slack/lib/domain/user.dart b/super_clones/slack/lib/domain/user.dart new file mode 100644 index 0000000000..9e9f3805cf --- /dev/null +++ b/super_clones/slack/lib/domain/user.dart @@ -0,0 +1,10 @@ +class User { + const User({ + required this.id, + required this.displayName, + required this.avatarUrl, + }); + final String id; + final String displayName; + final String avatarUrl; +} diff --git a/super_clones/slack/lib/fake_data/fake_messages.dart b/super_clones/slack/lib/fake_data/fake_messages.dart new file mode 100644 index 0000000000..50868c6df7 --- /dev/null +++ b/super_clones/slack/lib/fake_data/fake_messages.dart @@ -0,0 +1,79 @@ +import 'package:slack/domain/message.dart'; +import 'package:super_editor/super_editor.dart'; + +final fakeMessages = [ + _buildMessage( + from: "1", + dateTime: DateTime(2025, 5, 14, 16, 50), + message: + "**Hey** @DevDana! Have you had a chance to delve deep into the **Flutter** framework? I've found some interesting aspects about it.", + ), + _buildMessage( + from: "2", + dateTime: DateTime(2025, 5, 14, 16, 52), + message: + "Oh, hey @FlutterGuru! I've been dabbling a bit. It's fascinating how it manages to be so _versatile_. What caught your attention?", + ), + _buildMessage( + from: "1", + dateTime: DateTime(2025, 5, 15, 16, 54), + message: + "Here are a few things that really stand out to me:\n\n- **Hot Reload**: Super useful during development.\n- **Single Codebase**: Write once and deploy everywhere.\n- **Dart Language**: Initially a curveball but it grew on me.\n\nWhat about you?", + ), + _buildMessage( + from: "2", + dateTime: DateTime(2025, 5, 15, 16, 56), + message: + "I'm particularly intrigued by the following:\n\n1. _Rich widget catalog_: Makes UI building a breeze.\n2. _Performance_: Apps run smoothly with native-like performance.\n3. _Community support_: The community is simply amazing.", + ), + _buildMessage( + from: "1", + dateTime: DateTime(2025, 5, 15, 16, 58), + message: + "Speaking of community, have you seen the plethora of resources out there? Here are a few I recommend:\n\n- [Flutter Docs](https://flutter.dev/docs)\n- [Dart Packages](https://pub.dev/flutter)\n- [Flutter YouTube Channel](https://www.youtube.com/flutterdev)", + ), + _buildMessage( + from: "2", + dateTime: DateTime(2025, 5, 16, 17, 01), + message: + "Oh, I've been on the YouTube channel! So many tutorials and demos. Also, here's a visual representation of Flutter's architecture I found useful:\n\n![Flutter Architecture](https://docs.flutter.dev/assets/images/docs/arch-overview/archdiagram.png)", + ), + _buildMessage( + from: "1", + dateTime: DateTime(2025, 5, 16, 17, 03), + message: + "I've seen that! It's quite an insightful diagram. By the way, have you heard of the controversies around Flutter? Some say it might not be the best for every project.", + ), + _buildMessage( + from: "1", + dateTime: DateTime(2025, 5, 16, 17, 05), + message: + "For example, while it's great for most apps, heavy 3D games or highly specialized native applications might face challenges. But then again, every tool has its pros and cons, right?", + ), + _buildMessage( + from: "2", + dateTime: DateTime(2025, 5, 16, 17, 08), + message: + "Absolutely! No tool is a one-size-fits-all solution. And it's always about picking the right tool for the right job. By the way, did you see the new updates in the latest version? They've introduced some ~~deprecated widgets~~ and replaced them with more efficient ones.", + ), + _buildMessage( + from: "1", + dateTime: DateTime(2025, 5, 16, 17, 10), + message: + "I did notice that! And it shows how dedicated they are to improving and evolving. It's one of the reasons I'm so bullish on Flutter. **Onward and upward!**", + ), +]; + +Message _buildMessage({ + required String from, + required DateTime dateTime, + required String message, +}) { + return Message( + userId: from, + sentAt: dateTime, + content: deserializeMarkdownToDocument( + message, + ), + ); +} diff --git a/super_clones/slack/lib/fake_data/fake_users.dart b/super_clones/slack/lib/fake_data/fake_users.dart new file mode 100644 index 0000000000..e0e09cdda0 --- /dev/null +++ b/super_clones/slack/lib/fake_data/fake_users.dart @@ -0,0 +1,59 @@ +import 'package:slack/domain/user.dart'; + +const fakeUsers = [ + User( + id: '1', + displayName: 'FlutterGuru', + avatarUrl: 'https://i.pravatar.cc/36?img=13', + ), + User( + id: '2', + displayName: 'DevDana', + avatarUrl: 'https://i.pravatar.cc/36?img=47', + ), + User( + id: '3', + displayName: 'QuantumJester', + avatarUrl: 'https://i.pravatar.cc/36?img=56', + ), + User( + id: '4', + displayName: 'SereneSpectre', + avatarUrl: 'https://i.pravatar.cc/36?img=26', + ), + User( + id: '5', + displayName: 'MysticWhisperer', + avatarUrl: 'https://i.pravatar.cc/36?img=58', + ), + User( + id: '6', + displayName: 'NebulaNomad', + avatarUrl: 'https://i.pravatar.cc/36?img=59', + ), + User( + id: '7', + displayName: 'VelvetVortex', + avatarUrl: 'https://i.pravatar.cc/36?img=60', + ), + User( + id: '8', + displayName: 'EchoEnigma', + avatarUrl: 'https://i.pravatar.cc/36?img=61', + ), + User( + id: '9', + displayName: 'ZephyrZenith', + avatarUrl: 'https://i.pravatar.cc/36?img=62', + ), + User( + id: '10', + displayName: 'LunarLabyrinth', + avatarUrl: 'https://i.pravatar.cc/36?img=63', + ), + User( + id: '9', + displayName: 'EmberEclipse', + avatarUrl: 'https://i.pravatar.cc/36?img=64', + ), +]; diff --git a/super_clones/slack/lib/main.dart b/super_clones/slack/lib/main.dart new file mode 100644 index 0000000000..7dd46ad63d --- /dev/null +++ b/super_clones/slack/lib/main.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; +import 'package:slack/domain/message.dart'; +import 'package:slack/domain/user.dart'; +import 'package:slack/fake_data/fake_messages.dart'; +import 'package:slack/fake_data/fake_users.dart'; +import 'package:slack/chat_thread.dart'; +import 'package:slack/mobile_message_editor.dart'; +import 'package:slack/styles.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + runApp( + const MaterialApp( + home: SlackChatPage(), + ), + ); +} + +/// A Slack-like UI containing a list of messages and a message editor. +/// +/// Messages are loaded from markdown and can contain rich content, like list items, +/// headers, images, user mentions, etc. +class SlackChatPage extends StatefulWidget { + const SlackChatPage({super.key}); + + @override + State createState() => _SlackChatPageState(); +} + +class _SlackChatPageState extends State { + final ScrollController _messageListScrollController = ScrollController(); + final GlobalKey _scaffoldKey = GlobalKey(); + + final User _user = fakeUsers[1]; + final List _messages = [...fakeMessages]; + + @override + void dispose() { + _messageListScrollController.dispose(); + super.dispose(); + } + + void _onSendMessage(Document document) { + if (document.length == 1 && + document.first is ParagraphNode && + (document.first as ParagraphNode).text.toPlainText().trim().isEmpty) { + // The message editor is empty. Fizzle. + return; + } + + setState(() { + _messages.add(Message( + userId: '1', + sentAt: DateTime.now(), + content: document, + )); + }); + + // Scroll the message list to display the newly added message. + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (!mounted) { + return; + } + + _messageListScrollController.animateTo( + _messageListScrollController.position.minScrollExtent, + // ^ minScrollExtent because the list is reversed. + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _scaffoldKey, + backgroundColor: backgroundColor, + resizeToAvoidBottomInset: false, + body: SafeArea( + bottom: false, + // ^ Don't add padding at the bottom of the screen because + // we handle it ourselves. + child: DefaultTextStyle( + style: const TextStyle(color: Colors.white), + child: _buildBody(), + ), + ), + ); + } + + Widget _buildBody() { + // TODO: implement desktop screen. + return _SlackChatMobile( + scaffoldKey: _scaffoldKey, + onSendMessage: _onSendMessage, + user: _user, + messages: _messages, + scrollController: _messageListScrollController, + ); + } +} + +class _SlackChatMobile extends StatefulWidget { + const _SlackChatMobile({ + required this.scaffoldKey, + required this.onSendMessage, + required this.user, + required this.messages, + required this.scrollController, + }); + + final GlobalKey scaffoldKey; + final OnSendMessage onSendMessage; + final User user; + final List messages; + final ScrollController scrollController; + + @override + State<_SlackChatMobile> createState() => _SlackChatMobileState(); +} + +class _SlackChatMobileState extends State<_SlackChatMobile> { + final _messagePageController = MessagePageController(); + + @override + Widget build(BuildContext context) { + return MessagePageScaffold( + controller: _messagePageController, + bottomSheetMinimumTopGap: 150, + bottomSheetMinimumHeight: 120, + contentBuilder: (contentContext, bottomSpacing) { + return MediaQuery.removePadding( + context: contentContext, + removeBottom: true, + // ^ Remove bottom padding because if we don't, when the keyboard + // opens to edit the bottom sheet, this content behind the bottom + // sheet adds some phantom space at the bottom, slightly pushing + // it up for no reason. + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + bottom: bottomSpacing, + child: _buildChatThread(), + ), + ], + ), + ); + }, + bottomSheetBuilder: (messageContext) { + return _buildBottomSheet(); + }, + ); + } + + Widget _buildChatThread() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildTopSection(), + Divider(color: dividerColor), + Expanded( + child: ChatThread( + messages: widget.messages, + backgroundColor: backgroundColor, + scrollController: widget.scrollController, + ), + ), + ], + ); + } + + /// Builds the top section of the chat, containing the back button, the user avatar with the + /// user's name, and the headphones icon. + Widget _buildTopSection() { + return SizedBox( + height: 60, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + ), + onPressed: () {}, + ), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20.0), + child: Container( + height: 50, + padding: EdgeInsets.all(2.0), + decoration: BoxDecoration( + color: Color(0xFF21252A), + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: Row( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + widget.user.avatarUrl, + fit: BoxFit.cover, + height: 50, + ), + ), + Positioned( + bottom: -1, + right: -4, + child: Container( + height: 18, + width: 18, + padding: EdgeInsets.all(3.0), + decoration: BoxDecoration( + color: Color(0xFF21252A), + shape: BoxShape.circle, + ), + child: Container( + decoration: BoxDecoration( + color: Color(0xFF3CAA7B), + shape: BoxShape.circle, + ), + ), + ), + ) + ], + ), + SizedBox(width: 8), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + widget.user.displayName, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Row( + children: [ + Text( + '3 tabs', + style: TextStyle(color: Colors.white), + ), + Transform.rotate( + angle: 270 * 3.14 / 180, + child: Icon( + Icons.chevron_left, + size: 19, + color: Colors.white, + ), + ) + ], + ), + ], + ), + ], + ), + ), + ), + ), + IconButton( + icon: const Icon(Icons.headphones_outlined), + color: Colors.white, + onPressed: () {}, + ) + ], + ), + ); + } + + Widget _buildBottomSheet() { + return MobileMessageEditor( + hintText: 'Message ${widget.user.displayName}', + messagePageController: _messagePageController, + onSendMessage: widget.onSendMessage, + ); + } +} diff --git a/super_clones/slack/lib/mobile_message_editor.dart b/super_clones/slack/lib/mobile_message_editor.dart new file mode 100644 index 0000000000..1b29a3ebc4 --- /dev/null +++ b/super_clones/slack/lib/mobile_message_editor.dart @@ -0,0 +1,458 @@ +import 'package:flutter/material.dart'; +import 'package:slack/styles.dart'; +import 'package:super_editor/super_editor.dart'; + +/// An input field where users can compose rich text messages. +class MobileMessageEditor extends StatefulWidget { + const MobileMessageEditor({ + super.key, + required this.hintText, + required this.messagePageController, + required this.onSendMessage, + }); + + final String hintText; + final MessagePageController messagePageController; + final OnSendMessage onSendMessage; + + @override + State createState() => _MobileMessageEditorState(); +} + +class _MobileMessageEditorState extends State { + final _dragIndicatorKey = GlobalKey(); + final _panelFocusNode = FocusNode(); + final _editorFocusNode = FocusNode(); + + final _scrollController = ScrollController(); + + final _editorSheetKey = GlobalKey(); + late Editor _editor; + + /// The message being composed. + late MutableDocument _document; + + double _dragTouchOffsetFromIndicator = 0; + + // FIXME: Keyboard keeps closing without a bunch of global keys. Either + // document why, or figure out how to operate without all the keys. + final _editorKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _panelFocusNode.addListener(_onFocusChange); + _createDocument(); + } + + @override + void dispose() { + _panelFocusNode.removeListener(_onFocusChange); + _panelFocusNode.dispose(); + + _editorFocusNode.dispose(); + + _document.removeListener(_onDocumentChange); + super.dispose(); + } + + void _onDocumentChange(DocumentChangeLog changeLog) { + setState(() {}); + } + + void _onFocusChange() { + // Reflow the layout to show the editor when the panel is focused. + setState(() {}); + + // Request focus on the editor when the panel is focused. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_panelFocusNode.hasFocus) { + _editorFocusNode.requestFocus(); + } + }); + } + + /// Create a document with an empty paragraph. + /// + /// If [withSelection] is `true`, selection is placed at the beginning of the document, + /// and the caret is displayed. + void _createDocument({bool withSelection = false}) { + _document = MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText(''), + ), + ], + ); + + _document.addListener(_onDocumentChange); + + final composer = MutableDocumentComposer( + initialSelection: withSelection + ? DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: _document.first.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ) + : null, + ); + + _editor = Editor( + editables: {Editor.documentKey: _document, Editor.composerKey: composer}, + requestHandlers: [...defaultRequestHandlers], + reactionPipeline: List.from(defaultEditorReactions), + ); + } + + void _onVerticalDragStart(DragStartDetails details) { + _dragTouchOffsetFromIndicator = _dragFingerOffsetFromIndicator(details.globalPosition); + + widget.messagePageController.onDragStart( + details.globalPosition.dy - _dragIndicatorOffsetFromTop + _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragUpdate(DragUpdateDetails details) { + widget.messagePageController.onDragUpdate( + details.globalPosition.dy - _dragIndicatorOffsetFromTop + _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragEnd(DragEndDetails details) { + widget.messagePageController.onDragEnd(); + } + + void _onVerticalDragCancel() { + widget.messagePageController.onDragEnd(); + } + + double get _dragIndicatorOffsetFromTop { + final editorSheetBox = _editorSheetKey.currentContext!.findRenderObject(); + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero, ancestor: editorSheetBox).dy; + } + + double _dragFingerOffsetFromIndicator(Offset globalDragOffset) { + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero).dy - globalDragOffset.dy; + } + + void _sendMessage() { + widget.onSendMessage(_document); + + // Reset the editor to an empty state. + _createDocument(withSelection: true); + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + key: _editorSheetKey, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + border: Border( + top: BorderSide(width: 1, color: borderColor), + ), + ), + child: KeyboardScaffoldSafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + // ^ Avoid the bottom notch when the keyboard is closed. + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_panelFocusNode.hasFocus) // + _buildDragHandle() + else + const SizedBox(height: 10), + Flexible( + child: _buildSheetContent(), + ), + if (_panelFocusNode.hasFocus) + _SlackMobileEditorToolbar( + onSendMessage: _document.isEmpty || + (_document.nodeCount == 1 && + _document.first is TextNode && + (_document.first as TextNode).text.isEmpty) + ? null + : _sendMessage, + ), + ], + ), + ), + ), + ); + } + + Widget _buildDragHandle() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onVerticalDragStart: _onVerticalDragStart, + onVerticalDragUpdate: _onVerticalDragUpdate, + onVerticalDragEnd: _onVerticalDragEnd, + onVerticalDragCancel: _onVerticalDragCancel, + behavior: HitTestBehavior.opaque, + // ^ Opaque to handle touch events in our invisible padding. + child: Padding( + padding: const EdgeInsets.all(18), + // ^ Expand the hit area with invisible padding. + child: Container( + key: _dragIndicatorKey, + width: 48, + height: 3, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ], + ); + } + + Widget _buildSheetContent() { + return Focus( + focusNode: _panelFocusNode, + child: BottomSheetEditorHeight( + previewHeight: 42, + child: _panelFocusNode.hasFocus // + ? _buildChatEditor() + : _buildNonFocusedBottomPanel(), + ), + ); + } + + Widget _buildChatEditor() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: _ChatEditor( + key: _editorKey, + editor: _editor, + editorFocusNode: _editorFocusNode, + hintText: widget.hintText, + messagePageController: widget.messagePageController, + scrollController: _scrollController, + onSendMessage: _sendMessage, + ), + ); + } + + /// A bottom panel that is shown when the editor is not focused. + /// + /// This panel contains a hint text, an add button and the microphone button. + Widget _buildNonFocusedBottomPanel() { + return GestureDetector( + onTap: () => _panelFocusNode.requestFocus(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5.0), + child: Row( + children: [ + _AddButton(), + Expanded( + child: Text( + widget.hintText, + style: _hintTextStyleBuilder(context), + ), + ), + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.mic_none_outlined, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } +} + +/// An editor for composing chat messages. +class _ChatEditor extends StatefulWidget { + const _ChatEditor({ + super.key, + required this.editor, + required this.hintText, + required this.editorFocusNode, + required this.messagePageController, + required this.scrollController, + required this.onSendMessage, + }); + + final Editor editor; + final String hintText; + final FocusNode editorFocusNode; + final MessagePageController messagePageController; + final ScrollController scrollController; + final VoidCallback onSendMessage; + + @override + State<_ChatEditor> createState() => _ChatEditorState(); +} + +class _ChatEditorState extends State<_ChatEditor> { + final _editorKey = GlobalKey(); + + final _isImeConnected = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + widget.messagePageController.addListener(_onMessagePageControllerChange); + _isImeConnected.addListener(_onImeConnectionChange); + } + + @override + void didUpdateWidget(_ChatEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.messagePageController != oldWidget.messagePageController) { + oldWidget.messagePageController.removeListener(_onMessagePageControllerChange); + widget.messagePageController.addListener(_onMessagePageControllerChange); + } + } + + @override + void dispose() { + widget.messagePageController.removeListener(_onMessagePageControllerChange); + widget.scrollController.dispose(); + _isImeConnected.dispose(); + + super.dispose(); + } + + void _onImeConnectionChange() { + widget.messagePageController.collapsedMode = _isImeConnected.value // + ? MessagePageSheetCollapsedMode.intrinsic + : MessagePageSheetCollapsedMode.preview; + } + + void _onMessagePageControllerChange() { + if (widget.messagePageController.isPreview) { + // Always scroll the editor to the top when in preview mode. + widget.scrollController.position.jumpTo(0); + } + } + + @override + Widget build(BuildContext context) { + return SuperEditorDryLayout( + controller: widget.scrollController, + superEditor: SuperEditor( + key: _editorKey, + focusNode: widget.editorFocusNode, + editor: widget.editor, + isImeConnected: _isImeConnected, + imePolicies: SuperEditorImePolicies(), + selectionPolicies: SuperEditorSelectionPolicies(), + shrinkWrap: false, + stylesheet: messageEditorStylesheet, + componentBuilders: [ + HintComponentBuilder( + widget.hintText, + _hintTextStyleBuilder, + ), + ...defaultComponentBuilders, + ], + ), + ); + } +} + +TextStyle _hintTextStyleBuilder(context) => TextStyle( + fontSize: 14, + color: Colors.grey, + ); + +class _SlackMobileEditorToolbar extends StatefulWidget { + const _SlackMobileEditorToolbar({ + required this.onSendMessage, + }); + + final VoidCallback? onSendMessage; + + @override + State<_SlackMobileEditorToolbar> createState() => _SlackMobileEditorToolbarState(); +} + +class _SlackMobileEditorToolbarState extends State<_SlackMobileEditorToolbar> { + @override + Widget build(BuildContext context) { + return IconButtonTheme( + data: IconButtonThemeData( + style: IconButton.styleFrom( + foregroundColor: Colors.white, + disabledForegroundColor: Colors.white.withValues(alpha: 0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6.0)), + ), + ), + child: Material( + child: Container( + width: double.infinity, + color: backgroundColor, + child: Row( + children: [ + _AddButton(), + IconButton(onPressed: () {}, icon: const Icon(Icons.format_size)), + IconButton(onPressed: () {}, icon: const Icon(Icons.emoji_emotions_outlined)), + IconButton(onPressed: () {}, icon: const Icon(Icons.alternate_email)), + Spacer(), + widget.onSendMessage != null + ? CircleAvatar( + backgroundColor: Colors.green, + child: IconButton( + onPressed: widget.onSendMessage, + icon: const Icon(Icons.send), + ), + ) + : IconButton( + onPressed: null, + icon: const Icon(Icons.send), + ), + const SizedBox(width: 10), + ], + ), + ), + ), + ); + } +} + +class _AddButton extends StatelessWidget { + const _AddButton(); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + shape: CircleBorder(), + padding: EdgeInsets.all(6), + backgroundColor: Color(0xFF22242A), + ), + child: Icon( + Icons.add, + size: 24, + color: Colors.white.withValues(alpha: 0.5), + ), + ); + } +} + +typedef OnSendMessage = void Function(Document document); diff --git a/super_clones/slack/lib/styles.dart b/super_clones/slack/lib/styles.dart new file mode 100644 index 0000000000..61871677ed --- /dev/null +++ b/super_clones/slack/lib/styles.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +const backgroundColor = Color(0xFF191E22); +const dividerColor = Color(0xFF292D32); +const borderColor = Color(0xFF36383E); + +final messageListStyles = [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFFCCCCCC), + fontSize: 14, + ), + Styles.padding: const CascadingPadding.symmetric( + horizontal: 0, + vertical: 0, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + fontSize: 16, + ), + Styles.padding: const CascadingPadding.symmetric( + horizontal: 0, + vertical: 0, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + ), + }; + }, + ), +]; + +final messageEditorStylesheet = defaultStylesheet.copyWith( + addRulesAfter: [ + ...messageListStyles, + StyleRule( + BlockSelector.all.last(), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(bottom: 22), + }; + }, + ), + ], + documentPadding: const EdgeInsets.symmetric(horizontal: 10), + inlineTextStyler: (attributions, existingStyle) { + TextStyle style = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.contains(stableTagComposingAttribution)) { + // Style an user tag being composed. + style = style.copyWith(color: Colors.blue); + } + + if (attributions.whereType().isNotEmpty) { + // Style an already composed user tag. + style = style.copyWith(color: Colors.orange); + } + + return style; + }, +); + +Color makeSelectedTextBlack({required Color originalTextColor, required Color selectionHighlightColor}) { + return Colors.black; +} diff --git a/super_clones/slack/macos/.gitignore b/super_clones/slack/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/super_clones/slack/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/super_clones/slack/macos/Flutter/Flutter-Debug.xcconfig b/super_clones/slack/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..4b81f9b2d2 --- /dev/null +++ b/super_clones/slack/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_clones/slack/macos/Flutter/Flutter-Release.xcconfig b/super_clones/slack/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5caa9d1579 --- /dev/null +++ b/super_clones/slack/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_clones/slack/macos/Flutter/GeneratedPluginRegistrant.swift b/super_clones/slack/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..8236f5728c --- /dev/null +++ b/super_clones/slack/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/super_clones/slack/macos/Podfile b/super_clones/slack/macos/Podfile new file mode 100644 index 0000000000..29c8eb3294 --- /dev/null +++ b/super_clones/slack/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.14' + +# 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! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/super_clones/slack/macos/Podfile.lock b/super_clones/slack/macos/Podfile.lock new file mode 100644 index 0000000000..9f2734069e --- /dev/null +++ b/super_clones/slack/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - FlutterMacOS (1.0.0) + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + +PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82 + +COCOAPODS: 1.16.2 diff --git a/super_clones/slack/macos/Runner.xcodeproj/project.pbxproj b/super_clones/slack/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..fd39c78d63 --- /dev/null +++ b/super_clones/slack/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,804 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + 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 */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 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 */; }; + 5FBFC7DAF5D1020E47DC6E09 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F132895167E45BE42F142BA /* Pods_RunnerTests.framework */; }; + 85F1771C842A080DBED51DC6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E03558B395162E329A674B0 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 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 */ + 10BC8B89FD8E3C29BD39EFA2 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* slack.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = slack.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 38336461EA4E68E371D4229E /* 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 = ""; }; + 4E03558B395162E329A674B0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5360F9C2A670052EF56DA81A /* 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 = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8F132895167E45BE42F142BA /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9D3244ECFCA0E7BE16BAED75 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + ADBED6CBDD767F7FA2A780B5 /* 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 = ""; }; + E89C82E2C788717482E91F88 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5FBFC7DAF5D1020E47DC6E09 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 85F1771C842A080DBED51DC6 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 871BDA0FF0F235B2B2D38301 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* slack.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 871BDA0FF0F235B2B2D38301 /* Pods */ = { + isa = PBXGroup; + children = ( + ADBED6CBDD767F7FA2A780B5 /* Pods-Runner.debug.xcconfig */, + 38336461EA4E68E371D4229E /* Pods-Runner.release.xcconfig */, + 5360F9C2A670052EF56DA81A /* Pods-Runner.profile.xcconfig */, + E89C82E2C788717482E91F88 /* Pods-RunnerTests.debug.xcconfig */, + 10BC8B89FD8E3C29BD39EFA2 /* Pods-RunnerTests.release.xcconfig */, + 9D3244ECFCA0E7BE16BAED75 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4E03558B395162E329A674B0 /* Pods_Runner.framework */, + 8F132895167E45BE42F142BA /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + C6DB7E7079F9AA96958CEBD9 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + A318FD204D07F766AEBD6E69 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 9D03EE2B39FB66988648A147 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* slack.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 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 */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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; + alwaysOutOfDate = 1; + 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"; + }; + 9D03EE2B39FB66988648A147 /* [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; + }; + A318FD204D07F766AEBD6E69 /* [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; + }; + C6DB7E7079F9AA96958CEBD9 /* [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-RunnerTests-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 */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 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 = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E89C82E2C788717482E91F88 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.slack.slack.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/slack.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/slack"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 10BC8B89FD8E3C29BD39EFA2 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.slack.slack.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/slack.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/slack"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9D3244ECFCA0E7BE16BAED75 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.slack.slack.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/slack.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/slack"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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", + ); + MACOSX_DEPLOYMENT_TARGET = 11.5; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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", + ); + MACOSX_DEPLOYMENT_TARGET = 11.5; + 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", + ); + MACOSX_DEPLOYMENT_TARGET = 11.5; + 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 */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 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 */; +} diff --git a/super_clones/slack/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/slack/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/slack/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/slack/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_clones/slack/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..79751fcc97 --- /dev/null +++ b/super_clones/slack/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/slack/macos/Runner.xcworkspace/contents.xcworkspacedata b/super_clones/slack/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_clones/slack/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_clones/slack/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_clones/slack/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_clones/slack/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_clones/slack/macos/Runner/AppDelegate.swift b/super_clones/slack/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..b3c1761412 --- /dev/null +++ b/super_clones/slack/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..82b6f9d9a3 Binary files /dev/null and b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000..13b35eba55 Binary files /dev/null and b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000..0a3f5fa40f Binary files /dev/null and b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000..bdb57226d5 Binary files /dev/null and b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000000..f083318e09 Binary files /dev/null and b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000..326c0e72c9 Binary files /dev/null and b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000..2f1632cfdd Binary files /dev/null and b/super_clones/slack/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/super_clones/slack/macos/Runner/Base.lproj/MainMenu.xib b/super_clones/slack/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/super_clones/slack/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_clones/slack/macos/Runner/Configs/AppInfo.xcconfig b/super_clones/slack/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..c929a0f72a --- /dev/null +++ b/super_clones/slack/macos/Runner/Configs/AppInfo.xcconfig @@ -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 = slack + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.clones.slack.slack + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.flutterbountyhunters.clones.slack. All rights reserved. diff --git a/super_clones/slack/macos/Runner/Configs/Debug.xcconfig b/super_clones/slack/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/super_clones/slack/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_clones/slack/macos/Runner/Configs/Release.xcconfig b/super_clones/slack/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/super_clones/slack/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_clones/slack/macos/Runner/Configs/Warnings.xcconfig b/super_clones/slack/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/super_clones/slack/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/super_clones/slack/macos/Runner/DebugProfile.entitlements b/super_clones/slack/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/super_clones/slack/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/super_clones/slack/macos/Runner/Info.plist b/super_clones/slack/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/super_clones/slack/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/super_clones/slack/macos/Runner/MainFlutterWindow.swift b/super_clones/slack/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..b16c4e58b4 --- /dev/null +++ b/super_clones/slack/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,24 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + + // Remove the title bar and expand content to fill the entire window. + self.setContentSize(NSSize(width: 1400, height: 800)) + self.styleMask.update(with: StyleMask.fullSizeContentView) + self.titleVisibility = TitleVisibility.hidden + self.titlebarAppearsTransparent = true + self.backgroundColor = NSColor.white + self.toolbar = NSToolbar() + self.toolbarStyle = ToolbarStyle.unified + } +} diff --git a/super_clones/slack/macos/Runner/Release.entitlements b/super_clones/slack/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/super_clones/slack/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/super_clones/slack/macos/RunnerTests/RunnerTests.swift b/super_clones/slack/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..61f3bd1fc5 --- /dev/null +++ b/super_clones/slack/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_clones/slack/pubspec.lock b/super_clones/slack/pubspec.lock new file mode 100644 index 0000000000..df1d20f995 --- /dev/null +++ b/super_clones/slack/pubspec.lock @@ -0,0 +1,751 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + url: "https://pub.dev" + source: hosted + version: "82.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" + url: "https://pub.dev" + source: hosted + version: "7.4.5" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + attributed_text: + dependency: "direct overridden" + description: + path: "../../attributed_text" + relative: true + source: path + version: "0.4.5" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080 + url: "https://pub.dev" + source: hosted + version: "1.14.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + url: "https://pub.dev" + source: hosted + version: "2.0.28" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_test_robots: + dependency: transitive + description: + name: flutter_test_robots + sha256: "3b00f2081148bde55190997c2772f934ad2f4529cbcfc4ccfa593f8ddc117a28" + url: "https://pub.dev" + source: hosted + version: "0.0.24" + flutter_test_runners: + dependency: transitive + description: + name: flutter_test_runners + sha256: cc575117ed66a79185a26995399d7048341517a1bd21188cb43753739627832d + url: "https://pub.dev" + source: hosted + version: "0.0.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + follow_the_leader: + dependency: "direct main" + description: + name: follow_the_leader + sha256: "2e4c4ebe6b3f1942b2385904b118ba8ba117fae0b30c8c453be0b64a271dd07a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: transitive + description: + name: http + sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: "direct main" + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlord: + dependency: "direct main" + description: + name: overlord + sha256: "532f5685ac09ee805d97ce89794a4eeda41672c32955b4a835bdfce93e720a05" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + super_editor: + dependency: "direct main" + description: + path: "../../super_editor" + relative: true + source: path + version: "0.3.0-dev.38" + super_keyboard: + dependency: transitive + description: + name: super_keyboard + sha256: e3accebf33635f760efbd4d3c13f6484242a09e773ce8e711f4aa745d52b73b1 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + super_text_layout: + dependency: "direct overridden" + description: + path: "../../super_text_layout" + relative: true + source: path + version: "0.1.19" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + url: "https://pub.dev" + source: hosted + version: "6.3.16" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" + url: "https://pub.dev" + source: hosted + version: "1.1.17" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.27.0" diff --git a/super_clones/slack/pubspec.yaml b/super_clones/slack/pubspec.yaml new file mode 100644 index 0000000000..8604ed1a62 --- /dev/null +++ b/super_clones/slack/pubspec.yaml @@ -0,0 +1,77 @@ +name: slack +description: "A Flutter clone of Slack." +publish_to: "none" + +version: 1.0.0+1 + +environment: + sdk: ">=3.6.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + + super_editor: + git: + url: https://github.com/superlistapp/super_editor.git + path: super_editor + follow_the_leader: ^0.5.1 + overlord: ^0.4.2 + markdown: ^7.2.1 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + flutter_svg: ^2.1.0 + intl: ^0.20.2 + +dependency_overrides: + super_editor: + path: ../../super_editor + super_text_layout: + path: ../../super_text_layout + attributed_text: + path: ../../attributed_text + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^5.0.0 + +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: + # - assets/styles.svg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # 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/to/font-from-package diff --git a/super_editor/.gitignore b/super_editor/.gitignore index dc400f121f..b20fa275b1 100644 --- a/super_editor/.gitignore +++ b/super_editor/.gitignore @@ -42,6 +42,7 @@ build/ # Android related **/android/**/gradle-wrapper.jar **/android/.gradle +**/android/app/.cxx/ **/android/captures/ **/android/gradlew **/android/gradlew.bat diff --git a/super_editor/.run/Example - All Demos (debug).run.xml b/super_editor/.run/Example - All Demos (debug).run.xml new file mode 100644 index 0000000000..06cbc9007c --- /dev/null +++ b/super_editor/.run/Example - All Demos (debug).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Example - Chat.run.xml b/super_editor/.run/Example - Chat.run.xml new file mode 100644 index 0000000000..afb72a6399 --- /dev/null +++ b/super_editor/.run/Example - Chat.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Example - Docs (debug).run.xml b/super_editor/.run/Example - Docs (debug).run.xml new file mode 100644 index 0000000000..35df832fa7 --- /dev/null +++ b/super_editor/.run/Example - Docs (debug).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Flutter - Text Field.run.xml b/super_editor/.run/Flutter - Text Field.run.xml new file mode 100644 index 0000000000..bc96023a39 --- /dev/null +++ b/super_editor/.run/Flutter - Text Field.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Panel Behind Keyboard.run.xml b/super_editor/.run/Panel Behind Keyboard.run.xml new file mode 100644 index 0000000000..29b59a5cde --- /dev/null +++ b/super_editor/.run/Panel Behind Keyboard.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Super Editor - Chat Demo (Debug).run.xml b/super_editor/.run/Super Editor - Chat Demo (Debug).run.xml new file mode 100644 index 0000000000..d633c1a789 --- /dev/null +++ b/super_editor/.run/Super Editor - Chat Demo (Debug).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Super Editor Demo (debug).run.xml b/super_editor/.run/Super Editor Demo (debug).run.xml new file mode 100644 index 0000000000..19e93ac66d --- /dev/null +++ b/super_editor/.run/Super Editor Demo (debug).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Super Reader Demo (debug).run.xml b/super_editor/.run/Super Reader Demo (debug).run.xml new file mode 100644 index 0000000000..e03456ac99 --- /dev/null +++ b/super_editor/.run/Super Reader Demo (debug).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/.run/Super Text Field (debug).run.xml b/super_editor/.run/Super Text Field (debug).run.xml new file mode 100644 index 0000000000..e416c8f8b9 --- /dev/null +++ b/super_editor/.run/Super Text Field (debug).run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/CHANGELOG.md b/super_editor/CHANGELOG.md index 5a4d25c90f..28886f5bb6 100644 --- a/super_editor/CHANGELOG.md +++ b/super_editor/CHANGELOG.md @@ -1,3 +1,57 @@ +## [0.3.0-dev.1] - June 10, 2024 +MAJOR UPDATE: First taste of the new editor pipeline. + +This is a dev release so that you can begin to see the changes coming in the next major version. +This release comes with numerous and significant breaking changes. As we get closer to stability +for the next release, we'll add website guides to help update all of our users. + +The primary features that we've been working on since last release include: + * Undo/Redo + * A stable edit pipeline: requests > commands > change list > reactions > listeners + * Common reaction features, e.g., hash tags and user tagging + +In addition to the major feature work, we've made hundreds of little adjustments, including bugfixes. + +We expect a steady stream of dev releases from this point forward, until we reach `v0.3.0`. + +## [0.2.6] - May 28, 2023 + * FEATURE: `SuperReader` now launches URLs when tapping a link (#1151) + * FIX: `SuperEditor` now correctly handles "\n" newlines reported by Android IME deltas (#1086) + +## [0.2.6-dev.1] - May 28, 2023 +* The same as v0.2.6+1, but compatible with Flutter `master` + +## [0.2.5] - May 12, 2023: + * Add support for Dart 3 and Flutter 3.10 + +## [0.2.4-dev.1] - May 08, 2023: + * The same as v0.2.4+1, but compatible with Flutter `master` + +## [0.2.4] - May 08, 2023: + * FEATURE: `SuperEditor` includes a built-in concept of a "task" + * FEATURE: `SuperEditor` links open on tap, when in "interaction mode" + * FEATURE: `SuperEditor`, `SuperReader`, `SuperTextField` all respect `MediaQuery` text scale + * FEATURE: `SuperEditor` selection changes now include a "reason", to facilitate multi-user and server interactions + * FEATURE: `SuperEditor` supports GBoard spacebar caret movement, and other software keyboard gestures + * FEATURE: `SuperEditor` allows a selection even when the software keyboard is closed, and also lets apps open and close the keyboard at their discretion + * FEATURE: `SuperEditor` lets apps override what happens when the IME wants to a perform an action, like "done" or "newline" + * FEATURE: `SuperEditor` respects inherited `MediaQuery` `GestureSetting`s + * FEATURE: `SuperTextField` now exposes configuration for the caret style + * FEATURE: `SuperTextField` now exposes configuration for keyboard appearance + * FEATURE: `SuperDesktopTextField` now supports IME text entry, which adds support for compound characters + * FIX: `SuperEditor` don't scroll while dragging over an image + * FIX: `SuperEditor` partial improvements to iOS floating cursor display + * FIX: `SuperEditor` fix text styles when backspacing a list item into a preceding paragraph + * FIX: `SuperEditor` rebuilds layers when document layout or component layout changes, e.g., rebuilds caret when a list item animates its size + * FIX: `SuperTextField` when selection changes, don't auto-scroll if the new selection position is already visible + * FIX: `SuperTextField` popup toolbar on iOS shows the arrow pointing towards content, instead of pointing away from content + * FIX: `SuperTextField` don't change selection when two fingers move on trackpad + * FIX: `SuperTextField` handle numpad ENTER same as regular ENTER + * FIX: `SuperTextField` when user taps to stop scroll momentum, don't change the selection + +## [0.2.3-dev.1] - Nov 11, 2022: SuperReader, Bug Fixes (pre-release) + * The same as v0.2.3+1, but compatible with Flutter `master` + ## [0.2.3+1] - Nov, 2022: Pub.dev listing updates * No functional changes diff --git a/super_editor/README.md b/super_editor/README.md index e665293809..a321036bb0 100644 --- a/super_editor/README.md +++ b/super_editor/README.md @@ -33,7 +33,7 @@ These platforms probably work, but our verification on these platforms is spotty ## Run the example implementation Super Editor comes with an example implementation to showcase the core functionality. It also exposes example UI elements on how to interact with the Editor. -It currently supports MacOS and Web and will be expanded along the way, as we will support more platforms. You can run the example editor from the example directory: +The example app should build and run on any platform. You can run the example editor from the example directory: ```bash cd example @@ -52,9 +52,9 @@ class _MyAppState extends State { void build(context) { // Display a visual, editable document. // - // A SuperEditor does not include any app bar controls or popup - // controls. If you want such controls, you need to implement - // them yourself. + // SuperEditor includes default magnifiers and popover toolbars for + // iOS and Android, but does not include any popovers on desktop. + // You can add your own, if desired. // // The standard editor displays and styles headers, paragraphs, // ordered and unordered lists, images, and horizontal rules. @@ -67,7 +67,9 @@ class _MyAppState extends State { } ``` -A `SuperEditor` widget requires a `DocumentEditor`, which is a pure-Dart class that's responsible for applying changes to a `Document`. A `DocumentEditor`, in turn, requires a reference to the `Document` that it will alter. Specifically, a `DocumentEditor` requires a `MutableDocument`. +A `SuperEditor` widget requires an `Editor`, which is a pure-Dart class that's responsible for +applying changes to a `Document`. An `Editor`, in turn, requires a reference to the `Document` that +it will alter. Specifically, a `Editor` requires a `MutableDocument`. ```dart // A MutableDocument is an in-memory Document. Create the starting @@ -91,11 +93,16 @@ final myDoc = MutableDocument( ], ); -// With a MutableDocument, create a DocumentEditor, which knows how -// to apply changes to the MutableDocument. -final docEditor = DocumentEditor(document: myDoc); +// A DocumentComposer holds the user's selection. Your editor will likely want +// to observe, and possibly change the user's selection. Therefore, you should +// hold onto your own DocumentComposer and pass it to your Editor. +final myComposer = MutableDocumentComposer(); -// Next: pass the docEditor to your Editor widget. +// With a MutableDocument, create an Editor, which knows how to apply changes +// to the MutableDocument. +final editor = createDefaultDocumentEditor(document: myDoc, composer: myComposer); + +// Next: pass the editor to your SuperEditor widget. ``` The `SuperEditor` widget can be customized. @@ -103,7 +110,7 @@ The `SuperEditor` widget can be customized. ```dart class _MyAppState extends State { void build(context) { - return SuperEditor.custom( + return SuperEditor( editor: _myDocumentEditor, selectionStyle: /** INSERT CUSTOMIZATION **/ null, stylesheet: defaultStylesheet.copyWith( @@ -118,7 +125,7 @@ class _MyAppState extends State { } return { - "padding": const CascadingPadding.only(top: 24), + Styles.padding: const CascadingPadding.only(top: 24), }; }, ) @@ -134,6 +141,7 @@ class _MyAppState extends State { } ``` -If your app requires deeper customization than `SuperEditor` provides, you can construct your own version of the `SuperEditor` widget by using lower level tools within the `super_editor` package. +If your app requires deeper customization than `SuperEditor` provides, you can construct your own +version of the `SuperEditor` widget by using lower level tools within the `super_editor` package. See the wiki for more information about how to customize an editor experience. diff --git a/super_editor/README_RELEASING.md b/super_editor/README_RELEASING.md index d76d00f601..5f88f839c7 100644 --- a/super_editor/README_RELEASING.md +++ b/super_editor/README_RELEASING.md @@ -1,9 +1,31 @@ # Releasing Super Editor +Super Editor maintains two active branches and releases: `main` and `stable`. -Follow these steps to release new versions of Super Editor: +The `main` branch tracks Flutter's `master` branch. The `stable` branch tracks Flutter's `stable` branch. + +New releases should be cut for both branches at the same time (unless other factors dictate otherwise). + +Releases based on `main` should end with `-dev.X`, e.g. `v0.2.1-dev.1`. + +Releases based on `stable` should use standard versioning, e.g., `v0.2.1`. + +To release `main`: 1. Checkout the tip of the `main` branch -2. Update the `CHANGELOG.md` to describe the important changes in this release -3. Merge the `CHANGELOG.md` update into `main` and ensure you're at the new tip of `main` -4. Follow official instructions to publish a new version to pub.dev: https://dart.dev/tools/pub/publishing#publishing-your-package -5. Tag the commit that was published with the version, e.g., "v0.1.0", and push that tag to `origin` +2. Increment the build version in the pubspec, e.g., `v0.2.1-dev.1` -> `v0.2.2-dev.1` +3. Update the `CHANGELOG.md` to describe the important changes in this release. Take this opportunity to add descriptions for both the `-dev` release and the standard release. +4. Use a PR to merge the `CHANGELOG.md` and version update into `main`. +5. Pull the latest version of `main` from `origin`, which should now be ready to publish. +6. Follow official instructions to publish a new version to pub.dev: https://dart.dev/tools/pub/publishing#publishing-your-package +7. Tag the commit that was published with the version, e.g., "v0.2.2-dev.1", and push that tag to `origin` + +To release `stable`: + +1. Follow instructions above to release to `main`. +2. Create a branch off of `stable` called something like `release-stable`. +3. Cherry pick the commit from `main` with the `CHANGELOG.md` and build version change. +4. Remove the `-dev.X` from the build version in the pubspec, e.g., `v0.2.2-dev.1` => `v0.2.2`. +5. Use a PR to merge these changes into `stable`. +6. Pull the latest version of `stable` from `origin`, which should now be ready to publish. +7. Follow official instructions to publish a new version to pub.dev: https://dart.dev/tools/pub/publishing#publishing-your-package +8. Tag the commit that was published with the version, e.g., "v0.2.2", and push that tag to `origin` \ No newline at end of file diff --git a/super_editor/README_TESTS.md b/super_editor/README_TESTS.md new file mode 100644 index 0000000000..06d957ef82 --- /dev/null +++ b/super_editor/README_TESTS.md @@ -0,0 +1,44 @@ +# Running tests + +In order to run the golden tests, Docker must be installed. See docs for installing Docker Desktop: +- macOS: https://docs.docker.com/desktop/install/mac-install/ +- Linux: https://docs.docker.com/desktop/install/linux-install/ +- Windows: https://docs.docker.com/desktop/install/windows-install/ + +Activate the golden_runner with: + +```console +dart pub global activate --source path ../golden_runner +``` + +## Run golden tests: + +``` +# run all tests +goldens test + +# run a single test +goldens test --plain-name "something" + +# run all tests in a directory +goldens test test_goldens/my_dir + +# run a single test in a directory +goldens test --plain-name "something" test_goldens/my_dir +``` + +## Update golden files: + +``` +# update all goldens +goldens update + +# update all goldens in a directory +goldens update test_goldens/my_dir + +# update a single golden +goldens update --plain-name "something" + +# update a single golden in a directory +goldens update --plain-name "something" test_goldens/my_dir +``` diff --git a/super_editor/analysis_options.yaml b/super_editor/analysis_options.yaml index e307353543..ac5f3c72d6 100644 --- a/super_editor/analysis_options.yaml +++ b/super_editor/analysis_options.yaml @@ -8,3 +8,4 @@ linter: omit_local_variable_types: false use_key_in_widget_constructors: false avoid_renaming_method_parameters: false + always_use_package_imports: true diff --git a/super_editor/dart_test.yaml b/super_editor/dart_test.yaml new file mode 100644 index 0000000000..f01b9558c9 --- /dev/null +++ b/super_editor/dart_test.yaml @@ -0,0 +1,2 @@ +tags: + golden: diff --git a/super_editor/example/.gitignore b/super_editor/example/.gitignore index 0fa6b675c0..4b243bcc31 100644 --- a/super_editor/example/.gitignore +++ b/super_editor/example/.gitignore @@ -1,3 +1,8 @@ +# Typically, you're supposed to commit pubspec.lock for app projects. However, +# this file seems to change constantly and it has become a source control +# nuisance. So we're not committing it, anymore. +pubspec.lock + # Miscellaneous *.class *.log diff --git a/super_editor/example/.metadata b/super_editor/example/.metadata index ebaec8e3d5..256940c397 100644 --- a/super_editor/example/.metadata +++ b/super_editor/example/.metadata @@ -4,7 +4,27 @@ # This file should be version controlled. version: - revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851 + revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 channel: stable project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + - platform: android + create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_editor/example/analysis_options.yaml b/super_editor/example/analysis_options.yaml index f198664962..0dd449669a 100644 --- a/super_editor/example/analysis_options.yaml +++ b/super_editor/example/analysis_options.yaml @@ -1,5 +1,3 @@ -include: package:flutter_lints/flutter.yaml - linter: rules: avoid_print: false diff --git a/super_editor/example/android/app/build.gradle b/super_editor/example/android/app/build.gradle index 72e40336ef..e59cf32da1 100644 --- a/super_editor/example/android/app/build.gradle +++ b/super_editor/example/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,10 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { @@ -21,12 +23,10 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { + namespace "com.supereditor.example" compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -44,6 +44,8 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.supereditor.example" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion flutter.minSdkVersion targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() @@ -63,6 +65,3 @@ flutter { source '../..' } -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/super_editor/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/super_editor/example/android/app/src/main/kotlin/com/supereditor/example/MainActivity.kt similarity index 100% rename from super_editor/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt rename to super_editor/example/android/app/src/main/kotlin/com/supereditor/example/MainActivity.kt diff --git a/super_editor/example/android/build.gradle b/super_editor/example/android/build.gradle index 4256f91736..bc157bd1a1 100644 --- a/super_editor/example/android/build.gradle +++ b/super_editor/example/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.6.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() @@ -26,6 +13,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/super_editor/example/android/gradle.properties b/super_editor/example/android/gradle.properties index 94adc3a3f9..4d3226abc2 100644 --- a/super_editor/example/android/gradle.properties +++ b/super_editor/example/android/gradle.properties @@ -1,3 +1,3 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true -android.enableJetifier=true +android.enableJetifier=true \ No newline at end of file diff --git a/super_editor/example/android/gradle/wrapper/gradle-wrapper.properties b/super_editor/example/android/gradle/wrapper/gradle-wrapper.properties index bc6a58afdd..2a0fcc1b86 100644 --- a/super_editor/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/super_editor/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip diff --git a/super_editor/example/android/settings.gradle b/super_editor/example/android/settings.gradle index 44e62bcf06..14b8a62152 100644 --- a/super_editor/example/android/settings.gradle +++ b/super_editor/example/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.4.2" apply false + id "org.jetbrains.kotlin.android" version "1.8.10" apply false +} + +include ":app" \ No newline at end of file diff --git a/super_editor/example/ios/Flutter/AppFrameworkInfo.plist b/super_editor/example/ios/Flutter/AppFrameworkInfo.plist index 8d4492f977..1dc6cf7652 100644 --- a/super_editor/example/ios/Flutter/AppFrameworkInfo.plist +++ b/super_editor/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 13.0 diff --git a/super_editor/example/ios/Podfile b/super_editor/example/ios/Podfile index 1e8c3c90a5..e72e0b480c 100644 --- a/super_editor/example/ios/Podfile +++ b/super_editor/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '9.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/super_editor/example/ios/Podfile.lock b/super_editor/example/ios/Podfile.lock index 51d402929f..381bde951b 100644 --- a/super_editor/example/ios/Podfile.lock +++ b/super_editor/example/ios/Podfile.lock @@ -2,27 +2,40 @@ PODS: - Flutter (1.0.0) - flutter_keyboard_visibility (0.0.1): - Flutter - - path_provider_ios (0.0.1): + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - super_keyboard (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: - Flutter (from `Flutter`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - super_keyboard (from `.symlinks/plugins/super_keyboard/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) EXTERNAL SOURCES: Flutter: :path: Flutter flutter_keyboard_visibility: :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" - path_provider_ios: - :path: ".symlinks/plugins/path_provider_ios/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + super_keyboard: + :path: ".symlinks/plugins/super_keyboard/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a - flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 - path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + super_keyboard: 016de6ce9ab826f9a0b185608209d6a3b556d577 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d -PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c +PODFILE CHECKSUM: 0dbd5a87e0ace00c9610d2037ac22083a01f861d -COCOAPODS: 1.11.3 +COCOAPODS: 1.16.2 diff --git a/super_editor/example/ios/Runner.xcodeproj/project.pbxproj b/super_editor/example/ios/Runner.xcodeproj/project.pbxproj index c7ce5cb80e..ef1aa02e71 100644 --- a/super_editor/example/ios/Runner.xcodeproj/project.pbxproj +++ b/super_editor/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -155,7 +155,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -199,10 +199,12 @@ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -252,6 +254,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -339,7 +342,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -417,7 +420,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -466,7 +469,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/super_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a335..9c12df59c6 100644 --- a/super_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/super_editor/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ diff --git a/super_editor/example/ios/Runner/AppDelegate.swift b/super_editor/example/ios/Runner/AppDelegate.swift index 70693e4a8c..b636303481 100644 --- a/super_editor/example/ios/Runner/AppDelegate.swift +++ b/super_editor/example/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import UIKit import Flutter -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/super_editor/example/ios/Runner/Info.plist b/super_editor/example/ios/Runner/Info.plist index 907f329fe0..7f553465b7 100644 --- a/super_editor/example/ios/Runner/Info.plist +++ b/super_editor/example/ios/Runner/Info.plist @@ -45,5 +45,7 @@ CADisableMinimumFrameDurationOnPhone + UIApplicationSupportsIndirectInputEvents + diff --git a/super_editor/example/lib/demos/components/demo_text_with_hint.dart b/super_editor/example/lib/demos/components/demo_text_with_hint.dart index fc1e7b01fc..527590a19d 100644 --- a/super_editor/example/lib/demos/components/demo_text_with_hint.dart +++ b/super_editor/example/lib/demos/components/demo_text_with_hint.dart @@ -15,18 +15,20 @@ import 'package:super_editor/super_editor.dart'; /// Each of the above steps are demonstrated in this example. class TextWithHintDemo extends StatefulWidget { @override - _TextWithHintDemoState createState() => _TextWithHintDemoState(); + State createState() => _TextWithHintDemoState(); } class _TextWithHintDemoState extends State { late MutableDocument _doc; - late DocumentEditor _docEditor; + late MutableDocumentComposer _composer; + late Editor _docEditor; @override void initState() { super.initState(); _doc = _createDocument(); - _docEditor = DocumentEditor(document: _doc); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); } @override @@ -41,25 +43,24 @@ class _TextWithHintDemoState extends State { return MutableDocument( nodes: [ ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: ''), + id: Editor.createNodeId(), + text: AttributedText(), metadata: {'blockType': header1Attribution}, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: ''), + id: Editor.createNodeId(), + text: AttributedText(), metadata: {'blockType': header2Attribution}, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: ''), + id: Editor.createNodeId(), + text: AttributedText(), metadata: {'blockType': header3Attribution}, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', ), ), ], @@ -176,8 +177,8 @@ class HeaderWithHintComponentBuilder implements ComponentBuilder { : {}, // This is the text displayed as a hint. hintText: AttributedText( - text: 'header goes here...', - spans: AttributedSpans( + 'header goes here...', + AttributedSpans( attributions: [ const SpanMarker(attribution: italicsAttribution, offset: 12, markerType: SpanMarkerType.start), const SpanMarker(attribution: italicsAttribution, offset: 15, markerType: SpanMarkerType.end), @@ -190,6 +191,7 @@ class HeaderWithHintComponentBuilder implements ComponentBuilder { ), textSelection: textSelection, selectionColor: componentViewModel.selectionColor, + underlines: componentViewModel.createUnderlines(), ); } } diff --git a/super_editor/example/lib/demos/components/demo_unselectable_hr.dart b/super_editor/example/lib/demos/components/demo_unselectable_hr.dart index 7434143c88..64983b8095 100644 --- a/super_editor/example/lib/demos/components/demo_unselectable_hr.dart +++ b/super_editor/example/lib/demos/components/demo_unselectable_hr.dart @@ -6,18 +6,20 @@ import 'package:super_editor/super_editor.dart'; /// The user can select around the horizontal rule, but cannot select it, specifically. class UnselectableHrDemo extends StatefulWidget { @override - _UnselectableHrDemoState createState() => _UnselectableHrDemoState(); + State createState() => _UnselectableHrDemoState(); } class _UnselectableHrDemoState extends State { late MutableDocument _doc; - late DocumentEditor _docEditor; + late MutableDocumentComposer _composer; + late Editor _docEditor; @override void initState() { super.initState(); _doc = _createDocument(); - _docEditor = DocumentEditor(document: _doc); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); } @override @@ -30,18 +32,16 @@ class _UnselectableHrDemoState extends State { return MutableDocument( nodes: [ ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - "Below is a horizontal rule (HR). Normally in a SuperEditor, the user can tap to select an HR. In this case, you can't select the HR. You can only select around it. Try and find out:", + "Below is a horizontal rule (HR). Normally in a SuperEditor, the user can tap to select an HR. In this case, you can't select the HR. You can only select around it. Try and find out:", ), ), - HorizontalRuleNode(id: DocumentEditor.createNodeId()), + HorizontalRuleNode(id: Editor.createNodeId()), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - "Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.", + "Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.", ), ), ], diff --git a/super_editor/example/lib/demos/debugging/simple_deltas_input.dart b/super_editor/example/lib/demos/debugging/simple_deltas_input.dart index ea70fbdd15..1c07977aed 100644 --- a/super_editor/example/lib/demos/debugging/simple_deltas_input.dart +++ b/super_editor/example/lib/demos/debugging/simple_deltas_input.dart @@ -9,12 +9,12 @@ import 'package:super_text_layout/super_text_layout.dart'; /// all the rest of Super Editor into the picture. class SimpleDeltasInputDemo extends StatefulWidget { @override - _SimpleDeltasInputState createState() => _SimpleDeltasInputState(); + State createState() => _SimpleDeltasInputState(); } class _SimpleDeltasInputState extends State with TextInputClient, DeltaTextInputClient { final _textGlobalKey = GlobalKey(debugLabel: "text_input"); - AttributedText _text = AttributedText(text: "Hello, world!"); + AttributedText _text = AttributedText("Hello, world!"); @override void initState() { @@ -68,6 +68,7 @@ class _SimpleDeltasInputState extends State with TextInpu TextInputConfiguration _createInputConfiguration() { return TextInputConfiguration( + viewId: View.of(context).viewId, enableDeltaModel: true, inputType: TextInputType.multiline, textCapitalization: TextCapitalization.sentences, @@ -149,7 +150,7 @@ class _SimpleDeltasInputState extends State with TextInpu setState(() { _currentTextEditingValue = delta.apply(currentTextEditingValue!); - _text = AttributedText(text: _currentTextEditingValue!.text); + _text = AttributedText(_currentTextEditingValue!.text); }); } diff --git a/super_editor/example/lib/demos/demo_animated_task_height.dart b/super_editor/example/lib/demos/demo_animated_task_height.dart new file mode 100644 index 0000000000..f6ffce2dd1 --- /dev/null +++ b/super_editor/example/lib/demos/demo_animated_task_height.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// Example of a task component whose height is animated. +class AnimatedTaskHeightDemo extends StatefulWidget { + @override + State createState() => _AnimatedTaskHeightDemoState(); +} + +class _AnimatedTaskHeightDemoState extends State { + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; + + @override + void initState() { + super.initState(); + _doc = _createDocument(); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); + } + + @override + void dispose() { + _doc.dispose(); + super.dispose(); + } + + MutableDocument _createDocument() { + return MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "Below are several tasks. These tasks will animate the appearance of a subtitle depending on whether they have selection. Try and find out:", + ), + ), + ...List.generate( + 10, + (index) => TaskNode( + id: Editor.createNodeId(), + text: AttributedText("Task ${index + 1}"), + isComplete: false, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + print("Building the entire demo"); + return SuperEditor( + editor: _docEditor, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), + ), + // Add a new component builder that creates a task that animates its height, + // instead of creating the usual static kind. + componentBuilders: [ + const AnimatedTaskComponentBuilder(), + TaskComponentBuilder(_docEditor), + ...defaultComponentBuilders, + ], + ); + } +} + +/// SuperEditor [ComponentBuilder] that builds a task that is animates the appearance of +/// a subtitle depending on whether it has selection. +class AnimatedTaskComponentBuilder implements ComponentBuilder { + const AnimatedTaskComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + // This builder can work with the standard task view model, so + // we'll defer to the standard task builder. + return null; + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! TaskComponentViewModel) { + return null; + } + + return _AnimatedTaskComponent( + key: componentContext.componentKey, + viewModel: componentViewModel, + ); + } +} + +class _AnimatedTaskComponent extends StatefulWidget { + const _AnimatedTaskComponent({ + Key? key, + required this.viewModel, + // ignore: unused_element + this.showDebugPaint = false, + }) : super(key: key); + + final TaskComponentViewModel viewModel; + final bool showDebugPaint; + + @override + State<_AnimatedTaskComponent> createState() => _AnimatedTaskComponentState(); +} + +class _AnimatedTaskComponentState extends State<_AnimatedTaskComponent> + with ProxyDocumentComponent<_AnimatedTaskComponent>, ProxyTextComposable { + final _textKey = GlobalKey(); + + @override + GlobalKey> get childDocumentComponentKey => _textKey; + + @override + TextComposable get childTextComposable => childDocumentComponentKey.currentState as TextComposable; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 4), + child: Checkbox( + value: widget.viewModel.isComplete, + onChanged: (newValue) { + widget.viewModel.setComplete?.call(newValue!); + }, + ), + ), + Expanded( + child: TextComponent( + key: _textKey, + text: widget.viewModel.text, + textStyleBuilder: (attributions) { + // Show a strikethrough across the entire task if it's complete. + final style = widget.viewModel.textStyleBuilder(attributions); + return widget.viewModel.isComplete + ? style.copyWith( + decoration: style.decoration == null + ? TextDecoration.lineThrough + : TextDecoration.combine([TextDecoration.lineThrough, style.decoration!]), + ) + : style; + }, + textSelection: widget.viewModel.selection, + selectionColor: widget.viewModel.selectionColor, + highlightWhenEmpty: widget.viewModel.highlightWhenEmpty, + showDebugPaint: widget.showDebugPaint, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 56), + child: SizeChangedLayoutNotifier( + child: AnimatedSize( + key: _animatedSizeKey, + duration: const Duration(milliseconds: 100), + child: widget.viewModel.selection != null + ? const SizedBox( + height: 20, + child: Row( + children: [ + Icon(Icons.label_important_outline, size: 16), + SizedBox(width: 4), + Icon(Icons.timelapse_sharp, size: 16), + ], + ), + ) + : const SizedBox.shrink(), + ), + ), + ), + ], + ); + } + + final _animatedSizeKey = GlobalKey(); +} diff --git a/super_editor/example/lib/demos/demo_app_shortcuts.dart b/super_editor/example/lib/demos/demo_app_shortcuts.dart index 18045d6412..6d2ea73c40 100644 --- a/super_editor/example/lib/demos/demo_app_shortcuts.dart +++ b/super_editor/example/lib/demos/demo_app_shortcuts.dart @@ -4,12 +4,32 @@ import 'package:super_editor/super_editor.dart'; class AppShortcutsDemo extends StatefulWidget { @override - _AppShortcutsDemoState createState() => _AppShortcutsDemoState(); + State createState() => _AppShortcutsDemoState(); } class _AppShortcutsDemoState extends State { + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _editor; + String _message = ''; + @override + void initState() { + super.initState(); + + _doc = MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Random paragraph....'), + ), + ], + ); + _composer = MutableDocumentComposer(); + _editor = createDefaultDocumentEditor(document: _doc, composer: _composer); + } + @override Widget build(BuildContext context) { return FocusScope( @@ -32,18 +52,7 @@ class _AppShortcutsDemoState extends State { child: Column( children: [ Expanded( - child: SuperEditor( - editor: DocumentEditor( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Random paragraph....'), - ), - ], - ), - ), - ), + child: SuperEditor(editor: _editor), ), const TextField( decoration: InputDecoration( diff --git a/super_editor/example/lib/demos/demo_attributed_text.dart b/super_editor/example/lib/demos/demo_attributed_text.dart index c084566650..34c732532f 100644 --- a/super_editor/example/lib/demos/demo_attributed_text.dart +++ b/super_editor/example/lib/demos/demo_attributed_text.dart @@ -4,7 +4,7 @@ import 'package:super_text_layout/super_text_layout.dart'; class AttributedTextDemo extends StatefulWidget { @override - _AttributedTextDemoState createState() => _AttributedTextDemoState(); + State createState() => _AttributedTextDemoState(); } class _AttributedTextDemoState extends State { @@ -21,22 +21,22 @@ class _AttributedTextDemoState extends State { } void _computeStyledText() { - AttributedText _text = AttributedText( - text: 'This is some text styled with AttributedText', + AttributedText text = AttributedText( + 'This is some text styled with AttributedText', ); for (final range in _boldRanges) { - _text.addAttribution(boldAttribution, range); + text.addAttribution(boldAttribution, range); } for (final range in _italicsRanges) { - _text.addAttribution(italicsAttribution, range); + text.addAttribution(italicsAttribution, range); } for (final range in _strikethroughRanges) { - _text.addAttribution(strikethroughAttribution, range); + text.addAttribution(strikethroughAttribution, range); } setState(() { - _richText = _text.computeTextSpan((Set attributions) { + _richText = text.computeTextSpan((Set attributions) { TextStyle newStyle = const TextStyle( color: Colors.black, fontSize: 30, @@ -184,7 +184,7 @@ class TextRangeSelector extends StatefulWidget { final void Function(List)? onRangesChange; @override - _TextRangeSelectorState createState() => _TextRangeSelectorState(); + State createState() => _TextRangeSelectorState(); } class _TextRangeSelectorState extends State { @@ -239,12 +239,12 @@ class _TextRangeSelectorState extends State { rangeStart = i; } } else if (rangeStart >= 0) { - ranges.add(SpanRange(start: rangeStart, end: i - 1)); + ranges.add(SpanRange(rangeStart, i - 1)); rangeStart = -1; } } if (rangeStart >= 0) { - ranges.add(SpanRange(start: rangeStart, end: widget.cellCount - 1)); + ranges.add(SpanRange(rangeStart, widget.cellCount - 1)); } widget.onRangesChange!(ranges); @@ -265,7 +265,7 @@ class _TextRangeSelectorState extends State { height: widget.cellHeight, decoration: BoxDecoration( border: Border.all(width: 1, color: _isSelected(index) ? Colors.red : Colors.grey), - color: _isSelected(index) ? Colors.red.withOpacity(0.7) : Colors.grey.withOpacity(0.7), + color: _isSelected(index) ? Colors.red.withValues(alpha: 0.7) : Colors.grey.withValues(alpha: 0.7), ), ), ), diff --git a/super_editor/example/lib/demos/demo_document_loses_focus.dart b/super_editor/example/lib/demos/demo_document_loses_focus.dart index a1734b94b8..c588c85376 100644 --- a/super_editor/example/lib/demos/demo_document_loses_focus.dart +++ b/super_editor/example/lib/demos/demo_document_loses_focus.dart @@ -6,18 +6,20 @@ import 'package:super_editor/super_editor.dart'; /// its caret. class LoseFocusDemo extends StatefulWidget { @override - _LoseFocusDemoState createState() => _LoseFocusDemoState(); + State createState() => _LoseFocusDemoState(); } class _LoseFocusDemoState extends State { - late Document _doc; - late DocumentEditor _docEditor; + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; @override void initState() { super.initState(); _doc = _createDocument1(); - _docEditor = DocumentEditor(document: _doc as MutableDocument); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); } @override @@ -32,10 +34,17 @@ class _LoseFocusDemoState extends State { children: [ _buildTextField(), Expanded( - child: SuperEditor( - editor: _docEditor, - stylesheet: defaultStylesheet.copyWith( - documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), + child: SuperEditorDebugVisuals( + config: const SuperEditorDebugVisualsConfig( + showFocus: true, + showImeConnection: true, + ), + child: SuperEditor( + editor: _docEditor, + inputSource: TextInputSource.ime, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), + ), ), ), ), @@ -56,23 +65,20 @@ class _LoseFocusDemoState extends State { } } -Document _createDocument1() { +MutableDocument _createDocument1() { return MutableDocument( nodes: [ ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Document #1', - ), + id: Editor.createNodeId(), + text: AttributedText('Document #1'), metadata: { 'blockType': header1Attribution, }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', ), ), ], diff --git a/super_editor/example/lib/demos/demo_empty_document.dart b/super_editor/example/lib/demos/demo_empty_document.dart index 5a7ae7218f..7507eaab8d 100644 --- a/super_editor/example/lib/demos/demo_empty_document.dart +++ b/super_editor/example/lib/demos/demo_empty_document.dart @@ -11,18 +11,20 @@ import 'package:super_editor/super_editor.dart'; /// This demo can also be used to quickly hack experiments and tests. class EmptyDocumentDemo extends StatefulWidget { @override - _EmptyDocumentDemoState createState() => _EmptyDocumentDemoState(); + State createState() => _EmptyDocumentDemoState(); } class _EmptyDocumentDemoState extends State { - late Document _doc; - late DocumentEditor _docEditor; + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; @override void initState() { super.initState(); - _doc = _createDocument1(); - _docEditor = DocumentEditor(document: _doc as MutableDocument); + _doc = MutableDocument.empty("1"); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); } @override @@ -36,16 +38,7 @@ class _EmptyDocumentDemoState extends State { child: SuperEditor( editor: _docEditor, gestureMode: DocumentGestureMode.mouse, - inputSource: DocumentInputSource.keyboard, ), ); } } - -Document _createDocument1() { - return MutableDocument( - nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "")), - ], - ); -} diff --git a/super_editor/example/lib/demos/demo_markdown_serialization.dart b/super_editor/example/lib/demos/demo_markdown_serialization.dart index 8c992a7339..9c666bc5fa 100644 --- a/super_editor/example/lib/demos/demo_markdown_serialization.dart +++ b/super_editor/example/lib/demos/demo_markdown_serialization.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:super_editor/super_editor.dart'; -import 'package:super_editor_markdown/super_editor_markdown.dart'; /// Markdown serialization demo. /// @@ -11,13 +10,14 @@ import 'package:super_editor_markdown/super_editor_markdown.dart'; /// current structure of the document in the editor. class MarkdownSerializationDemo extends StatefulWidget { @override - _MarkdownSerializationDemoState createState() => _MarkdownSerializationDemoState(); + State createState() => _MarkdownSerializationDemoState(); } class _MarkdownSerializationDemoState extends State { final _docKey = GlobalKey(); - late Document _doc; - late DocumentEditor _docEditor; + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; String _markdown = ''; @@ -28,7 +28,8 @@ class _MarkdownSerializationDemoState extends State { void initState() { super.initState(); _doc = _createInitialDocument()..addListener(_onDocumentChange); - _docEditor = DocumentEditor(document: _doc as MutableDocument); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); _updateMarkdown(); } @@ -39,7 +40,7 @@ class _MarkdownSerializationDemoState extends State { super.dispose(); } - void _onDocumentChange() { + void _onDocumentChange(_) { _updateTimer?.cancel(); _updateTimer = Timer(_markdownUpdateWaitTime, _updateMarkdownAndRebuild); } @@ -64,6 +65,10 @@ class _MarkdownSerializationDemoState extends State { child: SuperEditor( key: _docKey, editor: _docEditor, + componentBuilders: [ + TaskComponentBuilder(_docEditor), + ...defaultComponentBuilders, + ], stylesheet: defaultStylesheet.copyWith( documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), ), @@ -93,95 +98,94 @@ class _MarkdownSerializationDemoState extends State { } } -Document _createInitialDocument() { +MutableDocument _createInitialDocument() { return MutableDocument( nodes: [ ImageNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), imageUrl: 'https://i.imgur.com/fSZwM7G.jpg', ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Example Document', - ), + id: Editor.createNodeId(), + text: AttributedText('Example Document'), metadata: { 'blockType': header1Attribution, }, ), - HorizontalRuleNode(id: DocumentEditor.createNodeId()), + HorizontalRuleNode(id: Editor.createNodeId()), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', ), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is an unordered list item', - ), + id: Editor.createNodeId(), + text: AttributedText('This is an unordered list item'), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is another list item', - ), + id: Editor.createNodeId(), + text: AttributedText('This is another list item'), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'This is a 3rd list item, with a link', - spans: AttributedSpans( + 'This is a 3rd list item, with a link', + AttributedSpans( attributions: [ SpanMarker( - attribution: LinkAttribution(url: Uri.https('example.org', '')), + attribution: LinkAttribution.fromUri(Uri.https('example.org', '')), offset: 30, markerType: SpanMarkerType.start), SpanMarker( - attribution: LinkAttribution(url: Uri.https('example.org', '')), + attribution: LinkAttribution.fromUri(Uri.https('example.org', '')), offset: 35, markerType: SpanMarkerType.end), ], )), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), ), ListItemNode.ordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'First thing to do', - ), + id: Editor.createNodeId(), + text: AttributedText('First thing to do'), ), ListItemNode.ordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Second thing to do', - ), + id: Editor.createNodeId(), + text: AttributedText('Second thing to do'), ), ListItemNode.ordered( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), + text: AttributedText('Third thing to do'), + ), + ParagraphNode( + id: Editor.createNodeId(), text: AttributedText( - text: 'Third thing to do', + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', ), ), - ParagraphNode( - id: DocumentEditor.createNodeId(), + TaskNode( + id: Editor.createNodeId(), + isComplete: false, + text: AttributedText( + 'This is an incomplete task', + ), + ), + TaskNode( + id: Editor.createNodeId(), + isComplete: true, text: AttributedText( - text: - 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + 'This is a completed task', ), ), ], diff --git a/super_editor/example/lib/demos/demo_paragraphs.dart b/super_editor/example/lib/demos/demo_paragraphs.dart index 5b70b815a1..2b529962cb 100644 --- a/super_editor/example/lib/demos/demo_paragraphs.dart +++ b/super_editor/example/lib/demos/demo_paragraphs.dart @@ -5,18 +5,20 @@ import 'package:super_editor/super_editor.dart'; /// Example of various paragraph configurations in an editor. class ParagraphsDemo extends StatefulWidget { @override - _ParagraphsDemoState createState() => _ParagraphsDemoState(); + State createState() => _ParagraphsDemoState(); } class _ParagraphsDemoState extends State { - late Document _doc; - late DocumentEditor _docEditor; + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; @override void initState() { super.initState(); _doc = _createInitialDocument(); - _docEditor = DocumentEditor(document: _doc as MutableDocument); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); } @override @@ -35,47 +37,38 @@ class _ParagraphsDemoState extends State { } } -Document _createInitialDocument() { +MutableDocument _createInitialDocument() { return MutableDocument( nodes: [ ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Various paragraph formations', - ), + id: Editor.createNodeId(), + text: AttributedText('Various paragraph formations'), metadata: { 'blockType': header1Attribution, }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is a short\nparagraph of text\nthat is left aligned', - ), + id: Editor.createNodeId(), + text: AttributedText('This is a short\nparagraph of text\nthat is left aligned'), ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is a short\nparagraph of text\nthat is center aligned', - ), + id: Editor.createNodeId(), + text: AttributedText('This is a short\nparagraph of text\nthat is center aligned'), metadata: { 'textAlign': 'center', }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is a short\nparagraph of text\nthat is right aligned', - ), + id: Editor.createNodeId(), + text: AttributedText('This is a short\nparagraph of text\nthat is right aligned'), metadata: { 'textAlign': 'right', }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'orem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + 'orem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', ), ), ], diff --git a/super_editor/example/lib/demos/demo_rtl.dart b/super_editor/example/lib/demos/demo_rtl.dart index d2030a808d..4163ceadcf 100644 --- a/super_editor/example/lib/demos/demo_rtl.dart +++ b/super_editor/example/lib/demos/demo_rtl.dart @@ -8,18 +8,20 @@ import 'package:super_editor/super_editor.dart'; /// package expands. class RTLDemo extends StatefulWidget { @override - _RTLDemoState createState() => _RTLDemoState(); + State createState() => _RTLDemoState(); } class _RTLDemoState extends State { late MutableDocument _doc; - late DocumentEditor _docEditor; + late MutableDocumentComposer _composer; + late Editor _docEditor; @override void initState() { super.initState(); _doc = _createInitialDocument(); - _docEditor = DocumentEditor(document: _doc); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); } @override @@ -42,54 +44,43 @@ MutableDocument _createInitialDocument() { return MutableDocument( nodes: [ ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Example Document', - ), + id: Editor.createNodeId(), + text: AttributedText('Example Document'), metadata: { 'blockType': header1Attribution, }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'مثال', - ), + id: Editor.createNodeId(), + text: AttributedText('مثال'), metadata: { 'blockType': header1Attribution, }, ), - HorizontalRuleNode(id: DocumentEditor.createNodeId()), + HorizontalRuleNode(id: Editor.createNodeId()), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'لكن لا بد أن أوضح لك أن كل هذه الأفكار المغلوطة حول استنكار النشوة وتمجيد الألم نشأت بالفعل، وسأعرض لك التفاصيل لتكتشف حقيقة وأساس تلك السعادة البشرية، فلا أحد يرفض أو يكره أو يتجنب الشعور بالسعادة، ولكن بفضل هؤلاء الأشخاص الذين لا يدركون بأن السعادة لا بد أن نستشعرها بصورة أكثر عقلانية ومنطقية فيعرضهم هذا لمواجهة الظروف الأليمة، وأكرر بأنه لا يوجد من يرغب في الحب ونيل المنال ويتلذذ بالآلام، الألم هو الألم ولكن نتيجة لظروف ما قد تكمن السعاده فيما نتحمله من كد وأسي.'), + 'لكن لا بد أن أوضح لك أن كل هذه الأفكار المغلوطة حول استنكار النشوة وتمجيد الألم نشأت بالفعل، وسأعرض لك التفاصيل لتكتشف حقيقة وأساس تلك السعادة البشرية، فلا أحد يرفض أو يكره أو يتجنب الشعور بالسعادة، ولكن بفضل هؤلاء الأشخاص الذين لا يدركون بأن السعادة لا بد أن نستشعرها بصورة أكثر عقلانية ومنطقية فيعرضهم هذا لمواجهة الظروف الأليمة، وأكرر بأنه لا يوجد من يرغب في الحب ونيل المنال ويتلذذ بالآلام، الألم هو الألم ولكن نتيجة لظروف ما قد تكمن السعاده فيما نتحمله من كد وأسي.', + ), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'فقرة رقم ١ في القائمة.', - ), + id: Editor.createNodeId(), + text: AttributedText('فقرة رقم ١ في القائمة.'), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'فقرة رقم ٢ في القائمة.', - ), + id: Editor.createNodeId(), + text: AttributedText('فقرة رقم ٢ في القائمة.'), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'فقرة رقم ٣ في القائمة.', - ), + id: Editor.createNodeId(), + text: AttributedText('فقرة رقم ٣ في القائمة.'), ), ], ); diff --git a/super_editor/example/lib/demos/demo_selectable_text.dart b/super_editor/example/lib/demos/demo_selectable_text.dart index 1713bb6bf1..44e57d2ad8 100644 --- a/super_editor/example/lib/demos/demo_selectable_text.dart +++ b/super_editor/example/lib/demos/demo_selectable_text.dart @@ -4,7 +4,7 @@ import 'package:super_text_layout/super_text_layout.dart'; /// Demo of a variety of `SelectableText` configurations. class SelectableTextDemo extends StatefulWidget { @override - _SelectableTextDemoState createState() => _SelectableTextDemoState(); + State createState() => _SelectableTextDemoState(); } class _SelectableTextDemoState extends State { diff --git a/super_editor/example/lib/demos/demo_switch_document_content.dart b/super_editor/example/lib/demos/demo_switch_document_content.dart index d024c2add8..9598391780 100644 --- a/super_editor/example/lib/demos/demo_switch_document_content.dart +++ b/super_editor/example/lib/demos/demo_switch_document_content.dart @@ -7,27 +7,35 @@ import 'package:super_editor/super_editor.dart'; /// when its content is replaced. class SwitchDocumentDemo extends StatefulWidget { @override - _SwitchDocumentDemoState createState() => _SwitchDocumentDemoState(); + State createState() => _SwitchDocumentDemoState(); } class _SwitchDocumentDemoState extends State { - late Document _doc1; - DocumentEditor? _docEditor1; + late MutableDocument _doc1; + late MutableDocumentComposer _composer1; + late Editor _docEditor1; - late Document _doc2; - DocumentEditor? _docEditor2; + late MutableDocument _doc2; + late MutableDocumentComposer _composer2; + late Editor _docEditor2; - DocumentEditor? _activeDocumentEditor; + late Document _activeDocument; + late DocumentComposer _activeComposer; + late Editor _activeDocumentEditor; @override void initState() { super.initState(); _doc1 = _createDocument1(); - _docEditor1 = DocumentEditor(document: _doc1 as MutableDocument); + _composer1 = MutableDocumentComposer(); + _docEditor1 = createDefaultDocumentEditor(document: _doc1, composer: _composer1); _doc2 = _createDocument2(); - _docEditor2 = DocumentEditor(document: _doc2 as MutableDocument); + _composer2 = MutableDocumentComposer(); + _docEditor2 = createDefaultDocumentEditor(document: _doc2, composer: _composer2); + _activeDocument = _doc1; + _activeComposer = _composer1; _activeDocumentEditor = _docEditor1; } @@ -44,7 +52,7 @@ class _SwitchDocumentDemoState extends State { _buildDocSelector(), Expanded( child: SuperEditor( - editor: _activeDocumentEditor!, + editor: _activeDocumentEditor, stylesheet: defaultStylesheet.copyWith( documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), ), @@ -62,6 +70,8 @@ class _SwitchDocumentDemoState extends State { TextButton( onPressed: () { setState(() { + _activeDocument = _doc1; + _activeComposer = _composer1; _activeDocumentEditor = _docEditor1; }); }, @@ -71,6 +81,8 @@ class _SwitchDocumentDemoState extends State { TextButton( onPressed: () { setState(() { + _activeDocument = _doc2; + _activeComposer = _composer2; _activeDocumentEditor = _docEditor2; }); }, @@ -81,46 +93,41 @@ class _SwitchDocumentDemoState extends State { } } -Document _createDocument1() { +MutableDocument _createDocument1() { return MutableDocument( nodes: [ ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Document #1', - ), + id: Editor.createNodeId(), + text: AttributedText('Document #1'), metadata: { 'blockType': header1Attribution, }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', ), ), ], ); } -Document _createDocument2() { +MutableDocument _createDocument2() { return MutableDocument( nodes: [ ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Document #2', - ), + id: Editor.createNodeId(), + text: AttributedText('Document #2'), metadata: { 'blockType': header1Attribution, }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), ), ], ); diff --git a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart index aee18d4ce0..263ad87902 100644 --- a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart +++ b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart @@ -11,65 +11,88 @@ import 'keyboard_overlay_clipper.dart'; /// no matter which platform or form factor you use. class MobileEditingAndroidDemo extends StatefulWidget { @override - _MobileEditingAndroidDemoState createState() => _MobileEditingAndroidDemoState(); + State createState() => _MobileEditingAndroidDemoState(); } class _MobileEditingAndroidDemoState extends State { final GlobalKey _docLayoutKey = GlobalKey(); - late Document _doc; - late DocumentEditor _docEditor; - late DocumentComposer _composer; + late MutableDocument _doc; + final _docChangeSignal = SignalNotifier(); + late Editor _docEditor; + late MutableDocumentComposer _composer; late CommonEditorOperations _docOps; - late SoftwareKeyboardHandler _softwareKeyboardHandler; + late MagnifierAndToolbarController _overlayController; FocusNode? _editorFocusNode; + SuperEditorImeConfiguration _imeConfiguration = const SuperEditorImeConfiguration(); @override void initState() { super.initState(); - _doc = _createInitialDocument(); - _docEditor = DocumentEditor(document: _doc as MutableDocument); - _composer = DocumentComposer()..addListener(_configureImeActionButton); + _doc = _createInitialDocument()..addListener(_onDocumentChange); + _composer = MutableDocumentComposer()..addListener(_configureImeActionButton); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); _docOps = CommonEditorOperations( editor: _docEditor, + document: _doc, composer: _composer, documentLayoutResolver: () => _docLayoutKey.currentState as DocumentLayout, ); - _softwareKeyboardHandler = SoftwareKeyboardHandler( - editor: _docEditor, - composer: _composer, - commonOps: _docOps, - ); _editorFocusNode = FocusNode(); + _overlayController = MagnifierAndToolbarController(); } @override void dispose() { _editorFocusNode!.dispose(); _composer.dispose(); + _doc.removeListener(_onDocumentChange); super.dispose(); } + void _onDocumentChange(_) => _docChangeSignal.notifyListeners(); + void _configureImeActionButton() { if (_composer.selection == null || !_composer.selection!.isCollapsed) { - _composer.imeConfiguration.value = _composer.imeConfiguration.value.copyWith( - keyboardActionButton: TextInputAction.newline, - ); + setState(() { + _imeConfiguration = _imeConfiguration.copyWith( + keyboardActionButton: TextInputAction.newline, + ); + }); return; } final selectedNode = _doc.getNodeById(_composer.selection!.extent.nodeId); if (selectedNode is ListItemNode) { - _composer.imeConfiguration.value = _composer.imeConfiguration.value.copyWith( - keyboardActionButton: TextInputAction.done, - ); + setState(() { + _imeConfiguration = _imeConfiguration.copyWith( + keyboardActionButton: TextInputAction.done, + ); + }); return; } - _composer.imeConfiguration.value = _composer.imeConfiguration.value.copyWith( - keyboardActionButton: TextInputAction.newline, - ); + setState(() { + _imeConfiguration = _imeConfiguration.copyWith( + keyboardActionButton: TextInputAction.newline, + ); + }); + } + + void _cut() { + _docOps.cut(); + _overlayController.hideToolbar(); + } + + void _copy() { + _docOps.copy(); + _overlayController.hideToolbar(); + } + + void _paste() { + _docOps.paste(); + _overlayController.hideToolbar(); } @override @@ -82,14 +105,14 @@ class _MobileEditingAndroidDemoState extends State { focusNode: _editorFocusNode, documentLayoutKey: _docLayoutKey, editor: _docEditor, - composer: _composer, - softwareKeyboardHandler: _softwareKeyboardHandler, + overlayController: _overlayController, gestureMode: DocumentGestureMode.android, - inputSource: DocumentInputSource.ime, + inputSource: TextInputSource.ime, + imeConfiguration: _imeConfiguration, androidToolbarBuilder: (_) => AndroidTextEditingFloatingToolbar( - onCutPressed: () => _docOps.cut(), - onCopyPressed: () => _docOps.copy(), - onPastePressed: () => _docOps.paste(), + onCutPressed: _cut, + onCopyPressed: _copy, + onPastePressed: _paste, onSelectAllPressed: () => _docOps.selectAll(), ), stylesheet: defaultStylesheet.copyWith( @@ -100,7 +123,7 @@ class _MobileEditingAndroidDemoState extends State { ), MultiListenableBuilder( listenables: { - _doc, + _docChangeSignal, _composer.selectionNotifier, }, builder: (_) => _buildMountedToolbar(), @@ -118,10 +141,12 @@ class _MobileEditingAndroidDemoState extends State { } return KeyboardEditingToolbar( + editor: _docEditor, document: _doc, composer: _composer, commonOps: CommonEditorOperations( editor: _docEditor, + document: _doc, composer: _composer, documentLayoutResolver: () => _docLayoutKey.currentState as DocumentLayout, ), @@ -167,7 +192,7 @@ class _MobileEditingAndroidDemoState extends State { borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), blurRadius: 5, offset: const Offset(0, 3), ), @@ -186,89 +211,70 @@ MutableDocument _createInitialDocument() { return MutableDocument( nodes: [ ImageNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), imageUrl: 'https://i.imgur.com/fSZwM7G.jpg', ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Example Document', - ), + id: Editor.createNodeId(), + text: AttributedText('Example Document'), metadata: { 'blockType': header1Attribution, }, ), - HorizontalRuleNode(id: DocumentEditor.createNodeId()), + HorizontalRuleNode(id: Editor.createNodeId()), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is a blockquote!', - ), + id: Editor.createNodeId(), + text: AttributedText('This is a blockquote!'), metadata: { 'blockType': blockquoteAttribution, }, ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is an unordered list item', - ), + id: Editor.createNodeId(), + text: AttributedText('This is an unordered list item'), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is another list item', - ), + id: Editor.createNodeId(), + text: AttributedText('This is another list item'), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is a 3rd list item', - ), + id: Editor.createNodeId(), + text: AttributedText('This is a 3rd list item'), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), ), ListItemNode.ordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'First thing to do', - ), + id: Editor.createNodeId(), + text: AttributedText('First thing to do'), ), ListItemNode.ordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Second thing to do', - ), + id: Editor.createNodeId(), + text: AttributedText('Second thing to do'), ), ListItemNode.ordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Third thing to do', - ), + id: Editor.createNodeId(), + text: AttributedText('Third thing to do'), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', ), ), ], diff --git a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart index 0c7d635659..c9e10c359d 100644 --- a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart +++ b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_ios.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:example/demos/editor_configs/keyboard_overlay_clipper.dart'; import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/super_editor.dart'; /// Mobile iOS document editing demo. @@ -10,67 +11,107 @@ import 'package:super_editor/super_editor.dart'; /// no matter which platform or form factor you use. class MobileEditingIOSDemo extends StatefulWidget { @override - _MobileEditingIOSDemoState createState() => _MobileEditingIOSDemoState(); + State createState() => _MobileEditingIOSDemoState(); } -class _MobileEditingIOSDemoState extends State { +class _MobileEditingIOSDemoState extends State with SingleTickerProviderStateMixin { final GlobalKey _docLayoutKey = GlobalKey(); - late Document _doc; - late DocumentEditor _docEditor; - late DocumentComposer _composer; + late MutableDocument _doc; + final _docChangeSignal = SignalNotifier(); + late MutableDocumentComposer _composer; + late Editor _docEditor; late CommonEditorOperations _docOps; FocusNode? _editorFocusNode; + final _selectionLayerLinks = SelectionLayerLinks(); + + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) + late MagnifierAndToolbarController _overlayController; + late final SuperEditorIosControlsController _iosEditorControlsController; + @override void initState() { super.initState(); - _doc = _createInitialDocument(); - _docEditor = DocumentEditor(document: _doc as MutableDocument); - _composer = DocumentComposer(); + _doc = _createInitialDocument()..addListener(_onDocumentChange); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); _docOps = CommonEditorOperations( editor: _docEditor, + document: _doc, composer: _composer, documentLayoutResolver: () => _docLayoutKey.currentState as DocumentLayout, ); _editorFocusNode = FocusNode(); + + // TODO: get rid of the overlay controller + _overlayController = MagnifierAndToolbarController(); + _iosEditorControlsController = SuperEditorIosControlsController( + toolbarBuilder: _buildIosToolbar, + magnifierBuilder: _buildIosMagnifier, + ); } @override void dispose() { + _iosEditorControlsController.dispose(); + _editorFocusNode!.dispose(); _composer.dispose(); + _doc.removeListener(_onDocumentChange); super.dispose(); } + void _onDocumentChange(_) => _docChangeSignal.notifyListeners(); + + void _cut() { + _docOps.cut(); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) + _overlayController.hideToolbar(); + _iosEditorControlsController.hideToolbar(); + } + + void _copy() { + _docOps.copy(); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) + _overlayController.hideToolbar(); + _iosEditorControlsController.hideToolbar(); + } + + void _paste() { + _docOps.paste(); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) + _overlayController.hideToolbar(); + _iosEditorControlsController.hideToolbar(); + } + @override Widget build(BuildContext context) { return _buildScaffold( child: Column( children: [ Expanded( - child: SuperEditor( - focusNode: _editorFocusNode, - documentLayoutKey: _docLayoutKey, - editor: _docEditor, - composer: _composer, - gestureMode: DocumentGestureMode.iOS, - inputSource: DocumentInputSource.ime, - iOSToolbarBuilder: (_) => IOSTextEditingFloatingToolbar( - onCutPressed: () => _docOps.cut(), - onCopyPressed: () => _docOps.copy(), - onPastePressed: () => _docOps.paste(), - ), - stylesheet: defaultStylesheet.copyWith( - documentPadding: const EdgeInsets.all(16), + child: SuperEditorIosControlsScope( + controller: _iosEditorControlsController, + child: SuperEditor( + focusNode: _editorFocusNode, + documentLayoutKey: _docLayoutKey, + editor: _docEditor, + gestureMode: DocumentGestureMode.iOS, + inputSource: TextInputSource.ime, + selectionLayerLinks: _selectionLayerLinks, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.all(16), + ), + overlayController: _overlayController, + createOverlayControlsClipper: (_) => const KeyboardToolbarClipper(), ), - createOverlayControlsClipper: (_) => const KeyboardToolbarClipper(), ), ), MultiListenableBuilder( listenables: { - _doc, + _docChangeSignal, _composer.selectionNotifier, }, builder: (_) => _buildMountedToolbar(), @@ -80,6 +121,30 @@ class _MobileEditingIOSDemoState extends State { ); } + Widget _buildIosToolbar(BuildContext context, Key mobileToolbarKey, LeaderLink focalPoint) { + return IOSTextEditingFloatingToolbar( + key: mobileToolbarKey, + focalPoint: focalPoint, + onCutPressed: _cut, + onCopyPressed: _copy, + onPastePressed: _paste, + ); + } + + Widget _buildIosMagnifier(BuildContext context, Key magnifierKey, LeaderLink focalPoint, bool isVisible) { + return Center( + child: IOSFollowingMagnifier.roundedRectangle( + magnifierKey: magnifierKey, + leaderLink: focalPoint, + // The magnifier is centered with the focal point. Translate it so that it sits + // above the focal point and leave a few pixels between the bottom of the magnifier + // and the focal point. This value was chosen empirically. + offsetFromFocalPoint: Offset(0, (-defaultIosMagnifierSize.height / 2) - 20), + show: isVisible, + ), + ); + } + Widget _buildMountedToolbar() { final selection = _composer.selection; @@ -88,6 +153,7 @@ class _MobileEditingIOSDemoState extends State { } return KeyboardEditingToolbar( + editor: _docEditor, document: _doc, composer: _composer, commonOps: _docOps, @@ -133,7 +199,7 @@ class _MobileEditingIOSDemoState extends State { borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), blurRadius: 5, offset: const Offset(0, 3), ), @@ -152,89 +218,70 @@ MutableDocument _createInitialDocument() { return MutableDocument( nodes: [ ImageNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), imageUrl: 'https://i.imgur.com/fSZwM7G.jpg', ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Example Document', - ), + id: Editor.createNodeId(), + text: AttributedText('Example Document'), metadata: { 'blockType': header1Attribution, }, ), - HorizontalRuleNode(id: DocumentEditor.createNodeId()), + HorizontalRuleNode(id: Editor.createNodeId()), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is a blockquote!', - ), + id: Editor.createNodeId(), + text: AttributedText('This is a blockquote!'), metadata: { 'blockType': blockquoteAttribution, }, ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is an unordered list item', - ), + id: Editor.createNodeId(), + text: AttributedText('This is an unordered list item'), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is another list item', - ), + id: Editor.createNodeId(), + text: AttributedText('This is another list item'), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'This is a 3rd list item', - ), + id: Editor.createNodeId(), + text: AttributedText('This is a 3rd list item'), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), ), ListItemNode.ordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'First thing to do', - ), + id: Editor.createNodeId(), + text: AttributedText('First thing to do'), ), ListItemNode.ordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Second thing to do', - ), + id: Editor.createNodeId(), + text: AttributedText('Second thing to do'), ), ListItemNode.ordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Third thing to do', - ), + id: Editor.createNodeId(), + text: AttributedText('Third thing to do'), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', ), ), ], diff --git a/super_editor/example/lib/demos/example_editor/_example_document.dart b/super_editor/example/lib/demos/example_editor/_example_document.dart index a57c521900..ca60f9b887 100644 --- a/super_editor/example/lib/demos/example_editor/_example_document.dart +++ b/super_editor/example/lib/demos/example_editor/_example_document.dart @@ -1,142 +1,129 @@ import 'package:flutter/rendering.dart'; import 'package:super_editor/super_editor.dart'; -import '_task.dart'; - -Document createInitialDocument() { +MutableDocument createInitialDocument() { return MutableDocument( nodes: [ ImageNode( id: "1", imageUrl: 'https://i.ibb.co/5nvRdx1/flutter-horizon.png', + expectedBitmapSize: ExpectedSize(1911, 630), metadata: const SingleColumnLayoutComponentStyles( width: double.infinity, padding: EdgeInsets.zero, ).toMetadata(), ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Welcome to Super Editor 💙 🚀', - ), + id: Editor.createNodeId(), + text: AttributedText('Welcome to Super Editor 💙 🚀'), metadata: { 'blockType': header1Attribution, }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - "Super Editor is a toolkit to help you build document editors, document layouts, text fields, and more.", + "Super Editor is a toolkit to help you build document editors, document layouts, text fields, and more.", ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Ready-made solutions 📦', - ), + id: Editor.createNodeId(), + text: AttributedText('Ready-made solutions 📦'), metadata: { 'blockType': header2Attribution, }, ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'SuperEditor is a ready-made, configurable document editing experience.', + 'SuperEditor is a ready-made, configurable document editing experience.', ), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'SuperTextField is a ready-made, configurable text field.', + 'SuperTextField is a ready-made, configurable text field.', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Quickstart 🚀', - ), + id: Editor.createNodeId(), + text: AttributedText('Quickstart 🚀'), metadata: { 'blockType': header2Attribution, }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'To get started with your own editing experience, take the following steps:'), + id: Editor.createNodeId(), + text: AttributedText('To get started with your own editing experience, take the following steps:'), ), TaskNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), isComplete: false, text: AttributedText( - text: 'Create and configure your document, for example, by creating a new MutableDocument.', + 'Create and configure your document, for example, by creating a new MutableDocument.', ), ), TaskNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), isComplete: false, text: AttributedText( - text: "If you want programmatic control over the user's selection and styles, create a DocumentComposer.", + "If you want programmatic control over the user's selection and styles, create a DocumentComposer.", ), ), TaskNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), isComplete: false, text: AttributedText( - text: - "Build a SuperEditor widget in your widget tree, configured with your Document and (optionally) your DocumentComposer.", + "Build a SuperEditor widget in your widget tree, configured with your Document and (optionally) your DocumentComposer.", ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - "Now, you're off to the races! SuperEditor renders your document, and lets you select, insert, and delete content.", + "Now, you're off to the races! SuperEditor renders your document, and lets you select, insert, and delete content.", ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Explore the toolkit 🔎', - ), + id: Editor.createNodeId(), + text: AttributedText('Explore the toolkit 🔎'), metadata: { 'blockType': header2Attribution, }, ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: "Use MutableDocument as an in-memory representation of a document.", + "Use MutableDocument as an in-memory representation of a document.", ), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: "Implement your own document data store by implementing the Document api.", + "Implement your own document data store by implementing the Document api.", ), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: "Implement your down DocumentLayout to position and size document components however you'd like.", + "Implement your down DocumentLayout to position and size document components however you'd like.", ), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: "Use SuperSelectableText to paint text with selection boxes and a caret.", + "Use SuperSelectableText to paint text with selection boxes and a caret.", ), ), ListItemNode.unordered( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'Use AttributedText to quickly and easily apply metadata spans to a string.', + 'Use AttributedText to quickly and easily apply metadata spans to a string.', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - "We hope you enjoy using Super Editor. Let us know what you're building, and please file issues for any bugs that you find.", + "We hope you enjoy using Super Editor. Let us know what you're building, and please file issues for any bugs that you find.", ), ), ], diff --git a/super_editor/example/lib/demos/example_editor/_task.dart b/super_editor/example/lib/demos/example_editor/_task.dart deleted file mode 100644 index 2aa4505369..0000000000 --- a/super_editor/example/lib/demos/example_editor/_task.dart +++ /dev/null @@ -1,259 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:super_editor/super_editor.dart'; - -/// This file includes everything needed to add the concept of a task -/// to Super Editor. This includes: -/// -/// * [TaskNode], which represents a logical task. -/// * [TaskComponentViewModel], which configures the visual appearance -/// of a task in a document. -/// * [taskStyles], which applies desired styles to tasks in a document. -/// * [TaskComponentBuilder], which creates new [TaskComponentViewModel]s -/// and [TaskComponent]s, for every [TaskNode] in the document. -/// * [TaskComponent], which renders a task in a document. - -/// [DocumentNode] that represents a task to complete. -/// -/// A task can either be complete, or incomplete. -class TaskNode extends TextNode { - TaskNode({ - required String id, - required AttributedText text, - Map? metadata, - required bool isComplete, - }) : _isComplete = isComplete, - super(id: id, text: text, metadata: metadata) { - // Set a block type so that TaskNode's can be styled by - // StyleRule's. - putMetadataValue("blockType", const NamedAttribution("task")); - } - - /// Whether this task is complete. - bool get isComplete => _isComplete; - bool _isComplete; - set isComplete(bool newValue) { - if (newValue == _isComplete) { - return; - } - - _isComplete = newValue; - notifyListeners(); - } - - @override - bool hasEquivalentContent(DocumentNode other) { - return other is TaskNode && isComplete == other.isComplete && text == other.text; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - super == other && other is TaskNode && runtimeType == other.runtimeType && isComplete == other.isComplete; - - @override - int get hashCode => super.hashCode ^ isComplete.hashCode; -} - -/// Styles all task components to apply top padding -final taskStyles = StyleRule( - const BlockSelector("task"), - (document, node) { - if (node is! TaskNode) { - return {}; - } - - return { - "padding": const CascadingPadding.only(top: 24), - }; - }, -); - -/// Builds [TaskComponentViewModel]s and [TaskComponent]s for every -/// [TaskNode] in a document. -class TaskComponentBuilder implements ComponentBuilder { - TaskComponentBuilder(this._editor); - - final DocumentEditor _editor; - - @override - TaskComponentViewModel? createViewModel(Document document, DocumentNode node) { - if (node is! TaskNode) { - return null; - } - - return TaskComponentViewModel( - nodeId: node.id, - padding: EdgeInsets.zero, - isComplete: node.isComplete, - setComplete: (bool isComplete) { - _editor.executeCommand(EditorCommandFunction((document, transaction) { - // Technically, this line could be called without the editor, but - // that's only because Super Editor hasn't fully separated document - // queries from document edits. In the future, all edits will have - // to go through a dedicated editing interface. - node.isComplete = isComplete; - })); - }, - text: node.text, - textStyleBuilder: noStyleBuilder, - selectionColor: const Color(0x00000000), - ); - } - - @override - Widget? createComponent( - SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { - if (componentViewModel is! TaskComponentViewModel) { - return null; - } - - return TaskComponent( - textKey: componentContext.componentKey, - viewModel: componentViewModel, - ); - } -} - -/// View model that configures the appearance of a [TaskComponent]. -/// -/// View models move through various style phases, which fill out -/// various properties in the view model. For example, one phase applies -/// all [StyleRule]s, and another phase configures content selection -/// and caret appearance. -class TaskComponentViewModel extends SingleColumnLayoutComponentViewModel with TextComponentViewModel { - TaskComponentViewModel({ - required String nodeId, - double? maxWidth, - required EdgeInsetsGeometry padding, - required this.isComplete, - required this.setComplete, - required this.text, - required this.textStyleBuilder, - this.textDirection = TextDirection.ltr, - this.textAlignment = TextAlign.left, - this.selection, - required this.selectionColor, - this.highlightWhenEmpty = false, - }) : super(nodeId: nodeId, maxWidth: maxWidth, padding: padding); - - bool isComplete; - void Function(bool) setComplete; - AttributedText text; - - @override - AttributionStyleBuilder textStyleBuilder; - @override - TextDirection textDirection; - @override - TextAlign textAlignment; - @override - TextSelection? selection; - @override - Color selectionColor; - @override - bool highlightWhenEmpty; - - @override - TaskComponentViewModel copy() { - return TaskComponentViewModel( - nodeId: nodeId, - maxWidth: maxWidth, - padding: padding, - isComplete: isComplete, - setComplete: setComplete, - text: text, - textStyleBuilder: textStyleBuilder, - textDirection: textDirection, - selection: selection, - selectionColor: selectionColor, - highlightWhenEmpty: highlightWhenEmpty, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - super == other && - other is TaskComponentViewModel && - runtimeType == other.runtimeType && - isComplete == other.isComplete && - setComplete == other.setComplete && - text == other.text && - textDirection == other.textDirection && - textAlignment == other.textAlignment && - selection == other.selection && - selectionColor == other.selectionColor && - highlightWhenEmpty == other.highlightWhenEmpty; - - @override - int get hashCode => - super.hashCode ^ - isComplete.hashCode ^ - setComplete.hashCode ^ - text.hashCode ^ - textDirection.hashCode ^ - textAlignment.hashCode ^ - selection.hashCode ^ - selectionColor.hashCode ^ - highlightWhenEmpty.hashCode; -} - -/// A document component that displays a complete-able task. -/// -/// This is the widget that appears in the document layout for -/// an individual task. This widget includes a checkbox that the -/// user can tap to toggle the completeness of the task. -/// -/// The appearance of a [TaskComponent] is configured by the given -/// [viewModel]. -class TaskComponent extends StatelessWidget { - const TaskComponent({ - Key? key, - required this.textKey, - required this.viewModel, - this.showDebugPaint = false, - }) : super(key: key); - - final GlobalKey textKey; - final TaskComponentViewModel viewModel; - final bool showDebugPaint; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 16, right: 4), - child: Checkbox( - value: viewModel.isComplete, - onChanged: (newValue) { - viewModel.setComplete(newValue!); - }, - ), - ), - Expanded( - child: TextComponent( - key: textKey, - text: viewModel.text, - textStyleBuilder: (attributions) { - // Show a strikethrough across the entire task if it's complete. - final style = viewModel.textStyleBuilder(attributions); - return viewModel.isComplete - ? style.copyWith( - decoration: style.decoration == null - ? TextDecoration.lineThrough - : TextDecoration.combine([TextDecoration.lineThrough, style.decoration!]), - ) - : style; - }, - textSelection: viewModel.selection, - selectionColor: viewModel.selectionColor, - highlightWhenEmpty: viewModel.highlightWhenEmpty, - showDebugPaint: showDebugPaint, - ), - ), - ], - ); - } -} diff --git a/super_editor/example/lib/demos/example_editor/_toolbar.dart b/super_editor/example/lib/demos/example_editor/_toolbar.dart index 373090365e..ab118dbf63 100644 --- a/super_editor/example/lib/demos/example_editor/_toolbar.dart +++ b/super_editor/example/lib/demos/example_editor/_toolbar.dart @@ -1,8 +1,11 @@ import 'dart:math'; +import 'package:example/demos/infrastructure/super_editor_item_selector.dart'; +import 'package:example/l10n/app_localizations.dart'; import 'package:example/logging.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; import 'package:super_editor/super_editor.dart'; /// Small toolbar that is intended to display near some selected @@ -15,19 +18,24 @@ import 'package:super_editor/super_editor.dart'; class EditorToolbar extends StatefulWidget { const EditorToolbar({ Key? key, - required this.anchor, + required this.editorViewportKey, required this.editorFocusNode, required this.editor, + required this.document, required this.composer, + required this.anchor, required this.closeToolbar, }) : super(key: key); - /// [EditorToolbar] displays itself horizontally centered and - /// slightly above the given [anchor] value. + /// [GlobalKey] that should be attached to a widget that wraps the viewport + /// area, which keeps the toolbar from appearing outside of the editor area. + final GlobalKey editorViewportKey; + + /// A [LeaderLink] that should be attached to the boundary of the toolbar + /// focal area, such as wrapped around the user's selection area. /// - /// [anchor] is a [ValueNotifier] so that [EditorToolbar] can - /// reposition itself as the [Offset] value changes. - final ValueNotifier anchor; + /// The toolbar is positioned relative to this anchor link. + final LeaderLink anchor; /// The [FocusNode] attached to the editor to which this toolbar applies. final FocusNode editorFocusNode; @@ -36,7 +44,9 @@ class EditorToolbar extends StatefulWidget { /// when the user selects a different block format for a /// text blob, e.g., paragraph, header, blockquote, or /// to apply styles to text. - final DocumentEditor? editor; + final Editor? editor; + + final Document document; /// The [composer] provides access to the user's current /// selection within the document, which dictates the @@ -49,25 +59,48 @@ class EditorToolbar extends StatefulWidget { final VoidCallback closeToolbar; @override - _EditorToolbarState createState() => _EditorToolbarState(); + State createState() => _EditorToolbarState(); } class _EditorToolbarState extends State { + late final FollowerAligner _toolbarAligner; + late FollowerBoundary _screenBoundary; + bool _showUrlField = false; + late FocusNode _popoverFocusNode; late FocusNode _urlFocusNode; - AttributedTextEditingController? _urlController; + ImeAttributedTextEditingController? _urlController; @override void initState() { super.initState(); + + _toolbarAligner = CupertinoPopoverToolbarAligner(); + + _popoverFocusNode = FocusNode(); + _urlFocusNode = FocusNode(); - _urlController = SingleLineAttributedTextEditingController(_applyLink); + _urlController = + ImeAttributedTextEditingController(controller: SingleLineAttributedTextEditingController(_applyLink)) // + ..onPerformActionPressed = _onPerformAction + ..text = AttributedText("https://"); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _screenBoundary = WidgetFollowerBoundary( + boundaryKey: widget.editorViewportKey, + ); } @override void dispose() { _urlFocusNode.dispose(); _urlController!.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); } @@ -81,7 +114,7 @@ class _EditorToolbarState extends State { return false; } - final selectedNode = widget.editor!.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.document.getNodeById(selection.extent.nodeId); return selectedNode is ParagraphNode || selectedNode is ListItemNode; } @@ -89,7 +122,7 @@ class _EditorToolbarState extends State { /// /// Throws an exception if the currently selected node is not a text node. _TextType _getCurrentTextType() { - final selectedNode = widget.editor!.document.getNodeById(widget.composer.selection!.extent.nodeId); + final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); if (selectedNode is ParagraphNode) { final type = selectedNode.getMetadataValue('blockType'); @@ -107,7 +140,7 @@ class _EditorToolbarState extends State { } else if (selectedNode is ListItemNode) { return selectedNode.type == ListItemType.ordered ? _TextType.orderedListItem : _TextType.unorderedListItem; } else { - throw Exception('Invalid node type: $selectedNode'); + throw Exception('Alignment does not apply to node of type: $selectedNode'); } } @@ -115,7 +148,7 @@ class _EditorToolbarState extends State { /// /// Throws an exception if the currently selected node is not a text node. TextAlign _getCurrentTextAlignment() { - final selectedNode = widget.editor!.document.getNodeById(widget.composer.selection!.extent.nodeId); + final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); if (selectedNode is ParagraphNode) { final align = selectedNode.getMetadataValue('textAlign'); switch (align) { @@ -131,7 +164,7 @@ class _EditorToolbarState extends State { return TextAlign.left; } } else { - throw Exception('Alignment does not apply to node of type: $selectedNode'); + throw Exception('Invalid node type: $selectedNode'); } } @@ -143,7 +176,7 @@ class _EditorToolbarState extends State { return false; } - final selectedNode = widget.editor!.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.document.getNodeById(selection.extent.nodeId); return selectedNode is ParagraphNode; } @@ -161,33 +194,36 @@ class _EditorToolbarState extends State { } if (_isListItem(existingTextType) && _isListItem(newType)) { - widget.editor!.executeCommand( - ChangeListItemTypeCommand( + widget.editor!.execute([ + ChangeListItemTypeRequest( nodeId: widget.composer.selection!.extent.nodeId, newType: newType == _TextType.orderedListItem ? ListItemType.ordered : ListItemType.unordered, ), - ); + ]); } else if (_isListItem(existingTextType) && !_isListItem(newType)) { - widget.editor!.executeCommand( - ConvertListItemToParagraphCommand( + widget.editor!.execute([ + ConvertListItemToParagraphRequest( nodeId: widget.composer.selection!.extent.nodeId, paragraphMetadata: { 'blockType': _getBlockTypeAttribution(newType), }, ), - ); + ]); } else if (!_isListItem(existingTextType) && _isListItem(newType)) { - widget.editor!.executeCommand( - ConvertParagraphToListItemCommand( + widget.editor!.execute([ + ConvertParagraphToListItemRequest( nodeId: widget.composer.selection!.extent.nodeId, type: newType == _TextType.orderedListItem ? ListItemType.ordered : ListItemType.unordered, ), - ); + ]); } else { // Apply a new block type to an existing paragraph node. - final existingNode = - widget.editor!.document.getNodeById(widget.composer.selection!.extent.nodeId)! as ParagraphNode; - existingNode.putMetadataValue('blockType', _getBlockTypeAttribution(newType)); + widget.editor!.execute([ + ChangeParagraphBlockTypeRequest( + nodeId: widget.composer.selection!.extent.nodeId, + blockType: _getBlockTypeAttribution(newType), + ), + ]); } } @@ -217,32 +253,52 @@ class _EditorToolbarState extends State { /// Toggles bold styling for the current selected text. void _toggleBold() { - widget.editor!.executeCommand( - ToggleTextAttributionsCommand( - documentSelection: widget.composer.selection!, + widget.editor!.execute([ + ToggleTextAttributionsRequest( + documentRange: widget.composer.selection!, attributions: {boldAttribution}, ), - ); + ]); } /// Toggles italic styling for the current selected text. void _toggleItalics() { - widget.editor!.executeCommand( - ToggleTextAttributionsCommand( - documentSelection: widget.composer.selection!, + widget.editor!.execute([ + ToggleTextAttributionsRequest( + documentRange: widget.composer.selection!, attributions: {italicsAttribution}, ), - ); + ]); } /// Toggles strikethrough styling for the current selected text. void _toggleStrikethrough() { - widget.editor!.executeCommand( - ToggleTextAttributionsCommand( - documentSelection: widget.composer.selection!, + widget.editor!.execute([ + ToggleTextAttributionsRequest( + documentRange: widget.composer.selection!, attributions: {strikethroughAttribution}, ), - ); + ]); + } + + /// Toggles superscript styling for the current selected text. + void _toggleSuperscript() { + widget.editor!.execute([ + ToggleTextAttributionsRequest( + documentRange: widget.composer.selection!, + attributions: {superscriptAttribution}, + ), + ]); + } + + /// Toggles subscript styling for the current selected text. + void _toggleSubscript() { + widget.editor!.execute([ + ToggleTextAttributionsRequest( + documentRange: widget.composer.selection!, + attributions: {subscriptAttribution}, + ), + ]); } /// Returns true if the current text selection includes part @@ -266,9 +322,9 @@ class _EditorToolbarState extends State { final extentOffset = (selection.extent.nodePosition as TextPosition).offset; final selectionStart = min(baseOffset, extentOffset); final selectionEnd = max(baseOffset, extentOffset); - final selectionRange = SpanRange(start: selectionStart, end: selectionEnd - 1); + final selectionRange = SpanRange(selectionStart, selectionEnd - 1); - final textNode = widget.editor!.document.getNodeById(selection.extent.nodeId) as TextNode; + final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; final text = textNode.text; final overlappingLinkAttributions = text.getAttributionSpansInRange( @@ -287,9 +343,9 @@ class _EditorToolbarState extends State { final extentOffset = (selection.extent.nodePosition as TextPosition).offset; final selectionStart = min(baseOffset, extentOffset); final selectionEnd = max(baseOffset, extentOffset); - final selectionRange = SpanRange(start: selectionStart, end: selectionEnd - 1); + final selectionRange = SpanRange(selectionStart, selectionEnd - 1); - final textNode = widget.editor!.document.getNodeById(selection.extent.nodeId) as TextNode; + final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; final text = textNode.text; final overlappingLinkAttributions = text.getAttributionSpansInRange( @@ -318,7 +374,7 @@ class _EditorToolbarState extends State { // the entire link attribution. text.removeAttribution( overlappingLinkSpan.attribution, - SpanRange(start: overlappingLinkSpan.start, end: overlappingLinkSpan.end), + overlappingLinkSpan.range, ); } } else { @@ -333,7 +389,7 @@ class _EditorToolbarState extends State { /// Takes the text from the [urlController] and applies it as a link /// attribution to the currently selected text. void _applyLink() { - final url = _urlController!.text.text; + final url = _urlController!.text.toPlainText(includePlaceholders: false); final selection = widget.composer.selection!; final baseOffset = (selection.base.nodePosition as TextPosition).offset; @@ -342,19 +398,31 @@ class _EditorToolbarState extends State { final selectionEnd = max(baseOffset, extentOffset); final selectionRange = TextRange(start: selectionStart, end: selectionEnd - 1); - final textNode = widget.editor!.document.getNodeById(selection.extent.nodeId) as TextNode; + final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; final text = textNode.text; final trimmedRange = _trimTextRangeWhitespace(text, selectionRange); - final linkAttribution = LinkAttribution(url: Uri.parse(url)); - text.addAttribution( - linkAttribution, - trimmedRange, - ); + final linkAttribution = LinkAttribution.fromUri(Uri.parse(url)); + + widget.editor!.execute([ + AddTextAttributionsRequest( + documentRange: DocumentRange( + start: DocumentPosition( + nodeId: textNode.id, + nodePosition: TextNodePosition(offset: trimmedRange.start), + ), + end: DocumentPosition( + nodeId: textNode.id, + nodePosition: TextNodePosition(offset: trimmedRange.end), + ), + ), + attributions: {linkAttribution}, + ), + ]); // Clear the field and hide the URL bar - _urlController!.clear(); + _urlController!.clearTextAndSelection(); setState(() { _showUrlField = false; _urlFocusNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); @@ -369,14 +437,16 @@ class _EditorToolbarState extends State { int startOffset = range.start; int endOffset = range.end; - while (startOffset < range.end && text.text[startOffset] == ' ') { + final plainText = text.toPlainText(); + while (startOffset < range.end && plainText[startOffset] == ' ') { startOffset += 1; } - while (endOffset > startOffset && text.text[endOffset] == ' ') { + while (endOffset > startOffset && plainText[endOffset] == ' ') { endOffset -= 1; } - return SpanRange(start: startOffset, end: endOffset); + // Add 1 to the end offset because SpanRange treats the end offset to be exclusive. + return SpanRange(startOffset, endOffset + 1); } /// Changes the alignment of the current selected text node @@ -385,26 +455,13 @@ class _EditorToolbarState extends State { if (newAlignment == null) { return; } - String? newAlignmentValue; - switch (newAlignment) { - case TextAlign.left: - case TextAlign.start: - newAlignmentValue = 'left'; - break; - case TextAlign.center: - newAlignmentValue = 'center'; - break; - case TextAlign.right: - case TextAlign.end: - newAlignmentValue = 'right'; - break; - case TextAlign.justify: - newAlignmentValue = 'justify'; - break; - } - final selectedNode = widget.editor!.document.getNodeById(widget.composer.selection!.extent.nodeId) as ParagraphNode; - selectedNode.putMetadataValue('textAlign', newAlignmentValue); + widget.editor!.execute([ + ChangeParagraphAlignmentRequest( + nodeId: widget.composer.selection!.extent.nodeId, + alignment: newAlignment, + ), + ]); } /// Returns the localized name for the given [_TextType], e.g., @@ -428,162 +485,209 @@ class _EditorToolbarState extends State { } } + void _onPerformAction(TextInputAction action) { + if (action == TextInputAction.done) { + _applyLink(); + } + } + + /// Called when the user selects a block type on the toolbar. + void _onBlockTypeSelected(SuperEditorDemoTextItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _convertTextToNewType(_TextType.values // + .where((e) => e.name == selectedItem.id) + .first); + }); + } + } + + /// Called when the user selects an alignment on the toolbar. + void _onAlignmentSelected(SuperEditorDemoIconItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _changeAlignment(TextAlign.values.firstWhere((e) => e.name == selectedItem.id)); + }); + } + } + @override Widget build(BuildContext context) { - return Stack( + return BuildInOrder( children: [ - // Conditionally display the URL text field below - // the standard toolbar. - if (_showUrlField) - Positioned( - left: widget.anchor.value!.dx, - top: widget.anchor.value!.dy, - child: FractionalTranslation( - translation: const Offset(-0.5, 0.0), - child: _buildUrlField(), - ), - ), - _PositionedToolbar( - anchor: widget.anchor, - composer: widget.composer, - child: ValueListenableBuilder( - valueListenable: widget.composer.selectionNotifier, - builder: (context, selection, child) { - appLog.fine("Building toolbar. Selection: $selection"); - if (selection == null) { - return const SizedBox(); - } - if (selection.extent.nodePosition is! TextPosition) { - // The user selected non-text content. This toolbar is probably - // about to disappear. Until then, build nothing, because the - // toolbar needs to inspect selected text to build correctly. - return const SizedBox(); - } - - return _buildToolbar(); - }, + FollowerFadeOutBeyondBoundary( + link: widget.anchor, + boundary: _screenBoundary, + child: Follower.withAligner( + link: widget.anchor, + aligner: _toolbarAligner, + boundary: _screenBoundary, + showWhenUnlinked: false, + child: _buildToolbars(), ), ), ], ); } + Widget _buildToolbars() { + return SuperEditorPopover( + popoverFocusNode: _popoverFocusNode, + editorFocusNode: widget.editorFocusNode, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildToolbar(), + if (_showUrlField) ...[ + const SizedBox(height: 8), + _buildUrlField(), + ], + ], + ), + ); + } + Widget _buildToolbar() { - return Material( - shape: const StadiumBorder(), - elevation: 5, - clipBehavior: Clip.hardEdge, - child: SizedBox( - height: 40, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Only allow the user to select a new type of text node if - // the currently selected node can be converted. - if (_isConvertibleNode()) ...[ - Tooltip( - message: AppLocalizations.of(context)!.labelTextBlockType, - child: DropdownButton<_TextType>( - value: _getCurrentTextType(), - items: _TextType.values - .map((textType) => DropdownMenuItem<_TextType>( - value: textType, - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Text(_getTextTypeName(textType)), - ), - )) - .toList(), - icon: const Icon(Icons.arrow_drop_down), - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - underline: const SizedBox(), - elevation: 0, - itemHeight: 48, - onChanged: _convertTextToNewType, + return IntrinsicWidth( + child: Material( + shape: const StadiumBorder(), + elevation: 5, + clipBehavior: Clip.hardEdge, + child: SizedBox( + height: 40, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Only allow the user to select a new type of text node if + // the currently selected node can be converted. + if (_isConvertibleNode()) ...[ + Tooltip( + message: AppLocalizations.of(context)!.labelTextBlockType, + child: _buildBlockTypeSelector(), + ), + _buildVerticalDivider(), + ], + Center( + child: IconButton( + onPressed: _toggleBold, + icon: const Icon(Icons.format_bold), + splashRadius: 16, + tooltip: AppLocalizations.of(context)!.labelBold, ), ), - _buildVerticalDivider(), - ], - Center( - child: IconButton( - onPressed: _toggleBold, - icon: const Icon(Icons.format_bold), - splashRadius: 16, - tooltip: AppLocalizations.of(context)!.labelBold, + Center( + child: IconButton( + onPressed: _toggleItalics, + icon: const Icon(Icons.format_italic), + splashRadius: 16, + tooltip: AppLocalizations.of(context)!.labelItalics, + ), ), - ), - Center( - child: IconButton( - onPressed: _toggleItalics, - icon: const Icon(Icons.format_italic), - splashRadius: 16, - tooltip: AppLocalizations.of(context)!.labelItalics, + Center( + child: IconButton( + onPressed: _toggleStrikethrough, + icon: const Icon(Icons.strikethrough_s), + splashRadius: 16, + tooltip: AppLocalizations.of(context)!.labelStrikethrough, + ), ), - ), - Center( - child: IconButton( - onPressed: _toggleStrikethrough, - icon: const Icon(Icons.strikethrough_s), - splashRadius: 16, - tooltip: AppLocalizations.of(context)!.labelStrikethrough, + Center( + child: IconButton( + onPressed: _toggleSuperscript, + icon: const Icon(Icons.superscript), + splashRadius: 16, + tooltip: AppLocalizations.of(context)!.labelSuperscript, + ), ), - ), - Center( - child: IconButton( - onPressed: _areMultipleLinksSelected() ? null : _onLinkPressed, - icon: const Icon(Icons.link), - color: _isSingleLinkSelected() ? const Color(0xFF007AFF) : IconTheme.of(context).color, - splashRadius: 16, - tooltip: AppLocalizations.of(context)!.labelLink, + Center( + child: IconButton( + onPressed: _toggleSubscript, + icon: const Icon(Icons.subscript), + splashRadius: 16, + tooltip: AppLocalizations.of(context)!.labelSubscript, + ), ), - ), - // Only display alignment controls if the currently selected text - // node respects alignment. List items, for example, do not. - if (_isTextAlignable()) ...[ + Center( + child: IconButton( + onPressed: _areMultipleLinksSelected() ? null : _onLinkPressed, + icon: const Icon(Icons.link), + color: _isSingleLinkSelected() ? const Color(0xFF007AFF) : IconTheme.of(context).color, + splashRadius: 16, + tooltip: AppLocalizations.of(context)!.labelLink, + ), + ), + // Only display alignment controls if the currently selected text + // node respects alignment. List items, for example, do not. + if (_isTextAlignable()) // + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildVerticalDivider(), + Tooltip( + message: AppLocalizations.of(context)!.labelTextAlignment, + child: _buildAlignmentSelector(), + ), + ], + ), + _buildVerticalDivider(), - Tooltip( - message: AppLocalizations.of(context)!.labelTextAlignment, - child: DropdownButton( - value: _getCurrentTextAlignment(), - items: [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify] - .map((textAlign) => DropdownMenuItem( - value: textAlign, - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon(_buildTextAlignIcon(textAlign)), - ), - )) - .toList(), - icon: const Icon(Icons.arrow_drop_down), - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - underline: const SizedBox(), - elevation: 0, - itemHeight: 48, - onChanged: _changeAlignment, + Center( + child: IconButton( + onPressed: () {}, + icon: const Icon(Icons.more_vert), + splashRadius: 16, + tooltip: AppLocalizations.of(context)!.labelMoreOptions, ), ), ], - _buildVerticalDivider(), - Center( - child: IconButton( - onPressed: () {}, - icon: const Icon(Icons.more_vert), - splashRadius: 16, - tooltip: AppLocalizations.of(context)!.labelMoreOptions, - ), - ), - ], + ), ), ), ); } + Widget _buildAlignmentSelector() { + final alignment = _getCurrentTextAlignment(); + return SuperEditorDemoIconItemSelector( + parentFocusNode: widget.editorFocusNode, + boundaryKey: widget.editorViewportKey, + value: SuperEditorDemoIconItem( + id: alignment.name, + icon: _buildTextAlignIcon(alignment), + ), + items: const [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify] + .map( + (alignment) => SuperEditorDemoIconItem( + icon: _buildTextAlignIcon(alignment), + id: alignment.name, + ), + ) + .toList(), + onSelected: _onAlignmentSelected, + ); + } + + Widget _buildBlockTypeSelector() { + final currentBlockType = _getCurrentTextType(); + return SuperEditorDemoTextItemSelector( + parentFocusNode: widget.editorFocusNode, + boundaryKey: widget.editorViewportKey, + id: SuperEditorDemoTextItem( + id: currentBlockType.name, + label: _getTextTypeName(currentBlockType), + ), + items: _TextType.values + .map( + (blockType) => SuperEditorDemoTextItem( + id: blockType.name, + label: _getTextTypeName(blockType), + ), + ) + .toList(), + onSelected: _onBlockTypeSelected, + ); + } + Widget _buildUrlField() { return Material( shape: const StadiumBorder(), @@ -596,34 +700,28 @@ class _EditorToolbarState extends State { child: Row( children: [ Expanded( - child: FocusWithCustomParent( + child: SuperTextField( focusNode: _urlFocusNode, - parentFocusNode: widget.editorFocusNode, - // We use a SuperTextField instead of a TextField because TextField - // automatically re-parents its FocusNode, which causes #609. Flutter - // #106923 tracks the TextField issue. - child: SuperTextField( - focusNode: _urlFocusNode, - textController: _urlController, - minLines: 1, - maxLines: 1, - hintBehavior: HintBehavior.displayHintUntilTextEntered, - hintBuilder: (context) { - return Text( - "enter a url...", - style: const TextStyle( - color: Colors.grey, - fontSize: 16, - ), - ); - }, - textStyleBuilder: (_) { - return const TextStyle( - color: Colors.black, + textController: _urlController, + minLines: 1, + maxLines: 1, + inputSource: TextInputSource.ime, + hintBehavior: HintBehavior.displayHintUntilTextEntered, + hintBuilder: (context) { + return const Text( + "enter a url...", + style: TextStyle( + color: Colors.grey, fontSize: 16, - ); - }, - ), + ), + ); + }, + textStyleBuilder: (_) { + return const TextStyle( + color: Colors.black, + fontSize: 16, + ); + }, ), ), IconButton( @@ -635,7 +733,7 @@ class _EditorToolbarState extends State { setState(() { _urlFocusNode.unfocus(); _showUrlField = false; - _urlController!.clear(); + _urlController!.clearTextAndSelection(); }); }, ), @@ -715,7 +813,7 @@ class ImageFormatToolbar extends StatefulWidget { final VoidCallback closeToolbar; @override - _ImageFormatToolbarState createState() => _ImageFormatToolbarState(); + State createState() => _ImageFormatToolbarState(); } class _ImageFormatToolbarState extends State { @@ -769,7 +867,7 @@ class _ImageFormatToolbarState extends State { onPressed: _makeImageConfined, icon: const Icon(Icons.photo_size_select_large), splashRadius: 16, - tooltip: AppLocalizations.of(context)!.labelBold, + tooltip: AppLocalizations.of(context)!.labelLimitedWidth, ), ), Center( @@ -777,7 +875,7 @@ class _ImageFormatToolbarState extends State { onPressed: _makeImageFullBleed, icon: const Icon(Icons.photo_size_select_actual), splashRadius: 16, - tooltip: AppLocalizations.of(context)!.labelItalics, + tooltip: AppLocalizations.of(context)!.labelFullWidth, ), ), ], diff --git a/super_editor/example/lib/demos/example_editor/example_editor.dart b/super_editor/example/lib/demos/example_editor/example_editor.dart index 1bb802eb09..54871c2bc6 100644 --- a/super_editor/example/lib/demos/example_editor/example_editor.dart +++ b/super_editor/example/lib/demos/example_editor/example_editor.dart @@ -1,4 +1,3 @@ -import 'package:example/demos/example_editor/_task.dart'; import 'package:example/logging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -13,59 +12,79 @@ import '_toolbar.dart'; /// capabilities expand. class ExampleEditor extends StatefulWidget { @override - _ExampleEditorState createState() => _ExampleEditorState(); + State createState() => _ExampleEditorState(); } class _ExampleEditorState extends State { + final GlobalKey _viewportKey = GlobalKey(); final GlobalKey _docLayoutKey = GlobalKey(); - late Document _doc; - late DocumentEditor _docEditor; - late DocumentComposer _composer; + late MutableDocument _doc; + final _docChangeSignal = SignalNotifier(); + late MutableDocumentComposer _composer; + late Editor _docEditor; late CommonEditorOperations _docOps; late FocusNode _editorFocusNode; late ScrollController _scrollController; + final SelectionLayerLinks _selectionLayerLinks = SelectionLayerLinks(); + final _darkBackground = const Color(0xFF222222); final _lightBackground = Colors.white; - bool _isLight = true; + final _brightness = ValueNotifier(Brightness.light); + + SuperEditorDebugVisualsConfig? _debugConfig; - OverlayEntry? _textFormatBarOverlayEntry; + final _textFormatBarOverlayController = OverlayPortalController(); final _textSelectionAnchor = ValueNotifier(null); - OverlayEntry? _imageFormatBarOverlayEntry; + final _imageFormatBarOverlayController = OverlayPortalController(); final _imageSelectionAnchor = ValueNotifier(null); + // TODO: get rid of overlay controller once Android is refactored to use a control scope (as follow up to: https://github.com/superlistapp/super_editor/pull/1470) + final _overlayController = MagnifierAndToolbarController() // + ..screenPadding = const EdgeInsets.all(20.0); + + late final SuperEditorIosControlsController _iosControlsController; + late final SuperEditorAndroidControlsController _androidControlsController; + @override void initState() { super.initState(); - _doc = createInitialDocument()..addListener(_hideOrShowToolbar); - _docEditor = DocumentEditor(document: _doc as MutableDocument); - _composer = DocumentComposer(); + _doc = createInitialDocument()..addListener(_onDocumentChange); + _composer = MutableDocumentComposer(); _composer.selectionNotifier.addListener(_hideOrShowToolbar); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer, isHistoryEnabled: true); _docOps = CommonEditorOperations( editor: _docEditor, + document: _doc, composer: _composer, documentLayoutResolver: () => _docLayoutKey.currentState as DocumentLayout, ); _editorFocusNode = FocusNode(); _scrollController = ScrollController()..addListener(_hideOrShowToolbar); + + _iosControlsController = SuperEditorIosControlsController(); + _androidControlsController = SuperEditorAndroidControlsController(); } @override void dispose() { - if (_textFormatBarOverlayEntry != null) { - _textFormatBarOverlayEntry!.remove(); - } - + _iosControlsController.dispose(); + _androidControlsController.dispose(); _scrollController.dispose(); _editorFocusNode.dispose(); _composer.dispose(); super.dispose(); } + void _onDocumentChange(_) { + _hideOrShowToolbar(); + _docChangeSignal.notifyListeners(); + } + void _hideOrShowToolbar() { if (_gestureMode != DocumentGestureMode.mouse) { // We only add our own toolbar when using mouse. On mobile, a bar @@ -113,7 +132,6 @@ class _ExampleEditorState extends State { } if (selectedNode is TextNode) { - appLog.fine("Showing text format toolbar"); // Show the editor's toolbar for text styling. _showEditorToolbar(); _hideImageToolbar(); @@ -126,40 +144,17 @@ class _ExampleEditorState extends State { } void _showEditorToolbar() { - if (_textFormatBarOverlayEntry == null) { - // Create an overlay entry to build the editor toolbar. - // TODO: add an overlay to the Editor widget to avoid using the - // application overlay - _textFormatBarOverlayEntry ??= OverlayEntry(builder: (context) { - return EditorToolbar( - anchor: _textSelectionAnchor, - editorFocusNode: _editorFocusNode, - editor: _docEditor, - composer: _composer, - closeToolbar: _hideEditorToolbar, - ); - }); - - // Display the toolbar in the application overlay. - final overlay = Overlay.of(context)!; - overlay.insert(_textFormatBarOverlayEntry!); - } + _textFormatBarOverlayController.show(); // Schedule a callback after this frame to locate the selection // bounds on the screen and display the toolbar near the selected // text. + // TODO: switch this to use a Leader and Follower WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (_textFormatBarOverlayEntry == null) { - return; - } - - final docBoundingBox = (_docLayoutKey.currentState as DocumentLayout) - .getRectForSelection(_composer.selection!.base, _composer.selection!.extent)!; - final docBox = _docLayoutKey.currentContext!.findRenderObject() as RenderBox; - final overlayBoundingBox = Rect.fromPoints( - docBox.localToGlobal(docBoundingBox.topLeft), - docBox.localToGlobal(docBoundingBox.bottomRight), - ); + final layout = _docLayoutKey.currentState as DocumentLayout; + final docBoundingBox = layout.getRectForSelection(_composer.selection!.base, _composer.selection!.extent)!; + final globalOffset = layout.getGlobalOffsetFromDocumentOffset(Offset.zero); + final overlayBoundingBox = docBoundingBox.shift(globalOffset); _textSelectionAnchor.value = overlayBoundingBox.topCenter; }); @@ -170,20 +165,21 @@ class _ExampleEditorState extends State { // the bar doesn't momentarily "flash" at its old anchor position. _textSelectionAnchor.value = null; - if (_textFormatBarOverlayEntry != null) { - // Remove the toolbar overlay and null-out the entry. - // We null out the entry because we can't query whether - // or not the entry exists in the overlay, so in our - // case, null implies the entry is not in the overlay, - // and non-null implies the entry is in the overlay. - _textFormatBarOverlayEntry!.remove(); - _textFormatBarOverlayEntry = null; - - // Ensure that focus returns to the editor. - // - // I tried explicitly unfocus()'ing the URL textfield - // in the toolbar but it didn't return focus to the - // editor. I'm not sure why. + _textFormatBarOverlayController.hide(); + + // Ensure that focus returns to the editor. + // + // I tried explicitly unfocus()'ing the URL textfield + // in the toolbar but it didn't return focus to the + // editor. I'm not sure why. + // + // Only do that if the primary focus is not at the root focus scope because + // this might signify that the app is going to the background. Removing + // the focus from the root focus scope in that situation prevents the editor + // from re-gaining focus when the app is brought back to the foreground. + // + // See https://github.com/superlistapp/super_editor/issues/2279 for details. + if (FocusManager.instance.primaryFocus != FocusManager.instance.rootScope) { _editorFocusNode.requestFocus(); } } @@ -204,7 +200,7 @@ class _ExampleEditorState extends State { bool get _isMobile => _gestureMode != DocumentGestureMode.mouse; - DocumentInputSource get _inputSource { + TextInputSource get _inputSource { switch (defaultTargetPlatform) { case TargetPlatform.android: case TargetPlatform.iOS: @@ -212,48 +208,16 @@ class _ExampleEditorState extends State { case TargetPlatform.linux: case TargetPlatform.macOS: case TargetPlatform.windows: - return DocumentInputSource.ime; - // return DocumentInputSource.keyboard; + return TextInputSource.ime; } } - void _cut() => _docOps.cut(); - void _copy() => _docOps.copy(); - void _paste() => _docOps.paste(); - void _selectAll() => _docOps.selectAll(); - void _showImageToolbar() { - if (_imageFormatBarOverlayEntry == null) { - // Create an overlay entry to build the image toolbar. - _imageFormatBarOverlayEntry ??= OverlayEntry(builder: (context) { - return ImageFormatToolbar( - anchor: _imageSelectionAnchor, - composer: _composer, - setWidth: (nodeId, width) { - final node = _doc.getNodeById(nodeId)!; - final currentStyles = SingleColumnLayoutComponentStyles.fromMetadata(node); - SingleColumnLayoutComponentStyles( - width: width, - padding: currentStyles.padding, - ).applyTo(node); - }, - closeToolbar: _hideImageToolbar, - ); - }); - - // Display the toolbar in the application overlay. - final overlay = Overlay.of(context)!; - overlay.insert(_imageFormatBarOverlayEntry!); - } - // Schedule a callback after this frame to locate the selection // bounds on the screen and display the toolbar near the selected // text. + // TODO: switch to a Leader and Follower for this WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (_imageFormatBarOverlayEntry == null) { - return; - } - final docBoundingBox = (_docLayoutKey.currentState as DocumentLayout) .getRectForSelection(_composer.selection!.base, _composer.selection!.extent)!; final docBox = _docLayoutKey.currentContext!.findRenderObject() as RenderBox; @@ -264,6 +228,8 @@ class _ExampleEditorState extends State { _imageSelectionAnchor.value = overlayBoundingBox.center; }); + + _imageFormatBarOverlayController.show(); } void _hideImageToolbar() { @@ -271,105 +237,185 @@ class _ExampleEditorState extends State { // it doesn't momentarily "flash" at its old anchor position. _imageSelectionAnchor.value = null; - if (_imageFormatBarOverlayEntry != null) { - // Remove the image toolbar overlay and null-out the entry. - // We null out the entry because we can't query whether - // or not the entry exists in the overlay, so in our - // case, null implies the entry is not in the overlay, - // and non-null implies the entry is in the overlay. - _imageFormatBarOverlayEntry!.remove(); - _imageFormatBarOverlayEntry = null; - - // Ensure that focus returns to the editor. + _imageFormatBarOverlayController.hide(); + + // Ensure that focus returns to the editor. + // + // Only do that if the primary focus is not at the root focus scope because + // this might signify that the app is going to the background. Removing + // the focus from the root focus scope in that situation prevents the editor + // from re-gaining focus when the app is brought back to the foreground. + // + // See https://github.com/superlistapp/super_editor/issues/2279 for details. + if (FocusManager.instance.primaryFocus != FocusManager.instance.rootScope) { _editorFocusNode.requestFocus(); } } @override Widget build(BuildContext context) { - return Stack( - children: [ - Column( - children: [ - Expanded( - child: _buildEditor(), + return ValueListenableBuilder( + valueListenable: _brightness, + builder: (context, brightness, child) { + return Theme( + data: ThemeData(brightness: brightness), + child: child!, + ); + }, + child: Builder( + // This builder captures the new theme + builder: (themedContext) { + return OverlayPortal( + controller: _textFormatBarOverlayController, + overlayChildBuilder: _buildFloatingToolbar, + child: OverlayPortal( + controller: _imageFormatBarOverlayController, + overlayChildBuilder: _buildImageToolbar, + child: Stack( + children: [ + Column( + children: [ + Expanded( + child: _buildEditor(themedContext), + ), + if (_isMobile) // + _buildMountedToolbar(), + ], + ), + Align( + alignment: Alignment.bottomRight, + child: ListenableBuilder( + listenable: _composer.selectionNotifier, + builder: (context, child) { + return Padding( + padding: EdgeInsets.only(bottom: _isMobile && _composer.selection != null ? 48 : 0), + child: child, + ); + }, + child: _buildCornerFabs(), + ), + ), + ], + ), ), - if (_isMobile) _buildMountedToolbar(), - ], - ), - Align( - alignment: Alignment.bottomRight, - child: _buildLightAndDarkModeToggle(), - ), - ], + ); + }, + ), ); } - Widget _buildLightAndDarkModeToggle() { + Widget _buildCornerFabs() { return Padding( - padding: const EdgeInsets.only(right: 16.0, bottom: 16.0), - child: FloatingActionButton( - backgroundColor: _isLight ? _darkBackground : _lightBackground, - foregroundColor: _isLight ? _lightBackground : _darkBackground, - elevation: 5, - onPressed: () { - setState(() { - _isLight = !_isLight; - }); - }, - child: _isLight - ? const Icon( - Icons.dark_mode, - ) - : const Icon( - Icons.light_mode, - ), + padding: const EdgeInsets.only(right: 16, bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildDebugVisualsToggle(), + const SizedBox(height: 16), + _buildLightAndDarkModeToggle(), + ], + ), + ); + } + + Widget _buildDebugVisualsToggle() { + return FloatingActionButton( + backgroundColor: _brightness.value == Brightness.light ? _darkBackground : _lightBackground, + foregroundColor: _brightness.value == Brightness.light ? _lightBackground : _darkBackground, + elevation: 5, + onPressed: () { + setState(() { + _debugConfig = _debugConfig != null + ? null + : const SuperEditorDebugVisualsConfig( + showFocus: true, + showImeConnection: true, + ); + }); + }, + child: const Icon( + Icons.bug_report, ), ); } - Widget _buildEditor() { + Widget _buildLightAndDarkModeToggle() { + return FloatingActionButton( + backgroundColor: _brightness.value == Brightness.light ? _darkBackground : _lightBackground, + foregroundColor: _brightness.value == Brightness.light ? _lightBackground : _darkBackground, + elevation: 5, + onPressed: () { + _brightness.value = _brightness.value == Brightness.light ? Brightness.dark : Brightness.light; + }, + child: _brightness.value == Brightness.light + ? const Icon( + Icons.dark_mode, + ) + : const Icon( + Icons.light_mode, + ), + ); + } + + Widget _buildEditor(BuildContext context) { + final isLight = Theme.of(context).brightness == Brightness.light; + return ColoredBox( - color: _isLight ? _lightBackground : _darkBackground, - child: SuperEditor( - editor: _docEditor, - composer: _composer, - focusNode: _editorFocusNode, - scrollController: _scrollController, - documentLayoutKey: _docLayoutKey, - documentOverlayBuilders: [ - DefaultCaretOverlayBuilder( - CaretStyle().copyWith(color: _isLight ? Colors.black : Colors.redAccent), - ), - ], - selectionStyle: _isLight - ? defaultSelectionStyle - : SelectionStyles( - selectionColor: Colors.red.withOpacity(0.3), + color: isLight ? _lightBackground : _darkBackground, + child: SuperEditorDebugVisuals( + config: _debugConfig ?? const SuperEditorDebugVisualsConfig(), + child: KeyedSubtree( + key: _viewportKey, + child: SuperEditorAndroidControlsScope( + controller: _androidControlsController, + child: SuperEditorIosControlsScope( + controller: _iosControlsController, + child: SuperEditor( + editor: _docEditor, + focusNode: _editorFocusNode, + scrollController: _scrollController, + documentLayoutKey: _docLayoutKey, + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: const CaretStyle().copyWith(color: isLight ? Colors.black : Colors.redAccent), + ), + if (defaultTargetPlatform == TargetPlatform.iOS) ...[ + SuperEditorIosHandlesDocumentLayerBuilder(), + SuperEditorIosToolbarFocalPointDocumentLayerBuilder(), + ], + if (defaultTargetPlatform == TargetPlatform.android) ...[ + SuperEditorAndroidToolbarFocalPointDocumentLayerBuilder(), + SuperEditorAndroidHandlesDocumentLayerBuilder(), + ], + ], + selectionLayerLinks: _selectionLayerLinks, + selectionStyle: isLight + ? defaultSelectionStyle + : SelectionStyles( + selectionColor: Colors.red.withValues(alpha: 0.3), + ), + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: [ + if (!isLight) ..._darkModeStyles, + taskStyles, + ], + ), + componentBuilders: [ + TaskComponentBuilder(_docEditor), + ...defaultComponentBuilders, + ], + gestureMode: _gestureMode, + inputSource: _inputSource, + keyboardActions: + _inputSource == TextInputSource.ime ? defaultImeKeyboardActions : defaultKeyboardActions, + overlayController: _overlayController, + plugins: { + MarkdownInlineUpstreamSyntaxPlugin(), + }, ), - stylesheet: defaultStylesheet.copyWith( - addRulesAfter: [ - if (!_isLight) ..._darkModeStyles, - taskStyles, - ], - ), - componentBuilders: [ - ...defaultComponentBuilders, - TaskComponentBuilder(_docEditor), - ], - gestureMode: _gestureMode, - inputSource: _inputSource, - keyboardActions: _inputSource == DocumentInputSource.ime ? defaultImeKeyboardActions : defaultKeyboardActions, - androidToolbarBuilder: (_) => AndroidTextEditingFloatingToolbar( - onCutPressed: _cut, - onCopyPressed: _copy, - onPastePressed: _paste, - onSelectAllPressed: _selectAll, - ), - iOSToolbarBuilder: (_) => IOSTextEditingFloatingToolbar( - onCutPressed: _cut, - onCopyPressed: _copy, - onPastePressed: _paste, + ), + ), ), ), ); @@ -378,7 +424,7 @@ class _ExampleEditorState extends State { Widget _buildMountedToolbar() { return MultiListenableBuilder( listenables: { - _doc, + _docChangeSignal, _composer.selectionNotifier, }, builder: (_) { @@ -389,6 +435,7 @@ class _ExampleEditorState extends State { } return KeyboardEditingToolbar( + editor: _docEditor, document: _doc, composer: _composer, commonOps: _docOps, @@ -396,6 +443,41 @@ class _ExampleEditorState extends State { }, ); } + + Widget _buildFloatingToolbar(BuildContext context) { + return EditorToolbar( + editorViewportKey: _viewportKey, + anchor: _selectionLayerLinks.expandedSelectionBoundsLink, + editorFocusNode: _editorFocusNode, + editor: _docEditor, + document: _doc, + composer: _composer, + closeToolbar: _hideEditorToolbar, + ); + } + + Widget _buildImageToolbar(BuildContext context) { + return ImageFormatToolbar( + anchor: _imageSelectionAnchor, + composer: _composer, + setWidth: (nodeId, width) { + print("Applying width $width to node $nodeId"); + final node = _doc.getNodeById(nodeId)!; + final currentStyles = SingleColumnLayoutComponentStyles.fromMetadata(node); + + _docEditor.execute([ + ChangeSingleColumnLayoutComponentStylesRequest( + nodeId: nodeId, + styles: SingleColumnLayoutComponentStyles( + width: width, + padding: currentStyles.padding, + ), + ) + ]); + }, + closeToolbar: _hideImageToolbar, + ); + } } // Makes text light, for use during dark mode styling. @@ -404,7 +486,7 @@ final _darkModeStyles = [ BlockSelector.all, (doc, docNode) { return { - "textStyle": const TextStyle( + Styles.textStyle: const TextStyle( color: Color(0xFFCCCCCC), ), }; @@ -414,7 +496,7 @@ final _darkModeStyles = [ const BlockSelector("header1"), (doc, docNode) { return { - "textStyle": const TextStyle( + Styles.textStyle: const TextStyle( color: Color(0xFF888888), ), }; @@ -424,7 +506,7 @@ final _darkModeStyles = [ const BlockSelector("header2"), (doc, docNode) { return { - "textStyle": const TextStyle( + Styles.textStyle: const TextStyle( color: Color(0xFF888888), ), }; diff --git a/super_editor/example/lib/demos/flutter_features/demo_inline_widgets.dart b/super_editor/example/lib/demos/flutter_features/demo_inline_widgets.dart index 162cbfcc01..3f5c74cffb 100644 --- a/super_editor/example/lib/demos/flutter_features/demo_inline_widgets.dart +++ b/super_editor/example/lib/demos/flutter_features/demo_inline_widgets.dart @@ -3,7 +3,7 @@ import 'package:flutter/rendering.dart'; class TextInlineWidgetDemo extends StatefulWidget { @override - _TextInlineWidgetDemoState createState() => _TextInlineWidgetDemoState(); + State createState() => _TextInlineWidgetDemoState(); } class _TextInlineWidgetDemoState extends State { @@ -107,10 +107,10 @@ class _TextInlineWidgetDemoState extends State { shape: StadiumBorder(), color: Colors.yellow, ), - child: Row( + child: const Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, - children: const [ + children: [ Icon( Icons.account_circle, size: 14, @@ -136,10 +136,10 @@ class _TextInlineWidgetDemoState extends State { } Widget _buildProgressExample() { - return SelectableText.rich( + return const SelectableText.rich( TextSpan( text: "This is a multi-step item with progress", - style: const TextStyle( + style: TextStyle( color: Colors.black, ), children: [ @@ -148,7 +148,7 @@ class _TextInlineWidgetDemoState extends State { child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, - children: const [ + children: [ SizedBox(width: 8), Icon(Icons.check_circle, color: Colors.green, size: 14), SizedBox(width: 4), diff --git a/super_editor/example/lib/demos/flutter_features/textinputclient/barebones_ios_text_input_client.dart b/super_editor/example/lib/demos/flutter_features/textinputclient/barebones_ios_text_input_client.dart index 62853e61eb..10960f803b 100644 --- a/super_editor/example/lib/demos/flutter_features/textinputclient/barebones_ios_text_input_client.dart +++ b/super_editor/example/lib/demos/flutter_features/textinputclient/barebones_ios_text_input_client.dart @@ -4,10 +4,10 @@ import 'package:super_text_layout/super_text_layout.dart'; /// Demo that displays a very limited iOS text field, constructed from /// the ground up, using [TextInput] for user interaction instead -/// of a [RawKeyboardListener] or similar. +/// of a [KeyboardListener] or similar. class BarebonesIosTextInputClientDemo extends StatefulWidget { @override - _BarebonesIosTextInputClientDemoState createState() => _BarebonesIosTextInputClientDemoState(); + State createState() => _BarebonesIosTextInputClientDemoState(); } class _BarebonesIosTextInputClientDemoState extends State { @@ -130,7 +130,12 @@ class _BareBonesTextFieldWithInputClientState extends State<_BareBonesTextFieldW if (_textInputConnection == null) { print('Attaching TextInputClient to TextInput'); setState(() { - _textInputConnection = TextInput.attach(this, const TextInputConfiguration()); + _textInputConnection = TextInput.attach( + this, + TextInputConfiguration( + viewId: View.of(context).viewId, + ), + ); _textInputConnection! ..show() ..setEditingState(currentTextEditingValue!); diff --git a/super_editor/example/lib/demos/flutter_features/textinputclient/basic_text_input_client.dart b/super_editor/example/lib/demos/flutter_features/textinputclient/basic_text_input_client.dart index c0e0aa267f..1ba501a60d 100644 --- a/super_editor/example/lib/demos/flutter_features/textinputclient/basic_text_input_client.dart +++ b/super_editor/example/lib/demos/flutter_features/textinputclient/basic_text_input_client.dart @@ -4,10 +4,10 @@ import 'package:super_text_layout/super_text_layout.dart'; /// Demo that displays a very limited text field, constructed from /// the ground up, and using [TextInput] for user interaction instead -/// of a [RawKeyboardListener] or similar. +/// of a [KeyboardListener] or similar. class BasicTextInputClientDemo extends StatefulWidget { @override - _BasicTextInputClientDemoState createState() => _BasicTextInputClientDemoState(); + State createState() => _BasicTextInputClientDemoState(); } class _BasicTextInputClientDemoState extends State { @@ -130,7 +130,12 @@ class _BareBonesTextFieldWithInputClientState extends State<_BareBonesTextFieldW if (_textInputConnection == null) { print('Attaching TextInputClient to TextInput'); setState(() { - _textInputConnection = TextInput.attach(this, const TextInputConfiguration()); + _textInputConnection = TextInput.attach( + this, + TextInputConfiguration( + viewId: View.of(context).viewId, + ), + ); _textInputConnection! ..show() ..setEditingState(currentTextEditingValue!); diff --git a/super_editor/example/lib/demos/flutter_features/textinputclient/textfield.dart b/super_editor/example/lib/demos/flutter_features/textinputclient/textfield.dart index eeee427791..ce577f003b 100644 --- a/super_editor/example/lib/demos/flutter_features/textinputclient/textfield.dart +++ b/super_editor/example/lib/demos/flutter_features/textinputclient/textfield.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; class FlutterTextFieldDemo extends StatefulWidget { @override - _FlutterTextFieldDemoState createState() => _FlutterTextFieldDemoState(); + State createState() => _FlutterTextFieldDemoState(); } class _FlutterTextFieldDemoState extends State { diff --git a/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart b/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart new file mode 100644 index 0000000000..26f63ecd29 --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart @@ -0,0 +1,393 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/material.dart' hide ListenableBuilder; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/super_editor.dart'; + +import 'popover_list.dart'; + +class ActionTagsFeatureDemo extends StatefulWidget { + const ActionTagsFeatureDemo({super.key}); + + @override + State createState() => _ActionTagsFeatureDemoState(); +} + +class _ActionTagsFeatureDemoState extends State { + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + late final Editor _editor; + late final ActionTagsPlugin _actionTagPlugin; + + late final FocusNode _editorFocusNode; + + final _actions = []; + + @override + void initState() { + super.initState(); + + _document = MutableDocument.empty(); + _composer = MutableDocumentComposer(); + _editor = Editor( + editables: { + Editor.documentKey: _document, + Editor.composerKey: _composer, + }, + requestHandlers: [ + (editor, request) => request is ConvertSelectedTextNodeRequest // + ? ConvertSelectedTextNodeCommand(request.newType) + : null, + ...defaultRequestHandlers, + ], + ); + + _actionTagPlugin = ActionTagsPlugin()..composingActionTag.addListener(_updateActionTagList); + + _editorFocusNode = FocusNode(); + } + + @override + void dispose() { + _editorFocusNode.dispose(); + + _actionTagPlugin.composingActionTag.removeListener(_updateActionTagList); + + _composer.dispose(); + _editor.dispose(); + _document.dispose(); + + super.dispose(); + } + + void _updateActionTagList() { + setState(() { + _actions.clear(); + + for (final node in _document) { + if (node is! TextNode) { + continue; + } + + final actionSpans = node.text.getAttributionSpansInRange( + attributionFilter: (a) => a == actionTagComposingAttribution, + range: SpanRange(0, node.text.length - 1), + ); + + for (final actionSpan in actionSpans) { + _actions.add(node.text.substring(actionSpan.start, actionSpan.end + 1)); + } + } + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + InTheLabScaffold( + content: _buildEditor(), + supplemental: _buildTagList(), + ), + if (_actionTagPlugin.composingActionTag.value != null) + Follower.withOffset( + link: _composingLink, + offset: Offset(0, 16), + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + showWhenUnlinked: false, + child: _ActionTagsListPopover( + editor: _editor, + actionTagPlugin: _actionTagPlugin, + editorFocusNode: _editorFocusNode, + ), + ), + ], + ); + } + + Widget _buildEditor() { + return SuperEditor( + editor: _editor, + focusNode: _editorFocusNode, + componentBuilders: [ + TaskComponentBuilder(_editor), + ...defaultComponentBuilders, + ], + shrinkWrap: true, + stylesheet: defaultStylesheet.copyWith( + inlineTextStyler: (attributions, existingStyle) { + TextStyle style = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.contains(actionTagComposingAttribution)) { + style = style.copyWith( + color: Colors.blue, + ); + } + + return style; + }, + addRulesAfter: [ + ...darkModeStyles, + ...largeTextStyles, + ], + ), + documentOverlayBuilders: [ + AttributedTextBoundsOverlay( + selector: (a) => a == actionTagComposingAttribution, + builder: (BuildContext context, Attribution attribution) { + return Leader( + link: _composingLink, + child: const SizedBox(), + ); + }, + ), + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + plugins: { + _actionTagPlugin, + }, + ); + } + + Widget _buildTagList() { + if (_actions.isEmpty) { + return const SizedBox(); + } + + return Center( + child: SingleChildScrollView( + child: Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: [ + for (final tag in _actions) // + Chip(label: Text(tag)), + ], + ), + ), + ); + } +} + +final _composingLink = LeaderLink(); + +class _ActionTagsListPopover extends StatefulWidget { + const _ActionTagsListPopover({ + required this.editor, + required this.actionTagPlugin, + required this.editorFocusNode, + }); + + final Editor editor; + final ActionTagsPlugin actionTagPlugin; + final FocusNode editorFocusNode; + + @override + State<_ActionTagsListPopover> createState() => _ActionTagsListPopoverState(); +} + +class _ActionTagsListPopoverState extends State<_ActionTagsListPopover> { + static const _actionCandidates = <_TextNodeConversion>[ + _TextNodeConversion("Header 1", TextNodeType.header1), + _TextNodeConversion("Header 2", TextNodeType.header2), + _TextNodeConversion("Header 3", TextNodeType.header3), + _TextNodeConversion("Ordered List Item", TextNodeType.orderedListItem), + _TextNodeConversion("Unordered List Item", TextNodeType.unorderedListItem), + _TextNodeConversion("Task", TextNodeType.task), + _TextNodeConversion("Paragraph ", TextNodeType.paragraph), + ]; + final _matchingActions = <_TextNodeConversion>[]; + + @override + void initState() { + super.initState(); + + widget.actionTagPlugin.composingActionTag.addListener(_onComposingTokenChange); + + final initialComposingTag = widget.actionTagPlugin.composingActionTag.value?.tag.token; + if (initialComposingTag != null) { + _selectMatchingActions(initialComposingTag); + } + } + + @override + void didUpdateWidget(_ActionTagsListPopover oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.actionTagPlugin != oldWidget.actionTagPlugin) { + oldWidget.actionTagPlugin.composingActionTag.removeListener(_onComposingTokenChange); + widget.actionTagPlugin.composingActionTag.addListener(_onComposingTokenChange); + } + } + + @override + void dispose() { + widget.actionTagPlugin.composingActionTag.removeListener(_onComposingTokenChange); + + super.dispose(); + } + + Future _onComposingTokenChange() async { + final composingTag = widget.actionTagPlugin.composingActionTag.value?.tag.token; + if (composingTag == null) { + // The user isn't composing a tag. + setState(() { + _matchingActions.clear(); + }); + return; + } + + // Filter the user list based on the composing token. + setState(() { + _selectMatchingActions(composingTag); + }); + } + + void _selectMatchingActions(String composingTag) { + _matchingActions + ..clear() + ..addAll(_actionCandidates + .where((availableAction) => availableAction.name.toLowerCase().contains(composingTag.toLowerCase()))); + } + + void _onItemSelected(Object type) { + widget.editor.execute([ + SubmitComposingActionTagRequest(), + ConvertSelectedTextNodeRequest(type as TextNodeType), + ]); + } + + void _cancelTag() { + widget.editor.execute([ + CancelComposingActionTagRequest(defaultActionTagRule), + ]); + } + + @override + Widget build(BuildContext context) { + return PopoverList( + editorFocusNode: widget.editorFocusNode, + leaderLink: _composingLink, + listItems: _matchingActions + .map( + (action) => PopoverListItem(id: action.type, label: action.name), + ) + .toList(), + onListItemSelected: _onItemSelected, + onCancelRequested: _cancelTag, + ); + } +} + +class ConvertSelectedTextNodeRequest implements EditRequest { + ConvertSelectedTextNodeRequest(this.newType); + + final TextNodeType newType; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ConvertSelectedTextNodeRequest && runtimeType == other.runtimeType && newType == other.newType; + + @override + int get hashCode => newType.hashCode; +} + +class ConvertSelectedTextNodeCommand extends EditCommand { + ConvertSelectedTextNodeCommand(this.newType); + + final TextNodeType newType; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.find(Editor.documentKey); + final composer = context.find(Editor.composerKey); + + if (composer.selection == null) { + // There's no selected node to convert. + return; + } + + final extentPosition = composer.selection!.extent.nodePosition; + if (extentPosition is! TextNodePosition) { + // The selected node isn't a text node. We only convert text nodes. + return; + } + + final oldNode = document.getNodeById(composer.selection!.extent.nodeId) as TextNode; + + late final TextNode newNode; + switch (newType) { + case TextNodeType.header1: + newNode = ParagraphNode( + id: oldNode.id, + text: oldNode.text, + metadata: Map.from(oldNode.metadata)..["blockType"] = header1Attribution, + ); + case TextNodeType.header2: + newNode = ParagraphNode( + id: oldNode.id, + text: oldNode.text, + metadata: Map.from(oldNode.metadata)..["blockType"] = header2Attribution, + ); + case TextNodeType.header3: + newNode = ParagraphNode( + id: oldNode.id, + text: oldNode.text, + metadata: Map.from(oldNode.metadata)..["blockType"] = header3Attribution, + ); + case TextNodeType.orderedListItem: + newNode = ListItemNode( + id: oldNode.id, + itemType: ListItemType.ordered, + text: oldNode.text, + ); + case TextNodeType.unorderedListItem: + newNode = ListItemNode( + id: oldNode.id, + itemType: ListItemType.unordered, + text: oldNode.text, + ); + case TextNodeType.task: + newNode = TaskNode( + id: oldNode.id, + text: oldNode.text, + isComplete: false, + ); + case TextNodeType.paragraph: + newNode = ParagraphNode( + id: oldNode.id, + text: oldNode.text, + metadata: Map.from(oldNode.metadata)..["blockType"] = paragraphAttribution, + ); + } + + document.replaceNode(oldNode: oldNode, newNode: newNode); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(newNode.id), + ), + ]); + } +} + +class _TextNodeConversion { + const _TextNodeConversion(this.name, this.type); + + final String name; + final TextNodeType type; +} + +enum TextNodeType { + header1, + header2, + header3, + orderedListItem, + unorderedListItem, + task, + paragraph, +} diff --git a/super_editor/example/lib/demos/in_the_lab/feature_ai_fade_in.dart b/super_editor/example/lib/demos/in_the_lab/feature_ai_fade_in.dart new file mode 100644 index 0000000000..a676da66c7 --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_ai_fade_in.dart @@ -0,0 +1,290 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/material.dart' hide ListenableBuilder; +import 'package:super_editor/super_editor.dart'; + +class AiFadeInFeatureDemo extends StatefulWidget { + const AiFadeInFeatureDemo({super.key}); + + @override + State createState() => _AiFadeInFeatureDemoState(); +} + +class _AiFadeInFeatureDemoState extends State with SingleTickerProviderStateMixin { + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + late final Editor _editor; + late final FadeInStyler _fadeInStylePhase; + + late final _FakeAiWithEditor _fakeAiWithEditor; + + @override + void initState() { + super.initState(); + + _document = MutableDocument.empty(); + _composer = MutableDocumentComposer( + initialSelection: DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: _document.first.id, + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + _editor = createDefaultDocumentEditor(document: _document, composer: _composer); + + _fadeInStylePhase = FadeInStyler(this); + + _fakeAiWithEditor = _FakeAiWithEditor(_editor); + } + + @override + void dispose() { + _fadeInStylePhase.dispose(); + + _fakeAiWithEditor.dispose(); + + _composer.dispose(); + _editor.dispose(); + _document.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InTheLabScaffold( + content: SuperReader( + editor: _editor, + customStylePhases: [ + _fadeInStylePhase, + ], + stylesheet: defaultStylesheet.copyWith( + selectedTextColorStrategy: ({ + required Color originalTextColor, + required Color selectionHighlightColor, + }) { + return Colors.black; + }, + addRulesAfter: [ + ...darkModeStyles, + ], + ), + ), + supplemental: Column( + spacing: 16, + children: [ + ElevatedButton( + onPressed: () => _fakeAiWithEditor.startSimulatedTextEntry(), + child: Text("Restart Simulation"), + ), + ElevatedButton( + onPressed: () => _fakeAiWithEditor.stopSimulatedTextEntry(), + child: Text("Pause Simulation"), + ), + ], + ), + ); + } +} + +class _FakeAiWithEditor { + _FakeAiWithEditor(this._editor) { + _preCannedDocument = _createFakeAiDocument(); + } + + void dispose() { + _contentEntryTimer?.cancel(); + + _isSimulatingTextEntry = false; + _simulatedEntryTextIndex = 0; + _nextTextEntrySnippet = null; + _contentEntryTimer = null; + } + + final Editor _editor; + late final MutableDocument _preCannedDocument; + + bool _isRunningFadeIn = false; + + String? _fadingNodeId; + + AttributedText? _textToInsert; + bool _isSimulatingTextEntry = false; + int _simulatedEntryTextIndex = 0; + AttributedText? _nextTextEntrySnippet; + + Timer? _contentEntryTimer; + + void startSimulatedTextEntry() { + stopSimulatedTextEntry(); + _editor.execute([ + ClearDocumentRequest(), + ]); + + _isRunningFadeIn = true; + _doInsertContent(); + _contentEntryTimer = Timer(_randomAiTextInsertionInterval, _doInsertContent); + } + + void _doInsertContent() { + if (!_isRunningFadeIn) { + return; + } + + if (_fadingNodeId == null) { + // This is the start of the content insertion process. + final firstNode = _preCannedDocument.first; + _fadingNodeId = firstNode.id; + + _editor.execute([ + DeleteNodeRequest(nodeId: _editor.document.first.id), + ]); + } else if (!_isSimulatingTextEntry) { + _selectNextNode(); + if (_fadingNodeId == null) { + // We're done running the document fade-in. + _isRunningFadeIn = false; + return; + } + } + + bool isInsertingText = false; + if (_isSimulatingTextEntry) { + // We're in the process of inserting text. + _selectNextTextSnippet(); + _doInsertText(); + + isInsertingText = true; + } else { + final nextNode = _preCannedDocument.getNodeById(_fadingNodeId!)!; + if (nextNode is TextNode) { + // Start a new text node. + _editor.execute([ + InsertNodeAtEndOfDocumentRequest( + nextNode.copyTextNodeWith( + text: AttributedText(), + ), + ), + ]); + + if (nextNode.text.isNotEmpty) { + _isSimulatingTextEntry = true; + _textToInsert = nextNode.text; + _simulatedEntryTextIndex = 0; + } + + isInsertingText = true; + } else { + // Insert the next non-text node. + _editor.execute([ + InsertNodeAtEndOfDocumentRequest( + nextNode.copyWithAddedMetadata({ + NodeMetadata.createdAt: DateTime.now(), + }), + ), + ]); + } + } + + if (_fadingNodeId != null) { + _contentEntryTimer = Timer( + isInsertingText ? _randomAiTextInsertionInterval : _randomAiNodeInsertionInterval, + _doInsertContent, + ); + } + } + + void _selectNextNode() { + final previousNodeId = _fadingNodeId; + final nextNode = previousNodeId != null // + ? _preCannedDocument.getNodeAfterById(previousNodeId) + : _preCannedDocument.first; + _fadingNodeId = nextNode?.id; + if (nextNode == null) { + return; + } + } + + void _selectNextTextSnippet() { + const minLength = 3; + const maxLength = 30; + + final remaining = _textToInsert!.length - _simulatedEntryTextIndex; + if (remaining <= 0) { + // There's no text left. Fizzle. + _nextTextEntrySnippet = null; + return; + } + if (remaining <= minLength) { + // There's not enough text left to satisfy the minimum per-entry amount. + // Add whatever characters remain in the text. + _nextTextEntrySnippet = _textToInsert!.copyText(_simulatedEntryTextIndex); + _simulatedEntryTextIndex = _textToInsert!.length; + return; + } + + // Pick a random amount of the remaining characters, based on a minimum and + // maximum number of characters that we want to insert per cycle. + final randomMax = min(maxLength, remaining) - minLength; + final length = Random().nextInt(randomMax) + minLength; + final endIndex = _simulatedEntryTextIndex + length; + + _nextTextEntrySnippet = _textToInsert!.copyText(_simulatedEntryTextIndex, endIndex); + _simulatedEntryTextIndex = endIndex; + } + + void _doInsertText() { + _editor.execute([ + InsertStyledTextAtEndOfDocumentRequest( + _nextTextEntrySnippet!, + createdAt: DateTime.now(), + ), + ]); + + if (_simulatedEntryTextIndex >= _textToInsert!.length) { + _isSimulatingTextEntry = false; + _textToInsert = null; + _simulatedEntryTextIndex = 0; + _nextTextEntrySnippet = null; + } + } + + void stopSimulatedTextEntry() { + if (!_isRunningFadeIn) { + return; + } + + _isRunningFadeIn = false; + _fadingNodeId = null; + _isSimulatingTextEntry = false; + _textToInsert = null; + _contentEntryTimer?.cancel(); + _contentEntryTimer = null; + } + + Duration get _randomAiNodeInsertionInterval => Duration(milliseconds: Random().nextInt(600) + 400); + + Duration get _randomAiTextInsertionInterval => Duration(milliseconds: Random().nextInt(400) + 100); +} + +MutableDocument _createFakeAiDocument() => deserializeMarkdownToDocument(_markdownDocument); + +const _markdownDocument = ''' +# AI-Style Fade-In +It's common for chat GPT AI systems to fade in text and content as its generated by the AI model. Super Editor supports this. + +We recommend using a SuperReader widget for LLM content, so that the user can't edit that content while it's generated. + +--- +To fade-in content... + +1. First step is to register a FadeInStyler with SuperReader. +2. Second, when inserting content, include a CreatedAtAttribution with the DateTime.now(). +--- + +[Learn more in the docs](https://supereditor.dev/guides/ai/fad-in-content) + +'''; diff --git a/super_editor/example/lib/demos/in_the_lab/feature_custom_underlines.dart b/super_editor/example/lib/demos/in_the_lab/feature_custom_underlines.dart new file mode 100644 index 0000000000..8a04453cc3 --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_custom_underlines.dart @@ -0,0 +1,129 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class CustomUnderlinesDemo extends StatefulWidget { + const CustomUnderlinesDemo({super.key}); + + @override + State createState() => _CustomUnderlinesDemoState(); +} + +class _CustomUnderlinesDemoState extends State { + late final Editor _editor; + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: _createDocument(), + composer: MutableDocumentComposer(), + ); + } + + @override + Widget build(BuildContext context) { + return InTheLabScaffold( + content: SuperEditor( + editor: _editor, + stylesheet: defaultStylesheet.copyWith( + addRulesBefore: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.customUnderlineStyles: CustomUnderlineStyles({ + _brandUnderline: StraightUnderlineStyle( + color: Colors.red, + thickness: 3, + capType: StrokeCap.round, + offset: -3, + ), + _dottedUnderline: DottedUnderlineStyle( + color: Colors.blue, + ), + _squiggleUnderline: SquiggleUnderlineStyle( + color: Colors.green, + ), + }), + }; + }, + ), + ], + addRulesAfter: [ + ...darkModeStyles, + ], + selectedTextColorStrategy: ({ + required Color originalTextColor, + required Color selectionHighlightColor, + }) => + Colors.black, + ), + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + ), + ); + } +} + +MutableDocument _createDocument() { + return MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("Custom Underlines"), + metadata: { + NodeMetadata.blockType: header1Attribution, + }, + ), + ParagraphNode( + id: "2", + text: AttributedText( + "Super Editor supports custom painted underlines across text spans.", + AttributedSpans( + attributions: [ + SpanMarker( + attribution: CustomUnderlineAttribution(_brandUnderline), + offset: 0, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: CustomUnderlineAttribution(_brandUnderline), + offset: 11, + markerType: SpanMarkerType.end, + ), + SpanMarker( + attribution: CustomUnderlineAttribution(_dottedUnderline), + offset: 22, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: CustomUnderlineAttribution(_dottedUnderline), + offset: 35, + markerType: SpanMarkerType.end, + ), + SpanMarker( + attribution: CustomUnderlineAttribution(_squiggleUnderline), + offset: 48, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: CustomUnderlineAttribution(_squiggleUnderline), + offset: 64, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ), + ], + ); +} + +const _brandUnderline = "brand"; +const _dottedUnderline = "dotted"; +const _squiggleUnderline = "squiggly"; diff --git a/super_editor/example/lib/demos/in_the_lab/feature_ios_native_context_menu.dart b/super_editor/example/lib/demos/in_the_lab/feature_ios_native_context_menu.dart new file mode 100644 index 0000000000..5468705c09 --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_ios_native_context_menu.dart @@ -0,0 +1,188 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/super_editor.dart'; + +/// Super Editor demo that uses the native iOS context menu as the floating toolbar +/// for both Super Editor and Super Text Field. +/// +/// By default, Super Editor and Super Text Field display a floating toolbar that's +/// painted by Flutter. By using Flutter, you gain full control over appearance, and +/// the available options. However, recent versions of iOS have security settings +/// that bring up an annoying warning if you attempt to run a "paste" command without +/// using their native iOS toolbar. For that reason, Super Editor makes it possible +/// to show the native iOS toolbar. +class NativeIosContextMenuFeatureDemo extends StatefulWidget { + const NativeIosContextMenuFeatureDemo({super.key}); + + @override + State createState() => _NativeIosContextMenuFeatureDemoState(); +} + +class _NativeIosContextMenuFeatureDemoState extends State { + final _documentLayoutKey = GlobalKey(); + + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + late final Editor _editor; + late final CommonEditorOperations _commonEditorOperations; + + late final SuperEditorIosControlsController _toolbarController; + + @override + void initState() { + super.initState(); + + _document = MutableDocument.empty(); + _composer = MutableDocumentComposer(); + _editor = Editor( + editables: { + Editor.documentKey: _document, + Editor.composerKey: _composer, + }, + requestHandlers: [ + ...defaultRequestHandlers, + ], + ); + _commonEditorOperations = CommonEditorOperations( + document: _document, + editor: _editor, + composer: _composer, + documentLayoutResolver: () => _documentLayoutKey.currentState as DocumentLayout, + ); + + _toolbarController = SuperEditorIosControlsController( + toolbarBuilder: _buildToolbar, + ); + } + + @override + void dispose() { + _toolbarController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InTheLabScaffold( + content: _buildEditor(), + supplemental: _buildTextField(), + ); + } + + Widget _buildEditor() { + return SuperEditorIosControlsScope( + controller: _toolbarController, + child: IntrinsicHeight( + child: SuperEditor( + editor: _editor, + documentLayoutKey: _documentLayoutKey, + selectionStyle: SelectionStyles( + selectionColor: Colors.red.withValues(alpha: 0.3), + ), + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: [ + ...darkModeStyles, + ...largeTextStyles, + ], + ), + documentOverlayBuilders: [ + if (defaultTargetPlatform == TargetPlatform.iOS) ...[ + // Adds a Leader around the document selection at a focal point for the + // iOS floating toolbar. + SuperEditorIosToolbarFocalPointDocumentLayerBuilder(), + // Displays caret and drag handles, specifically for iOS. + SuperEditorIosHandlesDocumentLayerBuilder( + handleColor: Colors.red, + ), + ], + + if (defaultTargetPlatform == TargetPlatform.android) ...[ + // Adds a Leader around the document selection at a focal point for the + // Android floating toolbar. + SuperEditorAndroidToolbarFocalPointDocumentLayerBuilder(), + // Displays caret and drag handles, specifically for Android. + SuperEditorAndroidHandlesDocumentLayerBuilder( + caretColor: Colors.red, + ), + ], + + // Displays caret for typical desktop use-cases. + DefaultCaretOverlayBuilder( + caretStyle: const CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + ), + ), + ); + } + + Widget _buildToolbar( + BuildContext context, + Key mobileToolbarKey, + LeaderLink focalPoint, + ) { + if (_editor.composer.selection == null) { + return const SizedBox(); + } + + return iOSSystemPopoverEditorToolbarWithFallbackBuilder( + context, + mobileToolbarKey, + focalPoint, + _commonEditorOperations, + SuperEditorIosControlsScope.rootOf(context), + ); + } + + Widget _buildTextField() { + return Padding( + padding: const EdgeInsets.all(24), + child: _SuperTextFieldWithNativeContextMenu(), + ); + } +} + +class _SuperTextFieldWithNativeContextMenu extends StatefulWidget { + const _SuperTextFieldWithNativeContextMenu({Key? key}) : super(key: key); + + @override + State<_SuperTextFieldWithNativeContextMenu> createState() => _SuperTextFieldWithNativeContextMenuState(); +} + +class _SuperTextFieldWithNativeContextMenuState extends State<_SuperTextFieldWithNativeContextMenu> { + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(4), + ), + child: SuperIOSTextField( + padding: const EdgeInsets.all(12), + caretStyle: CaretStyle(color: Colors.red), + selectionColor: defaultSelectionColor, + handlesColor: Colors.red, + textStyleBuilder: (attributions) { + return defaultTextFieldStyleBuilder(attributions).copyWith( + color: Colors.white, + fontSize: 18, + ); + }, + hintBehavior: HintBehavior.displayHintUntilTextEntered, + hintBuilder: (_) { + return Text( + "Enter text and open toolbar", + style: TextStyle( + color: Colors.grey, + fontSize: 18, + ), + ); + }, + popoverToolbarBuilder: iOSSystemPopoverTextFieldToolbarWithFallback, + ), + ); + } +} diff --git a/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart b/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart new file mode 100644 index 0000000000..3e7219df7e --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_pattern_tags.dart @@ -0,0 +1,112 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class HashTagsFeatureDemo extends StatefulWidget { + const HashTagsFeatureDemo({super.key}); + + @override + State createState() => _HashTagsFeatureDemoState(); +} + +class _HashTagsFeatureDemoState extends State { + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + late final Editor _editor; + + late final PatternTagPlugin _hashTagPlugin; + + final _tags = []; + + @override + void initState() { + super.initState(); + + _document = MutableDocument.empty(); + _composer = MutableDocumentComposer(); + _editor = Editor( + editables: { + Editor.documentKey: _document, + Editor.composerKey: _composer, + }, + requestHandlers: [ + ...defaultRequestHandlers, + ], + ); + + _hashTagPlugin = PatternTagPlugin() // + ..tagIndex.addListener(_updateHashTagList); + } + + @override + void dispose() { + _hashTagPlugin.tagIndex.removeListener(_updateHashTagList); + super.dispose(); + } + + void _updateHashTagList() { + setState(() { + _tags + ..clear() + ..addAll(_hashTagPlugin.tagIndex.getAllTags()); + }); + } + + @override + Widget build(BuildContext context) { + return InTheLabScaffold( + content: _buildEditor(), + supplemental: _buildTagList(), + ); + } + + Widget _buildEditor() { + return SuperEditor( + editor: _editor, + shrinkWrap: true, + stylesheet: defaultStylesheet.copyWith( + inlineTextStyler: (attributions, existingStyle) { + TextStyle style = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.whereType().isNotEmpty) { + style = style.copyWith( + color: Colors.orange, + ); + } + + return style; + }, + addRulesAfter: [ + ...darkModeStyles, + ...largeTextStyles, + ], + ), + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + plugins: { + _hashTagPlugin, + }, + ); + } + + Widget _buildTagList() { + if (_tags.isEmpty) { + return const SizedBox(); + } + + return SingleChildScrollView( + child: Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: [ + for (final tag in _tags) // + Chip(label: Text(tag.tag.raw)), + ], + ), + ); + } +} diff --git a/super_editor/example/lib/demos/in_the_lab/feature_stable_tags.dart b/super_editor/example/lib/demos/in_the_lab/feature_stable_tags.dart new file mode 100644 index 0000000000..e472c860a8 --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/feature_stable_tags.dart @@ -0,0 +1,302 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/material.dart' hide ListenableBuilder; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/super_editor.dart'; + +import 'popover_list.dart'; + +class UserTagsFeatureDemo extends StatefulWidget { + const UserTagsFeatureDemo({super.key}); + + @override + State createState() => _UserTagsFeatureDemoState(); +} + +class _UserTagsFeatureDemoState extends State { + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + late final Editor _editor; + late final StableTagPlugin _userTagPlugin; + + late final FocusNode _editorFocusNode; + + final _users = []; + + @override + void initState() { + super.initState(); + + _document = MutableDocument.empty(); + _composer = MutableDocumentComposer(); + _editor = Editor( + editables: { + Editor.documentKey: _document, + Editor.composerKey: _composer, + }, + requestHandlers: [ + ...defaultRequestHandlers, + ], + ); + + _userTagPlugin = StableTagPlugin() + ..tagIndex.composingStableTag.addListener(_updateUserTagList) + ..tagIndex.addListener(_updateUserTagList); + + _editorFocusNode = FocusNode(); + } + + @override + void dispose() { + _editorFocusNode.dispose(); + + _userTagPlugin.tagIndex + ..composingStableTag.removeListener(_updateUserTagList) + ..removeListener(_updateUserTagList); + + _composer.dispose(); + _editor.dispose(); + _document.dispose(); + + super.dispose(); + } + + void _updateUserTagList() { + setState(() { + _users.clear(); + + for (final node in _document) { + if (node is! TextNode) { + continue; + } + + final userSpans = node.text.getAttributionSpansInRange( + attributionFilter: (a) => a is CommittedStableTagAttribution, + range: SpanRange(0, node.text.length - 1), + ); + + for (final userSpan in userSpans) { + _users.add(node.text.substring(userSpan.start, userSpan.end + 1)); + } + } + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + InTheLabScaffold( + content: _buildEditor(), + supplemental: _buildTagList(), + ), + if (_userTagPlugin.tagIndex.composingStableTag.value != null) + Follower.withOffset( + link: _composingLink, + offset: Offset(0, 16), + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + showWhenUnlinked: false, + child: UserSelectionPopover( + editor: _editor, + userTagPlugin: _userTagPlugin, + editorFocusNode: _editorFocusNode, + ), + ), + ], + ); + } + + Widget _buildEditor() { + return SuperEditor( + editor: _editor, + focusNode: _editorFocusNode, + shrinkWrap: true, + stylesheet: defaultStylesheet.copyWith( + inlineTextStyler: (attributions, existingStyle) { + TextStyle style = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.contains(stableTagComposingAttribution)) { + style = style.copyWith( + color: Colors.blue, + ); + } + + if (attributions.whereType().isNotEmpty) { + style = style.copyWith( + color: Colors.orange, + ); + } + + return style; + }, + addRulesAfter: [ + ...darkModeStyles, + ...largeTextStyles, + ], + ), + documentOverlayBuilders: [ + AttributedTextBoundsOverlay( + selector: (a) => a == stableTagComposingAttribution, + builder: (context, attribution) { + return Leader( + link: _composingLink, + child: const SizedBox(), + ); + }, + ), + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + plugins: { + _userTagPlugin, + }, + ); + } + + Widget _buildTagList() { + if (_users.isEmpty) { + return const SizedBox(); + } + + return Center( + child: SingleChildScrollView( + child: Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: [ + for (final tag in _users) // + Chip(label: Text(tag)), + ], + ), + ), + ); + } +} + +final _composingLink = LeaderLink(); + +class UserSelectionPopover extends StatefulWidget { + const UserSelectionPopover({ + Key? key, + required this.editor, + required this.userTagPlugin, + required this.editorFocusNode, + }) : super(key: key); + + final Editor editor; + final StableTagPlugin userTagPlugin; + final FocusNode editorFocusNode; + + @override + State createState() => _UserSelectionPopoverState(); +} + +class _UserSelectionPopoverState extends State { + final _userCandidates = [ + "miguel", + "matt", + "john", + "sally", + "bob", + "jane", + "kelly", + ]; + final _matchingUsers = []; + + bool _isLoadingMatches = false; + + @override + void initState() { + super.initState(); + + widget.userTagPlugin.tagIndex.composingStableTag.addListener(_onComposingTokenChange); + + _onComposingTokenChange(); + } + + @override + void didUpdateWidget(UserSelectionPopover oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.userTagPlugin != oldWidget.userTagPlugin) { + oldWidget.userTagPlugin.tagIndex.composingStableTag.removeListener(_onComposingTokenChange); + widget.userTagPlugin.tagIndex.composingStableTag.addListener(_onComposingTokenChange); + } + } + + @override + void dispose() { + widget.userTagPlugin.tagIndex.composingStableTag.removeListener(_onComposingTokenChange); + + super.dispose(); + } + + Future _onComposingTokenChange() async { + final composingTag = widget.userTagPlugin.tagIndex.composingStableTag.value?.token; + if (composingTag == null) { + // The user isn't composing a tag. Therefore, this popover shouldn't + // have focus. + setState(() { + _matchingUsers.clear(); + }); + return; + } + + // Simulate a load time + setState(() { + _isLoadingMatches = true; + }); + + await Future.delayed(const Duration(seconds: 1)); + + if (!mounted) { + return; + } + if (composingTag != widget.userTagPlugin.tagIndex.composingStableTag.value?.token) { + // The user changed the token. Our search results are invalid. Fizzle. + return; + } + + // Filter the user list based on the composing token. + setState(() { + _isLoadingMatches = false; + _selectMatchingUsers(composingTag); + }); + } + + void _selectMatchingUsers(String composingTag) { + _matchingUsers + ..clear() + ..addAll(_userCandidates.where((user) => user.toLowerCase().contains(composingTag.toLowerCase()))); + } + + void _onUserSelected(Object name) { + widget.editor.execute([ + FillInComposingStableTagRequest(name as String, userTagRule), + ]); + } + + void _cancelTag() { + widget.editor.execute([ + CancelComposingStableTagRequest(userTagRule), + ]); + } + + @override + Widget build(BuildContext context) { + return PopoverList( + editorFocusNode: widget.editorFocusNode, + leaderLink: _composingLink, + listItems: _matchingUsers + .map( + (userName) => PopoverListItem(id: userName, label: userName), + ) + .toList(), + isLoading: _isLoadingMatches, + onListItemSelected: _onUserSelected, + onCancelRequested: _cancelTag, + ); + } +} diff --git a/super_editor/example/lib/demos/in_the_lab/in_the_lab_scaffold.dart b/super_editor/example/lib/demos/in_the_lab/in_the_lab_scaffold.dart new file mode 100644 index 0000000000..6004e4c4a5 --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/in_the_lab_scaffold.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A scaffold to be used by all lab demos, to align the visual styles. +class InTheLabScaffold extends StatelessWidget { + const InTheLabScaffold({ + super.key, + required this.content, + this.supplemental, + this.overlay, + }); + + /// Primary demo content. + final Widget content; + + /// An (optional) supplemental control panel for the demo. + final Widget? supplemental; + + /// An (optional) widget that's displayed on top of all content in this scaffold. + final Widget? overlay; + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData.dark(), + child: Builder( + builder: (context) { + return Scaffold( + backgroundColor: const Color(0xFF222222), + body: Stack( + children: [ + Positioned.fill( + child: _buildContent(), + ), + if (overlay != null) // + Positioned.fill( + child: overlay!, + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildContent() { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth / constraints.maxHeight >= 1) { + return _buildContentForDesktop(); + } else { + return _buildContentForMobile(); + } + }, + ); + } + + Widget _buildContentForDesktop() { + return Row( + children: [ + Expanded( + child: content, + ), + if (supplemental != null) // + _buildSupplementalSidePanel(), + ], + ); + } + + Widget _buildSupplementalSidePanel() { + return Container( + width: 250, + height: double.infinity, + decoration: BoxDecoration( + border: Border(left: BorderSide(color: Colors.white.withValues(alpha: 0.1))), + ), + child: Stack( + children: [ + Center( + child: Icon( + Icons.biotech, + color: Colors.white.withValues(alpha: 0.05), + size: 84, + ), + ), + Positioned.fill( + child: Center( + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + child: supplemental!, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildContentForMobile() { + return SafeArea( + left: false, + right: false, + bottom: false, + child: Padding( + // Push the content down below the nav drawer menu button. + padding: const EdgeInsets.only(top: 24), + child: Column( + children: [ + Expanded( + child: content, + ), + if (supplemental != null) // + _buildSupplementalBottomPanel(), + ], + ), + ), + ); + } + + Widget _buildSupplementalBottomPanel() { + return Container( + width: double.infinity, + height: 200, + decoration: BoxDecoration( + border: Border(top: BorderSide(color: Colors.white.withValues(alpha: 0.1))), + ), + child: Stack( + children: [ + Center( + child: Icon( + Icons.biotech, + color: Colors.white.withValues(alpha: 0.05), + size: 84, + ), + ), + Positioned.fill( + child: Center( + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + child: supplemental!, + ), + ), + ), + ), + ], + ), + ); + } +} + +// Makes text light, for use during dark mode styling. +final darkModeStyles = [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFFCCCCCC), + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + ), + }; + }, + ), +]; + +// Makes text larger for demos. +final largeTextStyles = [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + fontSize: 32, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + fontSize: 48, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + fontSize: 42, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + fontSize: 36, + ), + }; + }, + ), +]; diff --git a/super_editor/example/lib/demos/in_the_lab/popover_list.dart b/super_editor/example/lib/demos/in_the_lab/popover_list.dart new file mode 100644 index 0000000000..46ac47e835 --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/popover_list.dart @@ -0,0 +1,250 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A popover that displays a list, and responds to key presses to navigate +/// and select an item from the list. +class PopoverList extends StatefulWidget { + const PopoverList({ + super.key, + required this.editorFocusNode, + required this.leaderLink, + required this.listItems, + this.isLoading = false, + required this.onListItemSelected, + required this.onCancelRequested, + }); + + /// [FocusNode] attached to the editor, which is expected to be an ancestor + /// of this widget. + final FocusNode editorFocusNode; + + /// Link to the widget that this popover follows. + final LeaderLink leaderLink; + + /// The items displayed in this popover list. + final List listItems; + + /// Whether the data source is currently loading results. + /// + /// This popover shows an indeterminate loading indicator when [isLoading] + /// is `true`. + final bool isLoading; + + /// Callback that's executed when the user selects a highlighted list item, + /// e.g., by pressing ENTER. + final void Function(Object id) onListItemSelected; + + /// Callback that's executed when the user indicates the desire to cancel + /// interaction, e.g., by pressing ESCAPE. + final VoidCallback onCancelRequested; + + @override + State createState() => _PopoverListState(); +} + +class _PopoverListState extends State { + late final FocusNode _focusNode; + + final _listKey = GlobalKey(); + late final ScrollController _scrollController; + int _selectedValueIndex = 0; + + @override + void initState() { + super.initState(); + + _focusNode = FocusNode(); + _scrollController = ScrollController(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // Wait until next frame to request focus, so that the parent relationship + // can be established between our focus node and the editor focus node. + _focusNode.requestFocus(); + }); + } + + @override + void didUpdateWidget(PopoverList oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.listItems.length != oldWidget.listItems.length) { + // Make sure that the user's selection index remains in bound, even when + // the list items are switched out. + _selectedValueIndex = min(_selectedValueIndex, widget.listItems.length - 1); + } + } + + @override + void dispose() { + _scrollController.dispose(); + _focusNode.dispose(); + + super.dispose(); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + final reservedKeys = { + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + LogicalKeyboardKey.escape, + }; + + final key = event.logicalKey; + if (!reservedKeys.contains(key)) { + return KeyEventResult.ignored; + } + + if (event is KeyDownEvent) { + // Only handle up events, so we don't run our behavior twice + // for the same key press. + return KeyEventResult.handled; + } + + switch (key) { + case LogicalKeyboardKey.arrowUp: + if (_selectedValueIndex > 0) { + setState(() { + // TODO: auto-scroll to new position + _selectedValueIndex -= 1; + }); + } + case LogicalKeyboardKey.arrowDown: + if (_selectedValueIndex < widget.listItems.length - 1) { + setState(() { + // TODO: auto-scroll to new position + _selectedValueIndex += 1; + }); + } + case LogicalKeyboardKey.enter: + case LogicalKeyboardKey.numpadEnter: + widget.onListItemSelected(widget.listItems[_selectedValueIndex].id); + case LogicalKeyboardKey.escape: + widget.onCancelRequested(); + } + + return KeyEventResult.handled; + } + + @override + Widget build(BuildContext context) { + return SuperEditorPopover( + popoverFocusNode: _focusNode, + editorFocusNode: widget.editorFocusNode, + onKeyEvent: _onKeyEvent, + child: GestureDetector( + onTap: () => !_focusNode.hasPrimaryFocus ? _focusNode.requestFocus() : null, + child: ListenableBuilder( + listenable: _focusNode, + builder: (context, child) { + return CupertinoPopoverMenu( + focalPoint: LeaderMenuFocalPoint(link: widget.leaderLink), + child: SizedBox( + width: 200, + height: 125, + child: _buildContent(), + ), + ); + }, + ), + ), + ); + } + + Widget _buildContent() { + if (widget.isLoading) { + return Center( + child: SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(), + ), + ); + } + + return widget.listItems.isNotEmpty ? _buildList() : _buildEmptyDisplay(); + } + + Widget _buildList() { + return SingleChildScrollView( + key: _listKey, + controller: _scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + for (int i = 0; i < widget.listItems.length; i += 1) ...[ + ColoredBox( + color: i == _selectedValueIndex && _focusNode.hasPrimaryFocus + ? Colors.white.withValues(alpha: 0.05) + : Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.account_circle, + color: Colors.white, + size: 14, + ), + const SizedBox(width: 8), + Text( + widget.listItems[i].label, + style: TextStyle( + color: Colors.white, + ), + ), + ], + ), + ), + ), + if (i < widget.listItems.length - 1) // + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Divider( + color: Colors.white.withValues(alpha: 0.2), + height: 1, + ), + ), + ], + const SizedBox(height: 8), + ], + ), + ); + } + + Widget _buildEmptyDisplay() { + return SizedBox( + width: 200, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + "NO ACTIONS", + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black.withValues(alpha: 0.5), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } +} + +class PopoverListItem { + const PopoverListItem({ + required this.id, + required this.label, + }); + + final Object id; + final String label; +} diff --git a/super_editor/example/lib/demos/in_the_lab/selected_text_colors_demo.dart b/super_editor/example/lib/demos/in_the_lab/selected_text_colors_demo.dart new file mode 100644 index 0000000000..d511b3e05d --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/selected_text_colors_demo.dart @@ -0,0 +1,307 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +class SelectedTextColorsDemo extends StatefulWidget { + const SelectedTextColorsDemo({super.key}); + + @override + State createState() => _SelectedTextColorsDemoState(); +} + +class _SelectedTextColorsDemoState extends State { + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + late final Editor _editor; + + final _regularTextColorLeaderLink = LeaderLink(); + Color _regularTextColor = const Color(0xFFCCCCCC); + + final _selectionHighlightColorLeaderLink = LeaderLink(); + Color _selectionHighlightColor = const Color(0xFFACCEF7); + + final _selectedTextColorLeaderLink = LeaderLink(); + Color _selectedTextColor = const Color(0xFF000000); + + LeaderLink? _activeColorSelectorLink; + + @override + void initState() { + super.initState(); + + _document = MutableDocument(nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "SuperEditor can dynamically change color of selected text to better contrast with the highlight."), + ), + ]); + _composer = MutableDocumentComposer(); + _editor = Editor( + editables: { + Editor.documentKey: _document, + Editor.composerKey: _composer, + }, + requestHandlers: [ + ...defaultRequestHandlers, + ], + ); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InTheLabScaffold( + content: Center( + child: _buildEditor(), + ), + supplemental: _buildControlPanel(), + overlay: _buildOverlay(), + ); + } + + Widget _buildEditor() { + return SuperEditor( + editor: _editor, + shrinkWrap: true, + stylesheet: defaultStylesheet.copyWith( + selectedTextColorStrategy: _selectedTextColorStrategy, + addRulesAfter: [ + ...darkModeStyles, + ...largeTextStyles, + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: TextStyle( + color: _regularTextColor, + ), + }; + }, + ), + ], + ), + selectionStyle: SelectionStyles(selectionColor: _selectionHighlightColor), + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + ); + } + + Color _selectedTextColorStrategy({required Color originalTextColor, required Color selectionHighlightColor}) { + return _selectedTextColor; + } + + Widget _buildControlPanel() { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildColorSelector(_regularTextColor, "REGULAR TEXT", _regularTextColorLeaderLink), + const SizedBox(height: 24), + _buildColorSelector(_selectionHighlightColor, "SELECTION HIGHLIGHT", _selectionHighlightColorLeaderLink), + const SizedBox(height: 24), + _buildColorSelector(_selectedTextColor, "SELECTED TEXT", _selectedTextColorLeaderLink), + ], + ), + ); + } + + Widget _buildOverlay() { + if (_activeColorSelectorLink == null) { + return const SizedBox(); + } + + return ColorPickerPopoverModal( + leaderLink: _activeColorSelectorLink!, + onTapOutside: () { + setState(() { + _activeColorSelectorLink = null; + }); + }, + onColorSelected: (color) { + if (_activeColorSelectorLink == _regularTextColorLeaderLink) { + setState(() { + _regularTextColor = color; + }); + } else if (_activeColorSelectorLink == _selectionHighlightColorLeaderLink) { + setState(() { + _selectionHighlightColor = color; + }); + } else if (_activeColorSelectorLink == _selectedTextColorLeaderLink) { + setState(() { + _selectedTextColor = color; + }); + } + }, + ); + } + + Widget _buildColorSelector(Color currentColor, String label, [LeaderLink? leaderLink]) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => setState(() { + _activeColorSelectorLink = leaderLink; + }), + child: _buildLargeColorCircle(currentColor, leaderLink), + ), + const SizedBox(height: 12), + Text( + label, + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + Widget _buildLargeColorCircle(Color color, [LeaderLink? leaderLink]) { + final circle = MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 4), + color: color, + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 5, offset: Offset(0, 5)), + ], + ), + ), + ); + + return leaderLink != null + ? Leader( + link: leaderLink, + child: circle, + ) + : circle; + } +} + +class ColorPickerPopoverModal extends StatelessWidget { + const ColorPickerPopoverModal({ + Key? key, + required this.leaderLink, + required this.onTapOutside, + required this.onColorSelected, + }) : super(key: key); + + final LeaderLink leaderLink; + + final VoidCallback onTapOutside; + + final void Function(Color) onColorSelected; + + @override + Widget build(BuildContext context) { + return BuildInOrder( + children: [ + GestureDetector( + onTap: onTapOutside, + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.transparent, + ), + ), + Follower.withAligner( + link: leaderLink, + aligner: StaticOffsetAligner( + leaderAnchor: Alignment.centerLeft, + followerAnchor: Alignment.centerRight, + offset: Offset(-24, 0), + ), + boundary: const ScreenFollowerBoundary(), + child: CupertinoPopoverMenu( + focalPoint: LeaderMenuFocalPoint(link: leaderLink), + backgroundColor: const Color(0xFF111111), + child: _buildColorPalette(), + ), + ), + ], + ); + } + + Widget _buildColorPalette() { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSelectableColorCircle(Colors.red), + const SizedBox(width: 12), + _buildSelectableColorCircle(Colors.orange), + const SizedBox(width: 12), + _buildSelectableColorCircle(Colors.yellow), + const SizedBox(width: 12), + _buildSelectableColorCircle(Colors.green), + const SizedBox(width: 12), + _buildSelectableColorCircle(Colors.blue), + const SizedBox(width: 12), + _buildSelectableColorCircle(Colors.purple), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSelectableColorCircle(Colors.black), + const SizedBox(width: 12), + _buildSelectableColorCircle(Colors.black54), + const SizedBox(width: 12), + _buildSelectableColorCircle(Colors.black12), + const SizedBox(width: 12), + _buildSelectableColorCircle(Colors.white24), + const SizedBox(width: 12), + _buildSelectableColorCircle(Colors.white60), + const SizedBox(width: 12), + _buildSelectableColorCircle(Colors.white), + ], + ), + ], + ), + ); + } + + Widget _buildSelectableColorCircle(Color color) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => onColorSelected(color), + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + color: color, + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.2), blurRadius: 3, offset: Offset(0, 3)), + ], + ), + ), + ), + ); + } +} diff --git a/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart b/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart new file mode 100644 index 0000000000..bff74c71ff --- /dev/null +++ b/super_editor/example/lib/demos/in_the_lab/spelling_error_decorations.dart @@ -0,0 +1,208 @@ +import 'package:example/demos/in_the_lab/in_the_lab_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class SpellingErrorDecorationsDemo extends StatefulWidget { + const SpellingErrorDecorationsDemo({super.key}); + + @override + State createState() => _SpellingErrorDecorationsDemoState(); +} + +class _SpellingErrorDecorationsDemoState extends State { + late final MutableDocument _document; + late final MutableDocumentComposer _composer; + late final Editor _editor; + + // The meaning of a `null` decoration is a desire to set + // the decoration in the stylesheet. + _DecorationType? _decoration = _DecorationType.squiggles; + + @override + void initState() { + super.initState(); + + _document = MutableDocument(nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "SuperEditor cna sytle spelling error attribtions with various decorations, including custom decorations.", + AttributedSpans( + attributions: [ + SpanMarker(attribution: spellingErrorAttribution, offset: 12, markerType: SpanMarkerType.start), + SpanMarker(attribution: spellingErrorAttribution, offset: 14, markerType: SpanMarkerType.end), + SpanMarker(attribution: spellingErrorAttribution, offset: 16, markerType: SpanMarkerType.start), + SpanMarker(attribution: spellingErrorAttribution, offset: 20, markerType: SpanMarkerType.end), + SpanMarker(attribution: spellingErrorAttribution, offset: 37, markerType: SpanMarkerType.start), + SpanMarker(attribution: spellingErrorAttribution, offset: 47, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ]); + _composer = MutableDocumentComposer(); + _editor = createDefaultDocumentEditor(document: _document, composer: _composer); + } + + @override + Widget build(BuildContext context) { + return InTheLabScaffold( + content: Center( + child: _buildEditor(), + ), + supplemental: _buildControlPanel(), + ); + } + + Widget _buildEditor() { + return SuperEditor( + editor: _editor, + shrinkWrap: true, + componentBuilders: [ + // When `_decoration` is non-null, we apply it directly to our own + // custom component to show direct application. When it's `null`, + // we specify the decoration in the stylesheet and let it flow down + // to the standard components. + // + // As a result, we're able to demo both direct and indirect application + // of the underline style. + if (_decoration != null) // + SpellingErrorParagraphComponentBuilder(_decoration!.style), + ...defaultComponentBuilders, + ], + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: [ + ...darkModeStyles, + ...largeTextStyles, + // When `_decoration` is null, place the underline in the + // stylesheet instead of applying it directly to each component. + if (_decoration == null) + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.spellingErrorUnderlineStyle: SquiggleUnderlineStyle(color: Colors.blue), + }; + }, + ), + ], + ), + documentOverlayBuilders: [ + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle().copyWith(color: Colors.redAccent), + ), + ], + ); + } + + Widget _buildControlPanel() { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildButton( + label: "From Stylesheet", + isEnabled: _decoration != null, + onPressed: () { + setState(() { + _decoration = null; + }); + }, + ), + const SizedBox(height: 16), + _buildButton( + label: "Line", + isEnabled: _decoration != _DecorationType.line, + onPressed: () { + setState(() { + _decoration = _DecorationType.line; + }); + }, + ), + const SizedBox(height: 16), + _buildButton( + label: "Dots", + isEnabled: _decoration != _DecorationType.dots, + onPressed: () { + setState(() { + _decoration = _DecorationType.dots; + }); + }, + ), + const SizedBox(height: 16), + _buildButton( + label: "Squiggles", + isEnabled: _decoration != _DecorationType.squiggles, + onPressed: () { + setState(() { + _decoration = _DecorationType.squiggles; + }); + }, + ), + ], + ), + ); + } + + Widget _buildButton({ + required String label, + bool isEnabled = true, + required VoidCallback onPressed, + }) { + return ElevatedButton( + onPressed: isEnabled ? onPressed : null, + child: Text(label), + ); + } +} + +enum _DecorationType { + line, + dots, + squiggles; + + UnderlineStyle get style { + switch (this) { + case _DecorationType.line: + return StraightUnderlineStyle( + color: Colors.red, + ); + case _DecorationType.dots: + return DottedUnderlineStyle(); + case _DecorationType.squiggles: + return SquiggleUnderlineStyle(); + } + } +} + +class SpellingErrorParagraphComponentBuilder implements ComponentBuilder { + const SpellingErrorParagraphComponentBuilder(this.underlineStyle); + + final UnderlineStyle underlineStyle; + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + final viewModel = ParagraphComponentBuilder().createViewModel(document, node) as ParagraphComponentViewModel?; + if (viewModel == null) { + return null; + } + + print("Creating paragraph view model with style: $underlineStyle"); + return viewModel + ..spellingErrorUnderlineStyle = underlineStyle + ..spellingErrors = (node as TextNode) + .text + .getAttributionSpansByFilter((a) => a == spellingErrorAttribution) + .map((a) => TextRange(start: a.start, end: a.end + 1)) // +1 because text range end is exclusive + .toList(); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + return ParagraphComponentBuilder().createComponent(componentContext, componentViewModel); + } +} diff --git a/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart new file mode 100644 index 0000000000..01995c009a --- /dev/null +++ b/super_editor/example/lib/demos/infrastructure/super_editor_item_selector.dart @@ -0,0 +1,403 @@ +import 'package:flutter/material.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A selection control, which displays a button with the selected item, and upon tap, displays a +/// popover list of available text options, from which the user can select a different +/// option. +/// +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The popover list is positioned based on the following rules: +/// +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class SuperEditorDemoTextItemSelector extends StatefulWidget { + const SuperEditorDemoTextItemSelector({ + super.key, + this.parentFocusNode, + this.boundaryKey, + this.id, + required this.items, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoTextItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + /// The currently selected value or `null` if no item is selected. + /// + /// This value is used to build the button. + final SuperEditorDemoTextItem? id; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [SuperEditorDemoTextItem.label] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(SuperEditorDemoTextItem? value) onSelected; + + @override + State createState() => _SuperEditorDemoTextItemSelectorState(); +} + +class _SuperEditorDemoTextItemSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + void _onItemSelected(SuperEditorDemoTextItem? value) { + _popoverController.close(); + widget.onSelected(value); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + boundaryKey: widget.boundaryKey, + popoverBuilder: (context) => RoundedRectanglePopoverAppearance( + child: ItemSelectionList( + focusNode: _popoverFocusNode, + value: widget.id, + items: widget.items, + itemBuilder: _buildPopoverListItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return SuperEditorPopoverButton( + padding: const EdgeInsets.only(left: 16.0, right: 24), + onTap: () => _popoverController.open(), + child: widget.id == null // + ? const SizedBox() + : Text( + widget.id!.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ); + } + + Widget _buildPopoverListItem(BuildContext context, SuperEditorDemoTextItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Text( + item.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ), + ), + ); + } +} + +/// An option that is displayed as text by a [SuperEditorDemoTextItemSelector]. +/// +/// Two [SuperEditorDemoTextItem]s are considered to be equal if they have the same [id]. +class SuperEditorDemoTextItem { + const SuperEditorDemoTextItem({ + required this.id, + required this.label, + }); + + /// The value that identifies this item. + final String id; + + /// The text that is displayed. + final String label; + + @override + bool operator ==(Object other) => + identical(this, other) || other is SuperEditorDemoTextItem && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// A selection control, which displays a button with the selected item, and upon tap, displays a +/// popover list of available icons, from which the user can select a different option. +/// +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The popover list is positioned based on the following rules: +/// +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class SuperEditorDemoIconItemSelector extends StatefulWidget { + const SuperEditorDemoIconItemSelector({ + super.key, + this.parentFocusNode, + this.boundaryKey, + this.value, + required this.items, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoIconItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + /// The currently selected value or `null` if no item is selected. + /// + /// This value is used to build the button. + final SuperEditorDemoIconItem? value; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [SuperEditorDemoIconItem.icon] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(SuperEditorDemoIconItem? value) onSelected; + + @override + State createState() => _SuperEditorDemoIconItemSelectorState(); +} + +class _SuperEditorDemoIconItemSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + popoverBuilder: (context) => RoundedRectanglePopoverAppearance( + child: ItemSelectionList( + value: widget.value, + items: widget.items, + itemBuilder: _buildItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + focusNode: _popoverFocusNode, + ), + ), + ); + } + + Widget _buildItem(BuildContext context, SuperEditorDemoIconItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Icon(item.icon), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return SuperEditorPopoverButton( + onTap: () => _popoverController.open(), + padding: const EdgeInsets.only(left: 8.0, right: 24), + child: widget.value == null // + ? const SizedBox() + : Icon(widget.value!.icon), + ); + } + + void _onItemSelected(SuperEditorDemoIconItem? value) { + _popoverController.close(); + widget.onSelected(value); + } +} + +/// An option that is displayed as an icon by a [SuperEditorDemoIconItemSelector]. +/// +/// Two [SuperEditorDemoIconItem]s are considered to be equal if they have the same [id]. +class SuperEditorDemoIconItem { + const SuperEditorDemoIconItem({ + required this.id, + required this.icon, + }); + + /// The value that identifies this item. + final String id; + + /// The icon that is displayed. + final IconData icon; + + @override + bool operator ==(Object other) => + identical(this, other) || other is SuperEditorDemoIconItem && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// A button with a center-left aligned [child] and a right aligned arrow icon. +/// +/// The arrow is displayed above the [child]. +class SuperEditorPopoverButton extends StatelessWidget { + const SuperEditorPopoverButton({ + super.key, + this.padding, + required this.onTap, + this.child, + }); + + /// Padding around the [child]. + final EdgeInsets? padding; + + /// Called when the user taps the button. + final VoidCallback onTap; + + /// The Widget displayed inside this button. + /// + /// If `null`, only the arrow is displayed. + final Widget? child; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Center( + child: Stack( + alignment: Alignment.centerLeft, + children: [ + if (child != null) // + Padding( + padding: padding ?? EdgeInsets.zero, + child: child, + ), + const Positioned( + right: 0, + child: Icon(Icons.arrow_drop_down), + ), + ], + ), + ), + ); + } +} diff --git a/super_editor/example/lib/demos/interaction_spot_checks/spot_check_scaffold.dart b/super_editor/example/lib/demos/interaction_spot_checks/spot_check_scaffold.dart new file mode 100644 index 0000000000..75fe529e18 --- /dev/null +++ b/super_editor/example/lib/demos/interaction_spot_checks/spot_check_scaffold.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A scaffold to be used by all spot check demos +class SpotCheckScaffold extends StatelessWidget { + const SpotCheckScaffold({ + super.key, + required this.content, + this.supplemental, + this.overlay, + }); + + /// Primary demo content. + final Widget content; + + /// An (optional) supplemental control panel for the demo. + final Widget? supplemental; + + /// An (optional) widget that's displayed on top of all content in this scaffold. + final Widget? overlay; + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData.dark(), + child: Builder( + builder: (context) { + return Scaffold( + backgroundColor: const Color(0xFF222222), + body: Stack( + children: [ + Positioned.fill( + child: Row( + children: [ + Expanded( + child: content, + ), + if (supplemental != null) // + _buildSupplementalPanel(), + ], + ), + ), + if (overlay != null) // + Positioned.fill( + child: overlay!, + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildSupplementalPanel() { + return Container( + width: 250, + height: double.infinity, + decoration: BoxDecoration( + border: Border(left: BorderSide(color: Colors.white.withValues(alpha: 0.1))), + ), + child: Stack( + children: [ + Center( + child: Icon( + Icons.biotech, + color: Colors.white.withValues(alpha: 0.05), + size: 84, + ), + ), + Positioned.fill( + child: Center( + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + child: supplemental!, + ), + ), + ), + ), + ], + ), + ); + } +} + +// Makes text light, for use during dark mode styling. +final darkModeStyles = [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFFCCCCCC), + fontSize: 32, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + fontSize: 48, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + fontSize: 42, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + fontSize: 36, + ), + }; + }, + ), +]; diff --git a/super_editor/example/lib/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart b/super_editor/example/lib/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart new file mode 100644 index 0000000000..1c67a00ae4 --- /dev/null +++ b/super_editor/example/lib/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart @@ -0,0 +1,189 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +import 'spot_check_scaffold.dart'; + +class ToolbarFollowingContentInLayer extends StatefulWidget { + const ToolbarFollowingContentInLayer({super.key}); + + @override + State createState() => _ToolbarFollowingContentInLayerState(); +} + +class _ToolbarFollowingContentInLayerState extends State { + final _leaderLink = LeaderLink(); + final _viewportKey = GlobalKey(); + final _leaderBoundsKey = GlobalKey(); + + final _baseContentWidth = 10.0; + final _expansionExtent = ValueNotifier(0); + + final OverlayPortalController _overlayPortalController = OverlayPortalController(); + + @override + void initState() { + super.initState(); + + // Can't call `show` during build or Flutter blows up. + WidgetsBinding.instance.addPostFrameCallback((_) { + _overlayPortalController.show(); + }); + } + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: _buildToolbarOverlay, + child: SpotCheckScaffold( + content: KeyedSubtree( + key: _viewportKey, + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverContentLayers( + overlays: [ + (_) => LeaderLayoutLayer( + leaderLink: _leaderLink, + leaderBoundsKey: _leaderBoundsKey, + ), + ], + content: (_) => SliverToBoxAdapter( + child: Column( + children: [ + ValueListenableBuilder( + valueListenable: _expansionExtent, + builder: (context, expansionExtent, _) { + return Container( + height: 12, + width: _baseContentWidth + (2 * expansionExtent) + 2, // +2 for border + decoration: BoxDecoration( + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + key: _leaderBoundsKey, + width: _baseContentWidth + expansionExtent, + height: 10, + color: Colors.white.withValues(alpha: 0.2), + ), + ), + ); + }, + ), + const SizedBox(height: 96), + TextButton( + onPressed: () { + _expansionExtent.value = Random().nextDouble() * 200; + }, + child: Text("Change Size"), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildToolbarOverlay(BuildContext context) { + return FollowerFadeOutBeyondBoundary( + link: _leaderLink, + boundary: WidgetFollowerBoundary( + boundaryKey: _viewportKey, + ), + child: Follower.withAligner( + link: _leaderLink, + aligner: CupertinoPopoverToolbarAligner(), + child: CupertinoPopoverToolbar( + focalPoint: LeaderMenuFocalPoint(link: _leaderLink), + children: [ + CupertinoPopoverToolbarMenuItem( + label: 'Cut', + onPressed: () { + print("Pressed 'Cut'"); + }, + ), + CupertinoPopoverToolbarMenuItem( + label: 'Copy', + onPressed: () { + print("Pressed 'Copy'"); + }, + ), + CupertinoPopoverToolbarMenuItem( + label: 'Paste', + onPressed: () { + print("Pressed 'Paste'"); + }, + ), + ], + ), + ), + ); + } +} + +class LeaderLayoutLayer extends ContentLayerStatefulWidget { + const LeaderLayoutLayer({ + super.key, + required this.leaderLink, + required this.leaderBoundsKey, + }); + + final LeaderLink leaderLink; + final GlobalKey leaderBoundsKey; + + @override + ContentLayerState createState() => LeaderLayoutLayerState(); +} + +class LeaderLayoutLayerState extends ContentLayerState { + @override + Rect? computeLayoutData(Element? contentElement, RenderObject? contentLayout) { + final boundsBox = widget.leaderBoundsKey.currentContext?.findRenderObject() as RenderBox?; + if (boundsBox == null) { + return null; + } + + return Rect.fromLTWH(0, 0, boundsBox.size.width, boundsBox.size.height); + } + + @override + Widget doBuild(BuildContext context, Rect? layoutData) { + if (layoutData == null) { + return const SizedBox(); + } + + return Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 24.0), + child: SizedBox( + width: layoutData.size.width * 2, + height: layoutData.size.height, + child: Align( + alignment: Alignment.centerLeft, + child: Leader( + link: widget.leaderLink, + child: SizedBox.fromSize( + size: layoutData.size, + child: ColoredBox( + color: Colors.red, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/super_editor/example/lib/demos/interaction_spot_checks/url_launching_spot_checks.dart b/super_editor/example/lib/demos/interaction_spot_checks/url_launching_spot_checks.dart new file mode 100644 index 0000000000..176c3ca3f9 --- /dev/null +++ b/super_editor/example/lib/demos/interaction_spot_checks/url_launching_spot_checks.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/super_editor.dart'; + +import 'spot_check_scaffold.dart'; + +class UrlLauncherSpotChecks extends StatefulWidget { + const UrlLauncherSpotChecks({super.key}); + + @override + State createState() => _UrlLauncherSpotChecksState(); +} + +class _UrlLauncherSpotChecksState extends State { + late final Editor _editor; + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: deserializeMarkdownToDocument(''' +# Linkification Spot Check +In this spot check, we create a variety of linkification scenarios. We expect each link to be linkified, and to take the expect action when tapped. + +## Markdown Links (with schemes) +[https://google.com](https://google.com) +[mailto:somebody@gmail.com](mailto:somebody@gmail.com) +[obsidian://open?vault=my-vault](obsidian://open?vault=my-vault) + +## Markdown Links (no schemes) +[google.com](google.com) +[somebody@gmail.com](somebody@gmail.com) + +## Pasted Links +The first set of pasted links are all pasted together within a single block of text. Then the same links are pasted with one link per line. +'''), + composer: MutableDocumentComposer(), + ); + + _pasteLinks(); + } + + Future _pasteLinks() async { + final links = ''' + +google.com https://google.com somebody@gmail.com mailto:somebody@gmail.com obsidian://open?vault=my-vault + +google.com +https://google.com +somebody@gmail.com +mailto:somebody@gmail.com +obsidian://open?vault=my-vault +'''; + + // Put the text on the clipboard. + await Clipboard.setData(ClipboardData(text: links)); + + // Place the caret at the end of the document. + // TODO: Add a startPosition and endPosition to `Document`. + _editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: _editor.document.last.id, + nodePosition: (_editor.document.last as TextNode).endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the text from the clipboard, which should include a linkification reaction. + CommonEditorOperations( + editor: _editor, + document: _editor.document, + composer: _editor.composer, + documentLayoutResolver: () => throw UnimplementedError(), + ).paste(); + } + + @override + void dispose() { + _editor.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SpotCheckScaffold( + content: SuperEditor( + editor: _editor, + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: [ + ..._darkModeStyles, + ], + ), + ), + ); + } +} + +final _darkModeStyles = [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFFCCCCCC), + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + ), + }; + }, + ), +]; diff --git a/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart b/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart new file mode 100644 index 0000000000..80162bf19e --- /dev/null +++ b/super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart @@ -0,0 +1,543 @@ +import 'package:example/demos/mobile_chat/giphy_keyboard_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_keyboard/super_keyboard.dart'; + +/// A UI with a chat message editor at the bottom, and a fake chat conversation +/// behind it. +/// +/// The following are some of the behaviors that should/need to exist in +/// this demo: +/// +/// * Chat message editor is mounted to bottom of screen and sits in front of +/// chat content/messages. +/// * When the user taps on the chat editor, it raises the keyboard, and shows +/// a formatting toolbar above the keyboard. +/// * The user can open/close panels that replace the keyboard. +/// * While the keyboard/panel is up, the user can launch a modal, which closes +/// the keyboard/panel, then upon return from the modal, the keyboard/panel re-opens. +/// * The user can press a button on the toolbar to close the keyboard. +/// * The user can tap on the chat conversation to close the keyboard. +/// * While the keyboard/panel is up, the user can navigate to another tab, and the +/// keyboard/panel automatically close, and the safe area goes away. +/// +class MobileChatDemo extends StatefulWidget { + const MobileChatDemo({super.key}); + + @override + State createState() => _MobileChatDemoState(); +} + +class _MobileChatDemoState extends State { + final FocusNode _screenFocusNode = FocusNode(); + + final FocusNode _editorFocusNode = FocusNode(); + late final Editor _editor; + + late final KeyboardPanelController<_Panel> _keyboardPanelController; + final SoftwareKeyboardController _softwareKeyboardController = SoftwareKeyboardController(); + + final _imeConnectionNotifier = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + SKLog.startLogging(); + initLoggers(Level.ALL, {keyboardPanelLog}); + + final document = MutableDocument.empty(); + final composer = MutableDocumentComposer(); + _editor = createDefaultDocumentEditor(document: document, composer: composer); + + _keyboardPanelController = KeyboardPanelController(_softwareKeyboardController); + + // Initially focus the overall screen so that the software keyboard isn't immediately + // visible. + _screenFocusNode.requestFocus(); + } + + @override + void dispose() { + _imeConnectionNotifier.dispose(); + _keyboardPanelController.dispose(); + _editorFocusNode.dispose(); + _screenFocusNode.dispose(); + super.dispose(); + } + + void _openPanelFromAppBar() { + // This action is here to verify that we can open keyboard panels + // before opening the keyboard. + + // Focus the editor and place the caret. + _editorFocusNode.requestFocus(); + final document = _editor.context.document; + _editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: document.last.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Open a panel. + _keyboardPanelController.showKeyboardPanel(_Panel.panel1); + } + + void _togglePanel(_Panel panel) { + if (_keyboardPanelController.openPanel == panel) { + _keyboardPanelController.showSoftwareKeyboard(); + } else { + _keyboardPanelController.showKeyboardPanel(panel); + } + } + + @override + Widget build(BuildContext context) { + return KeyboardScaffoldSafeAreaScope( + // ^ Share keyboard inset info throughout all subtrees. The insets will + // be reported by the subtree with the editor. Those insets might then + // be used by the subtree with page content, etc. + child: DefaultTabController( + length: 2, + child: Scaffold( + appBar: _buildAppBar(), + body: TabBarView(children: [ + _buildChatPage(), + _buildAccountPage(), + ]), + resizeToAvoidBottomInset: false, + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + actions: [ + IconButton( + icon: Icon(Icons.open_in_new), + onPressed: _openPanelFromAppBar, + ), + IconButton( + icon: Icon(Icons.settings), + onPressed: () { + Navigator.of(context).pushNamed("/second"); + }, + ), + ], + bottom: const TabBar( + tabs: [ + Tab(icon: Icon(Icons.chat)), + Tab(icon: Icon(Icons.account_circle)), + ], + ), + ); + } + + Widget _buildChatPage() { + return Column( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () { + _screenFocusNode.requestFocus(); + _keyboardPanelController.closeKeyboardAndPanel(); + }, + child: Focus( + focusNode: _screenFocusNode, + child: ColoredBox( + color: Colors.white, + child: KeyboardScaffoldSafeArea( + child: ListView.builder( + // TODO: we need a solution to ensure this chat list has bottom + // padding large enough to account for the (dynamic) height + // of the editor. + itemCount: 10, + itemBuilder: (context, index) { + return Container( + height: 150, + margin: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 16, + offset: Offset(0, 8), + ), + ], + ), + ); + }, + ), + ), + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: KeyboardScaffoldSafeArea( + child: _buildCommentEditor(), + ), + ), + ], + ), + ), + // We build a small status area to ensure that things work correctly + // when the chat editor isn't at the absolute bottom of the screen. + // Our earlier bottom inset logic didn't account for this, and broke + // in a client app, where that app had persistent bottom tabs. + _buildChatStatus(context), + ], + ); + } + + Widget _buildCommentEditor() { + return Opacity( + // Opacity is here so we can easily check what's behind it. + opacity: 1.0, + child: KeyboardPanelScaffold<_Panel>( + controller: _keyboardPanelController, + isImeConnected: _imeConnectionNotifier, + toolbarBuilder: _buildKeyboardToolbar, + fallbackPanelHeight: MediaQuery.sizeOf(context).height / 3, + keyboardPanelBuilder: (context, panel) { + switch (panel) { + case _Panel.panel1: + return Container( + color: Colors.blue, + height: double.infinity, + ); + case _Panel.panel2: + return Container( + color: Colors.red, + height: double.infinity, + ); + case _Panel.giphy: + return GiphyKeyboardPanel( + editor: _editor, + ); + default: + return const SizedBox(); + } + }, + contentBuilder: (context, isKeyboardVisible) { + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: 250), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + border: Border( + top: BorderSide(width: 1, color: Colors.grey), + left: BorderSide(width: 1, color: Colors.grey), + right: BorderSide(width: 1, color: Colors.grey), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.075), + blurRadius: 8, + spreadRadius: 4, + ), + ], + ), + padding: const EdgeInsets.only(top: 16), + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + bottom: KeyboardScaffoldSafeAreaScope.of(context).geometry.bottomPadding, + // ^ Push the editor up above the OS bottom notch. + ), + sliver: SuperEditor( + editor: _editor, + focusNode: _editorFocusNode, + softwareKeyboardController: _softwareKeyboardController, + shrinkWrap: true, + stylesheet: _chatStylesheet, + selectionPolicies: const SuperEditorSelectionPolicies( + openKeyboardWhenTappingExistingSelection: false, + clearSelectionWhenEditorLosesFocus: true, + clearSelectionWhenImeConnectionCloses: false, + ), + imePolicies: SuperEditorImePolicies( + openKeyboardOnGainPrimaryFocus: false, + openKeyboardOnSelectionChange: false, + closeKeyboardOnSelectionLost: false, + ), + isImeConnected: _imeConnectionNotifier, + contentTapDelegateFactories: [ + superEditorLaunchLinkTapHandlerFactory, + _tapToFocusEditor, + ], + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + ContentTapDelegate _tapToFocusEditor(SuperEditorContext editContext) { + return _TapToFocusEditor( + _editorFocusNode, + _keyboardPanelController, + ); + } + + Widget _buildKeyboardToolbar(BuildContext context, _Panel? openPanel) { + return Row( + children: [ + Expanded( + child: Container( + width: double.infinity, + height: 54, + color: Colors.grey.shade100, + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const SizedBox(width: 24), + const Spacer(), + _PanelButton( + icon: Icons.text_fields, + isActive: _keyboardPanelController.openPanel == _Panel.panel1, + onPressed: () => _togglePanel(_Panel.panel1), + ), + const SizedBox(width: 16), + _PanelButton( + icon: Icons.align_horizontal_left, + isActive: _keyboardPanelController.openPanel == _Panel.panel2, + onPressed: () => _togglePanel(_Panel.panel2), + ), + const SizedBox(width: 16), + _PanelButton( + icon: Icons.account_circle, + onPressed: () => _showBottomSheetWithOptions(context), + ), + const SizedBox(width: 16), + _PanelButton( + icon: Icons.gif_box_outlined, + onPressed: () => _togglePanel(_Panel.giphy), + ), + const Spacer(), + GestureDetector( + onTap: () { + _keyboardPanelController.closeKeyboardAndPanel(); + + // We need to explicitly unfocus so that the caret doesn't + // keep blinking in the editor. + _editorFocusNode.unfocus(); + }, + child: Icon(Icons.keyboard_hide), + ), + const SizedBox(width: 24), + ], + ), + ), + ), + ], + ); + } + + Widget _buildChatStatus(BuildContext context) { + return DefaultTextStyle( + style: DefaultTextStyle.of(context).style.copyWith( + color: Colors.white.withValues(alpha: 0.5), + ), + child: Container( + width: double.infinity, + color: const Color(0xFF222222), + child: SafeArea( + top: false, + left: false, + right: false, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: Text( + "There are 3 people online in this chat.", + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ); + } + + Widget _buildAccountPage() { + return ColoredBox( + color: Colors.grey.shade100, + child: Center( + child: Icon(Icons.account_circle), + ), + ); + } +} + +class _TapToFocusEditor extends ContentTapDelegate { + _TapToFocusEditor( + this.editorFocusNode, + this.keyboardPanelController, + ); + + final FocusNode editorFocusNode; + final KeyboardPanelController keyboardPanelController; + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + if (!keyboardPanelController.isSoftwareKeyboardOpen && !keyboardPanelController.isKeyboardPanelOpen) { + // The user tapped on the editor and the software keyboard isn't up, nor is a panel. + // Open the software keyboard. + editorFocusNode.requestFocus(); + keyboardPanelController.showSoftwareKeyboard(); + } + + return TapHandlingInstruction.continueHandling; + } +} + +enum _Panel { + panel1, + panel2, + giphy; +} + +class _PanelButton extends StatelessWidget { + const _PanelButton({ + required this.icon, + this.isActive = false, + required this.onPressed, + }); + + final IconData icon; + final bool isActive; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed, + child: AspectRatio( + aspectRatio: 1.0, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isActive ? Colors.grey : Colors.transparent, + ), + child: Icon(icon), + ), + ), + ); + } +} + +Stylesheet get _chatStylesheet => defaultStylesheet.copyWith( + addRulesBefore: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.maxWidth: double.infinity, + Styles.padding: const CascadingPadding.symmetric(horizontal: 24), + }; + }, + ), + ], + addRulesAfter: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: TextStyle( + fontSize: 18, + ), + }; + }, + ), + StyleRule( + BlockSelector.all.first(), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(top: 12), + }; + }, + ), + StyleRule( + BlockSelector.all.last(), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(bottom: 12), + }; + }, + ), + ], + ); + +Future _showBottomSheetWithOptions(BuildContext context) async { + return showModalBottomSheet( + context: context, + builder: (sheetContext) { + return _BottomSheetWithoutButtonOptions(); + }, + ); +} + +class _BottomSheetWithoutButtonOptions extends StatefulWidget { + const _BottomSheetWithoutButtonOptions(); + + @override + State<_BottomSheetWithoutButtonOptions> createState() => _BottomSheetWithoutButtonOptionsState(); +} + +class _BottomSheetWithoutButtonOptionsState extends State<_BottomSheetWithoutButtonOptions> { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + "This bottom sheet represents a feature in which the user wants to temporarily leave the editor, and the toolbar, to review or select an option. We expect the keyboard or panel to close when this opens, and to re-open when this closes.", + textAlign: TextAlign.left, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text("Some Options"), + ), + ], + ), + ); + } +} diff --git a/super_editor/example/lib/demos/mobile_chat/giphy_keyboard_panel.dart b/super_editor/example/lib/demos/mobile_chat/giphy_keyboard_panel.dart new file mode 100644 index 0000000000..232f755f17 --- /dev/null +++ b/super_editor/example/lib/demos/mobile_chat/giphy_keyboard_panel.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +class GiphyKeyboardPanel extends StatefulWidget { + const GiphyKeyboardPanel({ + super.key, + required this.editor, + }); + + final Editor editor; + + @override + State createState() => _GiphyKeyboardPanelState(); +} + +class _GiphyKeyboardPanelState extends State { + void _onGifPressed(String url) { + final selection = widget.editor.context.composer.selection; + if (selection == null) { + return; + } + if (selection.base.nodePosition is! TextNodePosition) { + return; + } + + widget.editor.execute([ + if (!selection.isCollapsed) // + DeleteContentRequest( + documentRange: selection.normalize(widget.editor.context.document), + ), + InsertAttributedTextRequest( + selection.base, + AttributedText("", null, { + 0: InlineNetworkImagePlaceholder(url), + }), + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: selection.base.copyWith( + nodePosition: TextNodePosition(offset: (selection.base.nodePosition as TextNodePosition).offset + 1), + ), + ), + SelectionChangeType.alteredContent, + SelectionReason.userInteraction, + ), + ]); + } + + @override + Widget build(BuildContext context) { + return GridView( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + ), + children: _giphyEmojis.map(_buildEmoji).toList(), + ); + } + + Widget _buildEmoji(String url) { + return GestureDetector( + onTap: () => _onGifPressed(url), + child: Image.network(url), + ); + } +} + +const _giphyEmojis = [ + // Thumbs up. + "https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExZHBwdGgwYXYydTJiYmV1aGZ6dWZraGZsZzIzNmNkZGdiMGJyYW40dSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/ehz3LfVj7NvpY8jYUY/giphy.webp", + // Fire. + "https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExcHpjemk5eGVza29iOHNlaHJkbWJjamxpZW82MzEwM2F4bDV1NTJkaiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/J2awouDsf23R2vo2p5/giphy.webp", + // Flexing muscle. + "https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExY3NxOWFuanlvOXk3Y2V5bmFjaGQ2Z3c4aHQ5aDI5dXlwdzRpd25uMyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/SvLQ270MWY0GpztVjo/giphy.webp", + // Clapping hands. + "https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExMjhncWRqbHBmNDVvZ3Q2ZHYzN2VkbXdoZGt0Z2d4eTI2ZTV5aTR2dyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/ZdNlmHHr7czumQPvNE/giphy.webp", + // Prayer hands. + "https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExeGszYXh0djNieXJhZW1zbjJ5NjExd3RqcHppYjB0dHgxemk0d2loMSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/WqR7WfQVrpXNcmrm81/giphy.webp", + // Heart. + "https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExZGI5bTEwcTg4dXd2a29sc3BxdTFlMHEwOHI2b3ozYWgxNHAycnBmaSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/xUA7aWi4gtOdAaX9q8/giphy.webp", + // OMG face. + "https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExYXJyOGhudTBiNm4wZnR6bTdrNGwwOWtpYWtnbXlxYml0N3ZrMDl0NSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/j2NFnjcXwni0E9KcdI/giphy.webp", + // Popping hearts. + "https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExeXd4bHEwaWRxYm41dWhhc21neDFxZ2p6YXAxY2ZnM20wcDZwaG5wcCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9cw/QUGf8x31iMVSdbNn00/giphy.webp", + // Awkward face. + "https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExMWd5dDh1djVlbWhnMmV3dzR2emtqNDdxZHZqeGNrem9zZnE5MjI3aSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/XHdW0gCDj6KiFmKFCZ/giphy.webp", + // Fuming face. + "https://media1.giphy.com/media/v1.Y2lkPTc5MGI3NjExa2ZsbmZib2hleno0dTV4dzMyMmtoZ3JocThlZHFkdnYxeHJ1b21idiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/kyQfR7MlQQ9Gb8URKG/giphy.webp", + // Angry face. + "https://media0.giphy.com/media/v1.Y2lkPTc5MGI3NjExcGFrd2ZqaGM2ZmVveHU1bWZ5b25ocDV5M2J1MG9nbGplampsOGdibSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/QU3wZZG8x351iQAbfm/giphy.webp", + // Deflate face. + "https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExNnJxeHR3MmJiNmhiYmdtaWt3bDVmcHJlbXBibzNyazluZmE4dTBnZSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/H4cBu6XqKJtGujEXll/giphy.webp", + // Dumpster fire. + "https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExOHJ1dWFtazNoeTVrcGthMHE2ZWI1aDlyOWpkZHY4MzZyMXJsZDFwbiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9ZQ/jOsoGmmWGSloPU8fMH/giphy.webp", + + // Disappointed baby. + "https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExcWo4cnV2dW1sem9hMzk5cWd5cW4zcW80ejU3YnJuZjF5amdpMGF5ZyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9cw/tr4TTyG4BjxfDioymO/giphy.webp", + // Chihuahua face. + "https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExdXR2ZGoxZDBkemJpZzdtOXBpc292OXp0d2cyMzdqemlpZnJocjdiaSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9cw/3oKIPfZAisBaUuybcs/giphy.webp", + // South Park - Randy crying. + "https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjExa3EybmZxazIwaXgzY3lpcmpjdTMwcXh0c3Fsd28wbW5xZTBhNGZ3NCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9cw/PaVz5Z1dot5FIPS50w/giphy.webp", +]; diff --git a/super_editor/example/lib/demos/scrolling/demo_task_and_chat_with_customscrollview.dart b/super_editor/example/lib/demos/scrolling/demo_task_and_chat_with_customscrollview.dart index 05a855f9a4..9714e22214 100644 --- a/super_editor/example/lib/demos/scrolling/demo_task_and_chat_with_customscrollview.dart +++ b/super_editor/example/lib/demos/scrolling/demo_task_and_chat_with_customscrollview.dart @@ -22,30 +22,32 @@ import 'package:super_editor/super_editor.dart'; /// The layout is implemented with a [CustomScrollView] and relevant `Sliver`s. class TaskAndChatWithCustomScrollViewDemo extends StatefulWidget { @override - _TaskAndChatWithCustomScrollViewDemoState createState() => _TaskAndChatWithCustomScrollViewDemoState(); + State createState() => _TaskAndChatWithCustomScrollViewDemoState(); } class _TaskAndChatWithCustomScrollViewDemoState extends State { final _scrollViewportKey = GlobalKey(); - late DocumentEditor _editor; + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _editor; @override void initState() { super.initState(); - _editor = DocumentEditor( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1234', - text: AttributedText( - text: - 'Notice that when this document is short enough, the messages are pushed to the bottom of the viewport.\n\nTry adding more content to see things scroll.'), - ) - ], - ), + _doc = MutableDocument( + nodes: [ + ParagraphNode( + id: '1234', + text: AttributedText( + 'Notice that when this document is short enough, the messages are pushed to the bottom of the viewport.\n\nTry adding more content to see things scroll.', + ), + ) + ], ); + _composer = MutableDocumentComposer(); + _editor = createDefaultDocumentEditor(document: _doc, composer: _composer); } @override @@ -60,12 +62,10 @@ class _TaskAndChatWithCustomScrollViewDemoState extends State _SliverExampleEditorState(); + State createState() => _SliverExampleEditorState(); } class _SliverExampleEditorState extends State { @@ -20,8 +20,9 @@ class _SliverExampleEditorState extends State { late ScrollController _scrollController; final _minimapKey = GlobalKey(debugLabel: "sliver_minimap"); - late Document _doc; - late DocumentEditor _docEditor; + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; @override void initState() { @@ -30,7 +31,8 @@ class _SliverExampleEditorState extends State { _scrollController = ScrollController(); _doc = _createInitialDocument(); - _docEditor = DocumentEditor(document: _doc as MutableDocument); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); } @override @@ -73,16 +75,14 @@ class _SliverExampleEditorState extends State { textAlign: TextAlign.center, ), ), - SliverToBoxAdapter( - child: SuperEditor( - editor: _docEditor, - stylesheet: defaultStylesheet.copyWith( - documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), - ), - debugPaint: const DebugPaintConfig( - gestures: _showDebugPaint, - scrollingMinimapId: _showDebugPaint ? "sliver_demo" : null, - ), + SuperEditor( + editor: _docEditor, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), + ), + debugPaint: const DebugPaintConfig( + gestures: _showDebugPaint, + scrollingMinimapId: _showDebugPaint ? "sliver_demo" : null, ), ), SliverList( @@ -122,7 +122,7 @@ class _SliverExampleEditorState extends State { right: 0, width: 200, child: ColoredBox( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), child: Center( child: ScrollingMinimap.fromRepository( key: _minimapKey, @@ -135,48 +135,43 @@ class _SliverExampleEditorState extends State { } } -Document _createInitialDocument() { +MutableDocument _createInitialDocument() { return MutableDocument( nodes: [ ImageNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), imageUrl: 'https://i.imgur.com/fSZwM7G.jpg', ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Example Document', - ), + id: Editor.createNodeId(), + text: AttributedText('Example Document'), metadata: { 'blockType': header1Attribution, }, ), - HorizontalRuleNode(id: DocumentEditor.createNodeId()), + HorizontalRuleNode(id: Editor.createNodeId()), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', ), ), ], diff --git a/super_editor/example/lib/demos/styles/demo_doc_styles.dart b/super_editor/example/lib/demos/styles/demo_doc_styles.dart index e1896715fb..fda445291e 100644 --- a/super_editor/example/lib/demos/styles/demo_doc_styles.dart +++ b/super_editor/example/lib/demos/styles/demo_doc_styles.dart @@ -5,18 +5,20 @@ class DocumentStylesDemo extends StatefulWidget { const DocumentStylesDemo({Key? key}) : super(key: key); @override - _DocumentStylesDemoState createState() => _DocumentStylesDemoState(); + State createState() => _DocumentStylesDemoState(); } class _DocumentStylesDemoState extends State { - late Document _doc; - late DocumentEditor _docEditor; + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; @override void initState() { super.initState(); _doc = _createSampleDocument(); - _docEditor = DocumentEditor(document: _doc as MutableDocument); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); } Stylesheet _createStyles() { @@ -29,9 +31,9 @@ class _DocumentStylesDemoState extends State { BlockSelector.all, (doc, docNode) { return { - "maxWidth": 640.0, - "padding": const CascadingPadding.symmetric(horizontal: 24), - "textStyle": const TextStyle( + Styles.maxWidth: 640.0, + Styles.padding: const CascadingPadding.symmetric(horizontal: 24), + Styles.textStyle: const TextStyle( color: Colors.black, fontSize: 18, height: 1.4, @@ -43,8 +45,8 @@ class _DocumentStylesDemoState extends State { const BlockSelector("header1"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 40), - "textStyle": const TextStyle( + Styles.padding: const CascadingPadding.only(top: 40), + Styles.textStyle: const TextStyle( color: Color(0xFF333333), fontSize: 38, fontWeight: FontWeight.bold, @@ -56,8 +58,8 @@ class _DocumentStylesDemoState extends State { const BlockSelector("header2"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 32), - "textStyle": const TextStyle( + Styles.padding: const CascadingPadding.only(top: 32), + Styles.textStyle: const TextStyle( color: Color(0xFF333333), fontSize: 26, fontWeight: FontWeight.bold, @@ -69,8 +71,8 @@ class _DocumentStylesDemoState extends State { const BlockSelector("header3"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 28), - "textStyle": const TextStyle( + Styles.padding: const CascadingPadding.only(top: 28), + Styles.textStyle: const TextStyle( color: Color(0xFF333333), fontSize: 22, fontWeight: FontWeight.bold, @@ -82,7 +84,7 @@ class _DocumentStylesDemoState extends State { const BlockSelector("paragraph"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 24), + Styles.padding: const CascadingPadding.only(top: 24), }; }, ), @@ -90,7 +92,7 @@ class _DocumentStylesDemoState extends State { const BlockSelector("paragraph").after("header1"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 0), + Styles.padding: const CascadingPadding.only(top: 0), }; }, ), @@ -98,7 +100,7 @@ class _DocumentStylesDemoState extends State { const BlockSelector("paragraph").after("header2"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 0), + Styles.padding: const CascadingPadding.only(top: 0), }; }, ), @@ -106,7 +108,7 @@ class _DocumentStylesDemoState extends State { const BlockSelector("paragraph").after("header3"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 0), + Styles.padding: const CascadingPadding.only(top: 0), }; }, ), @@ -114,7 +116,7 @@ class _DocumentStylesDemoState extends State { const BlockSelector("listItem"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 24), + Styles.padding: const CascadingPadding.only(top: 24), }; }, ), @@ -122,7 +124,7 @@ class _DocumentStylesDemoState extends State { BlockSelector.all.last(), (doc, docNode) { return { - "padding": const CascadingPadding.only(bottom: 96), + Styles.padding: const CascadingPadding.only(bottom: 96), }; }, ), @@ -144,42 +146,42 @@ MutableDocument _createSampleDocument() { return MutableDocument( nodes: [ ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Header 1'), + id: Editor.createNodeId(), + text: AttributedText('Header 1'), metadata: {'blockType': header1Attribution}, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Header 2'), + id: Editor.createNodeId(), + text: AttributedText('Header 2'), metadata: {'blockType': header2Attribution}, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Header 3'), + id: Editor.createNodeId(), + text: AttributedText('Header 3'), metadata: {'blockType': header3Attribution}, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Header 4'), + id: Editor.createNodeId(), + text: AttributedText('Header 4'), metadata: {'blockType': header4Attribution}, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Header 5'), + id: Editor.createNodeId(), + text: AttributedText('Header 5'), metadata: {'blockType': header5Attribution}, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Header 6'), + id: Editor.createNodeId(), + text: AttributedText('Header 6'), metadata: {'blockType': header6Attribution}, ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'This is a paragraph of regular text'), + id: Editor.createNodeId(), + text: AttributedText('This is a paragraph of regular text'), ), ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'This is a blockquote'), + id: Editor.createNodeId(), + text: AttributedText('This is a blockquote'), metadata: {'blockType': blockquoteAttribution}, ), ], diff --git a/super_editor/example/lib/demos/super_document/example_document.dart b/super_editor/example/lib/demos/super_document/example_document.dart deleted file mode 100644 index 2c8238e97a..0000000000 --- a/super_editor/example/lib/demos/super_document/example_document.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:flutter/rendering.dart'; -import 'package:super_editor/super_editor.dart'; - -Document createInitialDocument() { - return MutableDocument( - nodes: [ - ImageNode( - id: "1", - imageUrl: 'https://i.ibb.co/5nvRdx1/flutter-horizon.png', - metadata: const SingleColumnLayoutComponentStyles( - width: double.infinity, - padding: EdgeInsets.zero, - ).toMetadata(), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Welcome to Super Editor 💙 🚀', - ), - metadata: { - 'blockType': header1Attribution, - }, - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: - "Super Editor is a toolkit to help you build document editors, document layouts, text fields, and more.", - ), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Ready-made solutions 📦', - ), - metadata: { - 'blockType': header2Attribution, - }, - ), - ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'SuperEditor is a ready-made, configurable document editing experience.', - ), - ), - ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'SuperTextField is a ready-made, configurable text field.', - ), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Quickstart 🚀', - ), - metadata: { - 'blockType': header2Attribution, - }, - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'To get started with your own editing experience, take the following steps:'), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: - "Now, you're off to the races! SuperEditor renders your document, and lets you select, insert, and delete content.", - ), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Explore the toolkit 🔎', - ), - metadata: { - 'blockType': header2Attribution, - }, - ), - ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: "Use MutableDocument as an in-memory representation of a document.", - ), - ), - ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: "Implement your own document data store by implementing the Document api.", - ), - ), - ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: "Implement your down DocumentLayout to position and size document components however you'd like.", - ), - ), - ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: "Use SuperSelectableText to paint text with selection boxes and a caret.", - ), - ), - ListItemNode.unordered( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Use AttributedText to quickly and easily apply metadata spans to a string.', - ), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: - "We hope you enjoy using Super Editor. Let us know what you're building, and please file issues for any bugs that you find.", - ), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: "", - ), - ), - ], - ); -} diff --git a/super_editor/example/lib/demos/super_document/demo_super_reader.dart b/super_editor/example/lib/demos/super_reader/demo_super_reader.dart similarity index 58% rename from super_editor/example/lib/demos/super_document/demo_super_reader.dart rename to super_editor/example/lib/demos/super_reader/demo_super_reader.dart index 7b2829b403..819a594bda 100644 --- a/super_editor/example/lib/demos/super_document/demo_super_reader.dart +++ b/super_editor/example/lib/demos/super_reader/demo_super_reader.dart @@ -12,23 +12,42 @@ class SuperReaderDemo extends StatefulWidget { } class _SuperReaderDemoState extends State { - late final Document _document; - final _selection = ValueNotifier(null); + late final Editor _editor; + final _selectionLayerLinks = SelectionLayerLinks(); + late MagnifierAndToolbarController _overlayController; + late final SuperReaderIosControlsController _iosReaderControlsController; @override void initState() { super.initState(); - _document = createInitialDocument(); + + _editor = createDefaultDocumentEditor( + document: createInitialDocument(), + composer: MutableDocumentComposer(), + ); + + _overlayController = MagnifierAndToolbarController(); + _iosReaderControlsController = SuperReaderIosControlsController( + toolbarBuilder: _buildToolbar, + ); + } + + @override + void dispose() { + _iosReaderControlsController.dispose(); + _editor.dispose(); + + super.dispose(); } void _copy() { - if (_selection.value == null) { + if (_editor.composer.selection == null) { return; } final textToCopy = _textInSelection( - document: _document, - documentSelection: _selection.value!, + document: _editor.document, + documentSelection: _editor.composer.selection!, ); // TODO: figure out a general approach for asynchronous behaviors that // need to be carried out in response to user input. @@ -96,35 +115,54 @@ class _SuperReaderDemoState extends State { } void _selectAll() { - final nodes = _document.nodes; - if (nodes.isEmpty) { + if (_editor.document.isEmpty) { return; } - _selection.value = DocumentSelection( - base: DocumentPosition( - nodeId: nodes.first.id, - nodePosition: nodes.first.beginningPosition, - ), - extent: DocumentPosition( - nodeId: nodes.last.id, - nodePosition: nodes.last.endPosition, + _editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: _editor.document.first.id, + nodePosition: _editor.document.first.beginningPosition, + ), + extent: DocumentPosition( + nodeId: _editor.document.last.id, + nodePosition: _editor.document.last.endPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, ), - ); + ]); } @override Widget build(BuildContext context) { - return SuperReader( - document: _document, - selection: _selection, - androidToolbarBuilder: (_) => AndroidTextEditingFloatingToolbar( - onCopyPressed: _copy, - onSelectAllPressed: _selectAll, - ), - iOSToolbarBuilder: (_) => IOSTextEditingFloatingToolbar( - onCopyPressed: _copy, + return SuperReaderIosControlsScope( + controller: _iosReaderControlsController, + child: SuperReader( + editor: _editor, + overlayController: _overlayController, + selectionLayerLinks: _selectionLayerLinks, + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: [ + taskStyles, + ], + ), + androidToolbarBuilder: (_) => AndroidTextEditingFloatingToolbar( + onCopyPressed: _copy, + onSelectAllPressed: _selectAll, + ), ), ); } + + Widget _buildToolbar(context, mobileToolbarKey, focalPoint) { + return IOSTextEditingFloatingToolbar( + key: mobileToolbarKey, + focalPoint: focalPoint, + onCopyPressed: _copy, + ); + } } diff --git a/super_editor/example/lib/demos/super_document/demo_read_only_scrolling_document.dart b/super_editor/example/lib/demos/super_reader/demo_super_reader_custom_scrollview.dart similarity index 51% rename from super_editor/example/lib/demos/super_document/demo_read_only_scrolling_document.dart rename to super_editor/example/lib/demos/super_reader/demo_super_reader_custom_scrollview.dart index 0905f670a7..a5e8d23b02 100644 --- a/super_editor/example/lib/demos/super_document/demo_read_only_scrolling_document.dart +++ b/super_editor/example/lib/demos/super_reader/demo_super_reader_custom_scrollview.dart @@ -9,12 +9,12 @@ import 'package:super_editor/super_editor.dart'; /// /// The demo begins with a collapsing tool bar at the top, followed by /// the read-only document, and then an infinite number of list items. -class ReadOnlyCustomScrollViewDemo extends StatefulWidget { +class SuperReaderCustomScrollViewDemo extends StatefulWidget { @override - _ReadOnlyCustomScrollViewDemoState createState() => _ReadOnlyCustomScrollViewDemoState(); + State createState() => _SuperReaderCustomScrollViewDemoState(); } -class _ReadOnlyCustomScrollViewDemoState extends State { +class _SuperReaderCustomScrollViewDemoState extends State { late Document _doc; @override @@ -33,9 +33,7 @@ class _ReadOnlyCustomScrollViewDemoState extends State createState() => _SuperReaderListViewDemoState(); +} + +class _SuperReaderListViewDemoState extends State { + late final Editor _editor; + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: _createInitialDocument(), + composer: MutableDocumentComposer(), + ); + } + + @override + void dispose() { + _editor.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListView( + children: [ + _buildListItem(0), + _buildListItem(1), + SuperReader( + editor: _editor, + shrinkWrap: true, + ), + _buildListItem(2), + _buildListItem(3), + ], + ); + } + + Widget _buildListItem(int index) { + return ListTile( + title: Text('$index'), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'ListView element tapped with index $index.', + ), + duration: const Duration(milliseconds: 500), + ), + ); + }, + ); + } +} + +MutableDocument _createInitialDocument() { + return MutableDocument( + nodes: [ + ImageNode( + id: Editor.createNodeId(), + imageUrl: 'https://i.imgur.com/fSZwM7G.jpg', + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Example Document'), + metadata: { + 'blockType': header1Attribution, + }, + ), + HorizontalRuleNode(id: Editor.createNodeId()), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ], + ); +} diff --git a/super_editor/example/lib/demos/super_reader/example_document.dart b/super_editor/example/lib/demos/super_reader/example_document.dart new file mode 100644 index 0000000000..a303c7d47d --- /dev/null +++ b/super_editor/example/lib/demos/super_reader/example_document.dart @@ -0,0 +1,151 @@ +import 'package:flutter/rendering.dart'; +import 'package:super_editor/super_editor.dart'; + +MutableDocument createInitialDocument() { + return MutableDocument( + nodes: [ + ImageNode( + id: "1", + imageUrl: 'https://i.ibb.co/5nvRdx1/flutter-horizon.png', + metadata: const SingleColumnLayoutComponentStyles( + width: double.infinity, + padding: EdgeInsets.zero, + ).toMetadata(), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Welcome to Super Editor 💙 🚀'), + metadata: { + 'blockType': header1Attribution, + }, + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "Super Editor is a toolkit to help you build document editors, document layouts, text fields, and more.", + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Ready-made solutions 📦'), + metadata: { + 'blockType': header2Attribution, + }, + ), + ListItemNode.unordered( + id: Editor.createNodeId(), + text: AttributedText('SuperEditor is a ready-made, configurable document editing experience.'), + ), + ListItemNode.unordered( + id: Editor.createNodeId(), + text: AttributedText('SuperTextField is a ready-made, configurable text field.'), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Quickstart 🚀'), + metadata: { + 'blockType': header2Attribution, + }, + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('To get started with your own editing experience, take the following steps:'), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "Now, you're off to the races! SuperEditor renders your document, and lets you select, insert, and delete content.", + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Explore the toolkit 🔎'), + metadata: { + 'blockType': header2Attribution, + }, + ), + ListItemNode.unordered( + id: Editor.createNodeId(), + text: AttributedText("Use MutableDocument as an in-memory representation of a document."), + ), + ListItemNode.unordered( + id: Editor.createNodeId(), + text: AttributedText("Implement your own document data store by implementing the Document api."), + ), + ListItemNode.unordered( + id: Editor.createNodeId(), + text: AttributedText( + "Implement your down DocumentLayout to position and size document components however you'd like.", + ), + ), + ListItemNode.unordered( + id: Editor.createNodeId(), + text: AttributedText("Use SuperSelectableText to paint text with selection boxes and a caret."), + ), + ListItemNode.unordered( + id: Editor.createNodeId(), + text: AttributedText('Use AttributedText to quickly and easily apply metadata spans to a string.'), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Quickstart 🚀'), + metadata: { + 'blockType': header2Attribution, + }, + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('To get started with your own editing experience, take the following steps:'), + ), + TaskNode( + id: Editor.createNodeId(), + isComplete: true, + text: AttributedText( + 'Create and configure your document, for example, by creating a new MutableDocument.', + ), + ), + TaskNode( + id: Editor.createNodeId(), + isComplete: false, + text: AttributedText( + "If you want programmatic control over the user's selection and styles, create a DocumentComposer.", + ), + ), + TaskNode( + id: Editor.createNodeId(), + isComplete: false, + text: AttributedText( + "Build a SuperEditor widget in your widget tree, configured with your Document and (optionally) your DocumentComposer.", + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "We hope you enjoy using Super Editor. Let us know what you're building, and please file issues for any bugs that you find.", + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "Built by the Flutter Bounty Hunters", + AttributedSpans(attributions: [ + SpanMarker( + attribution: LinkAttribution.fromUri(Uri.parse("https://flutterbountyhunters.com")), + offset: 13, + markerType: SpanMarkerType.start), + SpanMarker( + attribution: LinkAttribution.fromUri(Uri.parse("https://flutterbountyhunters.com")), + offset: 34, + markerType: SpanMarkerType.end), + ]), + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "", + ), + ), + ], + ); +} diff --git a/super_editor/example/lib/demos/supertextfield/_emojis_demo.dart b/super_editor/example/lib/demos/supertextfield/_emojis_demo.dart index 673da05ed4..18353c1eeb 100644 --- a/super_editor/example/lib/demos/supertextfield/_emojis_demo.dart +++ b/super_editor/example/lib/demos/supertextfield/_emojis_demo.dart @@ -1,3 +1,4 @@ +import 'package:example/demos/supertextfield/demo_text_styles.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/super_editor.dart'; @@ -12,7 +13,7 @@ class EmojisTextFieldDemo extends StatefulWidget { final TextAffinity direction; @override - _EmojisTextFieldDemoState createState() => _EmojisTextFieldDemoState(); + State createState() => _EmojisTextFieldDemoState(); } class _EmojisTextFieldDemoState extends State with TickerProviderStateMixin { @@ -49,23 +50,21 @@ class _EmojisTextFieldDemoState extends State with TickerPr void _startDemo() { _textFieldController ..selection = const TextSelection.collapsed(offset: 0) - ..text = AttributedText( - text: 'turtle 🐢 bomb 💣 skull ☠', - ); + ..text = AttributedText('turtle 🐢 bomb 💣 skull ☠'); if (widget.direction == TextAffinity.upstream) { // simulate pressing backspace _demoRobot - ..insertCaretAt(TextPosition(offset: _textFieldController.text.text.length)) + ..insertCaretAt(TextPosition(offset: _textFieldController.text.length)) ..pause(const Duration(seconds: 1)) - ..backspaceCharacters(_textFieldController.text.text.length) + ..backspaceCharacters(_textFieldController.text.length) ..start(); } else { // simulate pressing delete _demoRobot ..insertCaretAt(const TextPosition(offset: 0)) ..pause(const Duration(seconds: 1)) - ..deleteCharacters(_textFieldController.text.text.length) + ..deleteCharacters(_textFieldController.text.length) ..start(); } } @@ -121,6 +120,7 @@ class _EmojisTextFieldDemoState extends State with TickerPr ); }, hintBehavior: HintBehavior.displayHintUntilTextEntered, + textStyleBuilder: demoTextStyleBuilder, minLines: 1, maxLines: 1, ), diff --git a/super_editor/example/lib/demos/supertextfield/_expanding_multi_line_demo.dart b/super_editor/example/lib/demos/supertextfield/_expanding_multi_line_demo.dart index aacb088c06..825ce8d90c 100644 --- a/super_editor/example/lib/demos/supertextfield/_expanding_multi_line_demo.dart +++ b/super_editor/example/lib/demos/supertextfield/_expanding_multi_line_demo.dart @@ -1,3 +1,4 @@ +import 'package:example/demos/supertextfield/demo_text_styles.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/super_editor.dart'; @@ -5,22 +6,13 @@ import '_robot.dart'; class ExpandingMultiLineTextFieldDemo extends StatefulWidget { @override - _ExpandingMultiLineTextFieldDemoState createState() => _ExpandingMultiLineTextFieldDemoState(); + State createState() => _ExpandingMultiLineTextFieldDemoState(); } class _ExpandingMultiLineTextFieldDemoState extends State with TickerProviderStateMixin { final _textFieldController = AttributedTextEditingController( - text: AttributedText( - // text: - // 'Super Editor is an open source text editor for Flutter projects.\n\nThis is paragraph 2\n\nThis is paragraph 3', - // spans: AttributedSpans( - // attributions: [ - // SpanMarker(attribution: 'bold', offset: 0, markerType: SpanMarkerType.start), - // SpanMarker(attribution: 'bold', offset: 11, markerType: SpanMarkerType.end), - // ], - // ), - ), + text: AttributedText(), ); GlobalKey? _textKey; @@ -56,11 +48,11 @@ class _ExpandingMultiLineTextFieldDemoState extends State _InteractiveTextFieldDemoState(); + State createState() => _InteractiveTextFieldDemoState(); } class _InteractiveTextFieldDemoState extends State { + static const _tapRegionGroupId = "desktop"; + final _textFieldController = AttributedTextEditingController( text: AttributedText( - text: 'Super Editor is an open source text editor for Flutter projects.', - spans: AttributedSpans(attributions: [ + 'Super Editor is an open source text editor for Flutter projects.', + AttributedSpans( + attributions: [ const SpanMarker(attribution: brandAttribution, offset: 0, markerType: SpanMarkerType.start), const SpanMarker(attribution: brandAttribution, offset: 11, markerType: SpanMarkerType.end), const SpanMarker(attribution: flutterAttribution, offset: 47, markerType: SpanMarkerType.start), const SpanMarker(attribution: flutterAttribution, offset: 53, markerType: SpanMarkerType.end), - ])), + ], + ), + ), ); - OverlayEntry? _popupEntry; + final _popupOverlayController = OverlayPortalController(); Offset _popupOffset = Offset.zero; FocusNode? _focusNode; @@ -39,104 +43,53 @@ class _InteractiveTextFieldDemoState extends State { super.dispose(); } - void _onRightClick( - BuildContext textFieldContext, AttributedTextEditingController textController, Offset localOffset) { + TapHandlingInstruction _onRightClick(SuperTextFieldGestureDetails details) { // Only show menu if some text is selected - if (textController.selection.isCollapsed) { - return; + if (details.textController.selection.isCollapsed) { + return TapHandlingInstruction.continueHandling; } - final overlay = Overlay.of(context)!; - final overlayBox = overlay.context.findRenderObject() as RenderBox?; - final textFieldBox = textFieldContext.findRenderObject() as RenderBox; - _popupOffset = textFieldBox.localToGlobal(localOffset, ancestor: overlayBox); - - if (_popupEntry == null) { - _popupEntry = OverlayEntry(builder: (context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTapDown: (_) { - _closePopup(); - }, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.transparent, - child: Stack( - children: [ - Positioned( - left: _popupOffset.dx, - top: _popupOffset.dy, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 5, - offset: const Offset(3, 3), - ), - ], - ), - child: Column( - children: [ - TextButton( - onPressed: () { - Clipboard.setData(ClipboardData( - text: textController.selection.textInside(textController.text.text), - )); - _closePopup(); - }, - child: const Text('Copy'), - ), - ], - ), - ), - ), - ], - ), - ), - ); - }); + final overlay = Overlay.of(context); + final overlayBox = overlay.context.findRenderObject() as RenderBox; - overlay.insert(_popupEntry!); - } else { - _popupEntry!.markNeedsBuild(); - } + _popupOffset = overlayBox.globalToLocal(details.globalOffset); + + _popupOverlayController.show(); + + return TapHandlingInstruction.halt; } void _closePopup() { - if (_popupEntry == null) { - return; - } - - _popupEntry!.remove(); - _popupEntry = null; + _popupOverlayController.hide(); } @override Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - // Remove focus from text field when the user taps anywhere else. - _focusNode!.unfocus(); - }, - child: Center( - child: SizedBox( - width: 400, - child: GestureDetector( - onTap: () { - // no-op. Prevents unfocus from happening when text field is tapped. - }, + return OverlayPortal( + controller: _popupOverlayController, + overlayChildBuilder: _buildPopover, + child: TapRegion( + groupId: _tapRegionGroupId, + onTapOutside: (_) { + // Remove focus from text field when the user taps anywhere else. + _focusNode!.unfocus(); + }, + child: Center( + child: SizedBox( + width: 400, child: SizedBox( width: double.infinity, child: SuperDesktopTextField( - textController: _textFieldController, focusNode: _focusNode, - textStyleBuilder: _textStyleBuilder, + tapRegionGroupId: _tapRegionGroupId, + textController: _textFieldController, + inputSource: TextInputSource.ime, + textStyleBuilder: demoTextStyleBuilder, + blinkTimingMode: BlinkTimingMode.timer, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + tapHandlers: [ + _SuperTextFieldRightClickListener(rightClickHandler: _onRightClick), + ], decorationBuilder: (context, child) { return Container( decoration: BoxDecoration( @@ -160,7 +113,6 @@ class _InteractiveTextFieldDemoState extends State { hintBehavior: HintBehavior.displayHintUntilTextEntered, minLines: 5, maxLines: 5, - onRightClick: _onRightClick, ), ), ), @@ -169,24 +121,70 @@ class _InteractiveTextFieldDemoState extends State { ); } - TextStyle _textStyleBuilder(Set attributions) { - TextStyle textStyle = const TextStyle( - color: Colors.black, - fontSize: 14, + Widget _buildPopover(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTapDown: (_) { + _closePopup(); + }, + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.transparent, + child: Stack( + children: [ + Positioned( + left: _popupOffset.dx, + top: _popupOffset.dy, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 5, + offset: const Offset(3, 3), + ), + ], + ), + child: Column( + children: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData( + text: _textFieldController.selection.textInside( + _textFieldController.text.toPlainText(includePlaceholders: false), + ), + )); + _closePopup(); + }, + child: const Text('Copy'), + ), + ], + ), + ), + ), + ], + ), + ), ); + } +} - if (attributions.contains(brandAttribution)) { - textStyle = textStyle.copyWith( - color: Colors.red, - fontWeight: FontWeight.bold, - ); - } - if (attributions.contains(flutterAttribution)) { - textStyle = textStyle.copyWith( - color: Colors.blue, - ); - } +/// A [SuperTextFieldTapHandler] that listens for right clicks and invokes the +/// [rightClickHandler] when a right click happens. +class _SuperTextFieldRightClickListener extends SuperTextFieldTapHandler { + _SuperTextFieldRightClickListener({ + required this.rightClickHandler, + }); + + final RightClickHandler rightClickHandler; - return textStyle; + @override + TapHandlingInstruction onSecondaryTapUp(SuperTextFieldGestureDetails details) { + return rightClickHandler(details); } } + +typedef RightClickHandler = TapHandlingInstruction Function(SuperTextFieldGestureDetails details); diff --git a/super_editor/example/lib/demos/supertextfield/_mobile_style_bar.dart b/super_editor/example/lib/demos/supertextfield/_mobile_style_bar.dart index 4c444eb91c..7e71068d48 100644 --- a/super_editor/example/lib/demos/supertextfield/_mobile_style_bar.dart +++ b/super_editor/example/lib/demos/supertextfield/_mobile_style_bar.dart @@ -16,7 +16,7 @@ class MobileStyleBar extends StatelessWidget { final selection = textController.selection; return textController.text.hasAttributionsThroughout( attributions: {attribution}, - range: SpanRange(start: selection.start, end: selection.end - 1), + range: SpanRange(selection.start, selection.end - 1), ); } } diff --git a/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart b/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart index 2fa6368c9f..e97be4b6c5 100644 --- a/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart +++ b/super_editor/example/lib/demos/supertextfield/_mobile_textfield_demo.dart @@ -9,14 +9,18 @@ class MobileSuperTextFieldDemo extends StatefulWidget { const MobileSuperTextFieldDemo({ Key? key, required this.initialText, + required this.textFieldFocusNode, + required this.textFieldTapRegionGroupId, required this.createTextField, }) : super(key: key); final AttributedText initialText; + final FocusNode textFieldFocusNode; + final String textFieldTapRegionGroupId; final Widget Function(MobileTextFieldDemoConfig) createTextField; @override - _MobileSuperTextFieldDemoState createState() => _MobileSuperTextFieldDemoState(); + State createState() => _MobileSuperTextFieldDemoState(); } class _MobileSuperTextFieldDemoState extends State { @@ -24,6 +28,7 @@ class _MobileSuperTextFieldDemoState extends State { late ImeAttributedTextEditingController _textController; _TextFieldSizeMode _sizeMode = _TextFieldSizeMode.short; + late MobileTextFieldDemoConfig _demoConfig; bool _showDebugPaint = false; @@ -33,16 +38,18 @@ class _MobileSuperTextFieldDemoState extends State { initLoggers(Level.FINEST, { // textFieldLog, - scrollingTextFieldLog, + // scrollingTextFieldLog, // imeTextFieldLog, // androidTextFieldLog, }); _textController = ImeAttributedTextEditingController( controller: AttributedTextEditingController( - text: widget.initialText, + text: widget.initialText.copyText(0), ), ); + + _demoConfig = _createDemoConfig(); } @override @@ -62,15 +69,23 @@ class _MobileSuperTextFieldDemoState extends State { int? maxLines; switch (_sizeMode) { case _TextFieldSizeMode.singleLine: + _textController.text = widget.initialText.copyText(0); minLines = 1; maxLines = 1; break; case _TextFieldSizeMode.short: + _textController.text = widget.initialText.copyText(0); maxLines = 5; break; case _TextFieldSizeMode.tall: + _textController.text = widget.initialText.copyText(0); // no-op break; + case _TextFieldSizeMode.empty: + _textController.text = AttributedText(); + minLines = 1; + maxLines = 1; + break; } return MobileTextFieldDemoConfig( @@ -89,18 +104,16 @@ class _MobileSuperTextFieldDemoState extends State { children: [ Expanded( child: Scaffold( - body: GestureDetector( - onTap: () { - _screenFocusNode.requestFocus(); - }, - behavior: HitTestBehavior.translucent, - child: Focus( - focusNode: _screenFocusNode, - child: SafeArea( - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 48), - child: widget.createTextField(_createDemoConfig()), + body: Focus( + focusNode: _screenFocusNode, + child: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: TapRegion( + groupId: widget.textFieldTapRegionGroupId, + onTapOutside: (_) => widget.textFieldFocusNode.unfocus(), + child: widget.createTextField(_demoConfig), ), ), ), @@ -115,11 +128,14 @@ class _MobileSuperTextFieldDemoState extends State { child: const Icon(Icons.bug_report), ), bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, currentIndex: _sizeMode == _TextFieldSizeMode.singleLine ? 0 : _sizeMode == _TextFieldSizeMode.short ? 1 - : 2, + : _sizeMode == _TextFieldSizeMode.tall + ? 2 + : 3, items: const [ BottomNavigationBarItem( icon: Icon(Icons.short_text), @@ -133,6 +149,10 @@ class _MobileSuperTextFieldDemoState extends State { icon: Icon(Icons.wrap_text_rounded), label: 'Tall', ), + BottomNavigationBarItem( + icon: Icon(Icons.short_text), + label: 'Empty', + ), ], onTap: (int newIndex) { setState(() { @@ -142,7 +162,11 @@ class _MobileSuperTextFieldDemoState extends State { _sizeMode = _TextFieldSizeMode.short; } else if (newIndex == 2) { _sizeMode = _TextFieldSizeMode.tall; + } else if (newIndex == 3) { + _sizeMode = _TextFieldSizeMode.empty; } + + _demoConfig = _createDemoConfig(); }); }, ), @@ -178,6 +202,7 @@ enum _TextFieldSizeMode { singleLine, short, tall, + empty, } class MobileTextFieldDemoConfig { diff --git a/super_editor/example/lib/demos/supertextfield/_robot.dart b/super_editor/example/lib/demos/supertextfield/_robot.dart index eec9886eeb..5e5d58607a 100644 --- a/super_editor/example/lib/demos/supertextfield/_robot.dart +++ b/super_editor/example/lib/demos/supertextfield/_robot.dart @@ -138,8 +138,9 @@ class TypeTextCommand implements RobotCommand { focusNode!.requestFocus(); - for (int i = 0; i < textToType.text.length; ++i) { - _typeCharacter(textController, i); + final plainText = textToType.toPlainText(); + for (int i = 0; i < plainText.length; ++i) { + _typeCharacter(textController, i, plainText[i]); await _waitForCharacterDelay(); @@ -149,9 +150,9 @@ class TypeTextCommand implements RobotCommand { } } - void _typeCharacter(AttributedTextEditingController textController, int offset) { + void _typeCharacter(AttributedTextEditingController textController, int offset, String character) { textController.text = textController.text.insertString( - textToInsert: textToType.text[offset], // TODO: support insertion of attributed text + textToInsert: character, startOffset: textController.selection.extentOffset, ); @@ -222,8 +223,8 @@ class DeleteCharactersCommand implements RobotCommand { focusNode!.requestFocus(); int currentOffset = textController.selection.extentOffset; - final finalLength = textController.text.text.length - characterCount; - while (textController.text.text.length > finalLength) { + final finalLength = textController.text.length - characterCount; + while (textController.text.length > finalLength) { final codePointsDeleted = _deleteCharacter(textController, currentOffset, direction); if (direction == TextAffinity.upstream) { currentOffset -= codePointsDeleted; @@ -246,12 +247,12 @@ class DeleteCharactersCommand implements RobotCommand { if (direction == TextAffinity.downstream) { // Delete the character after the offset deleteStartIndex = offset; - deleteEndIndex = getCharacterEndBounds(textController.text.text, offset); + deleteEndIndex = getCharacterEndBounds(textController.text.toPlainText(), offset); deletedCodePointCount = deleteEndIndex - deleteStartIndex; newSelectionIndex = deleteStartIndex; } else { // Delete the character before the offset - deleteStartIndex = getCharacterStartBounds(textController.text.text, offset); + deleteStartIndex = getCharacterStartBounds(textController.text.toPlainText(), offset); deleteEndIndex = offset + 1; deletedCodePointCount = offset - deleteStartIndex; newSelectionIndex = deleteStartIndex; diff --git a/super_editor/example/lib/demos/supertextfield/_single_line_demo.dart b/super_editor/example/lib/demos/supertextfield/_single_line_demo.dart index 71cd3d57a7..5ed6d84d41 100644 --- a/super_editor/example/lib/demos/supertextfield/_single_line_demo.dart +++ b/super_editor/example/lib/demos/supertextfield/_single_line_demo.dart @@ -1,3 +1,4 @@ +import 'package:example/demos/supertextfield/demo_text_styles.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/super_editor.dart'; @@ -5,21 +6,12 @@ import '_robot.dart'; class SingleLineTextFieldDemo extends StatefulWidget { @override - _SingleLineTextFieldDemoState createState() => _SingleLineTextFieldDemoState(); + State createState() => _SingleLineTextFieldDemoState(); } class _SingleLineTextFieldDemoState extends State with TickerProviderStateMixin { final _textFieldController = AttributedTextEditingController( - text: AttributedText( - // text: - // 'Super Editor is an open source text editor for Flutter projects.\n\nThis is paragraph 2\n\nThis is paragraph 3', - // spans: AttributedSpans( - // attributions: [ - // SpanMarker(attribution: 'bold', offset: 0, markerType: SpanMarkerType.start), - // SpanMarker(attribution: 'bold', offset: 11, markerType: SpanMarkerType.end), - // ], - // ), - ), + text: AttributedText(), ); GlobalKey? _textKey; @@ -55,7 +47,7 @@ class _SingleLineTextFieldDemoState extends State with ..selection = const TextSelection.collapsed(offset: 0) ..text = AttributedText(); _demoRobot - ..typeText(AttributedText(text: 'Hello World! This is a robot typing some text into a SuperTextField.')) + ..typeText(AttributedText('Hello World! This is a robot typing some text into a SuperTextField.')) ..start(); } @@ -110,6 +102,7 @@ class _SingleLineTextFieldDemoState extends State with ); }, hintBehavior: HintBehavior.displayHintUntilTextEntered, + textStyleBuilder: demoTextStyleBuilder, minLines: 1, maxLines: 1, ), diff --git a/super_editor/example/lib/demos/supertextfield/_static_multi_line_demo.dart b/super_editor/example/lib/demos/supertextfield/_static_multi_line_demo.dart index 30dccc1f76..5433c880ab 100644 --- a/super_editor/example/lib/demos/supertextfield/_static_multi_line_demo.dart +++ b/super_editor/example/lib/demos/supertextfield/_static_multi_line_demo.dart @@ -1,3 +1,4 @@ +import 'package:example/demos/supertextfield/demo_text_styles.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/super_editor.dart'; @@ -5,21 +6,12 @@ import '_robot.dart'; class StaticMultiLineTextFieldDemo extends StatefulWidget { @override - _StaticMultiLineTextFieldDemoState createState() => _StaticMultiLineTextFieldDemoState(); + State createState() => _StaticMultiLineTextFieldDemoState(); } class _StaticMultiLineTextFieldDemoState extends State with TickerProviderStateMixin { final _textFieldController = AttributedTextEditingController( - text: AttributedText( - // text: - // 'Super Editor is an open source text editor for Flutter projects.\n\nThis is paragraph 2\n\nThis is paragraph 3', - // spans: AttributedSpans( - // attributions: [ - // SpanMarker(attribution: 'bold', offset: 0, markerType: SpanMarkerType.start), - // SpanMarker(attribution: 'bold', offset: 11, markerType: SpanMarkerType.end), - // ], - // ), - ), + text: AttributedText(), ); GlobalKey? _textKey; @@ -55,11 +47,11 @@ class _StaticMultiLineTextFieldDemoState extends State _SuperAndroidTextFieldDemoState(); + State createState() => _SuperAndroidTextFieldDemoState(); } class _SuperAndroidTextFieldDemoState extends State { + final String _tapRegionGroupId = "android"; + + late final FocusNode _focusNode; + @override void initState() { super.initState(); - initLoggers(Level.FINER, {androidTextFieldLog, imeTextFieldLog}); + initLoggers(Level.FINER, { + // androidTextFieldLog, + // imeTextFieldLog, + }); + + _focusNode = FocusNode(); } @override void dispose() { + _focusNode.dispose(); + deactivateLoggers({androidTextFieldLog, imeTextFieldLog}); super.dispose(); } @@ -26,8 +38,10 @@ class _SuperAndroidTextFieldDemoState extends State { Widget build(BuildContext context) { return MobileSuperTextFieldDemo( initialText: AttributedText( - text: - 'This is a custom textfield implementation called SuperAndroidTextField. It is super long so that we can mess with scrolling. This drags it out even further so that we can get multiline scrolling, too. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin tempor sapien est, in eleifend purus rhoncus fringilla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nulla varius libero lorem, eget tincidunt ante porta accumsan. Morbi quis ante at nunc molestie ullamcorper.'), + 'This is a custom textfield implementation called SuperAndroidTextField. It is super long so that we can mess with scrolling. This drags it out even further so that we can get multiline scrolling, too. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin tempor sapien est, in eleifend purus rhoncus fringilla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nulla varius libero lorem, eget tincidunt ante porta accumsan. Morbi quis ante at nunc molestie ullamcorper.', + ), + textFieldFocusNode: _focusNode, + textFieldTapRegionGroupId: _tapRegionGroupId, createTextField: _buildTextField, ); } @@ -37,16 +51,20 @@ class _SuperAndroidTextFieldDemoState extends State { final lineHeight = genericTextStyle.fontSize! * (genericTextStyle.height ?? 1.0); return SuperAndroidTextField( + focusNode: _focusNode, + tapRegionGroupId: _tapRegionGroupId, textController: config.controller, textStyleBuilder: config.styleBuilder, + padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 24), hintBehavior: HintBehavior.displayHintUntilTextEntered, hintBuilder: StyledHintBuilder( - hintText: AttributedText(text: "Enter text"), + hintText: AttributedText("Enter text"), hintTextStyleBuilder: (attributions) { return config.styleBuilder(attributions).copyWith(color: Colors.grey); }).build, - selectionColor: Colors.blue.withOpacity(0.4), - caretColor: Colors.green, + selectionColor: Colors.blue.withValues(alpha: 0.4), + caretStyle: const CaretStyle(color: Colors.green), + blinkTimingMode: BlinkTimingMode.timer, handlesColor: Colors.lightGreen, minLines: config.minLines, maxLines: config.maxLines, diff --git a/super_editor/example/lib/demos/supertextfield/demo_text_styles.dart b/super_editor/example/lib/demos/supertextfield/demo_text_styles.dart new file mode 100644 index 0000000000..fb5b68a27d --- /dev/null +++ b/super_editor/example/lib/demos/supertextfield/demo_text_styles.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +TextStyle demoTextStyleBuilder(Set attributions) { + TextStyle textStyle = const TextStyle( + color: Colors.black, + fontSize: 14, + ); + + if (attributions.contains(brandAttribution)) { + textStyle = textStyle.copyWith( + color: Colors.red, + fontWeight: FontWeight.bold, + ); + } + if (attributions.contains(flutterAttribution)) { + textStyle = textStyle.copyWith( + color: Colors.blue, + ); + } + + return textStyle; +} + +const brandAttribution = NamedAttribution('brand'); +const flutterAttribution = NamedAttribution('flutter'); diff --git a/super_editor/example/lib/demos/supertextfield/demo_textfield.dart b/super_editor/example/lib/demos/supertextfield/demo_textfield.dart index 02ca153591..3c077bf1ec 100644 --- a/super_editor/example/lib/demos/supertextfield/demo_textfield.dart +++ b/super_editor/example/lib/demos/supertextfield/demo_textfield.dart @@ -1,7 +1,9 @@ import 'package:example/demos/supertextfield/_emojis_demo.dart'; +import 'package:example/demos/supertextfield/_expanding_multi_line_demo.dart'; import 'package:example/demos/supertextfield/_interactive_demo.dart'; +import 'package:example/demos/supertextfield/textfield_inside_single_child_scroll_view_demo.dart'; +import 'package:example/demos/supertextfield/textfield_inside_slivers_demo.dart'; import 'package:example/demos/supertextfield/_single_line_demo.dart'; -import 'package:example/demos/supertextfield/_expanding_multi_line_demo.dart'; import 'package:example/demos/supertextfield/_static_multi_line_demo.dart'; import 'package:example/demos/supertextfield/_textfield_demo_screen.dart'; import 'package:flutter/material.dart' hide SelectableText; @@ -20,7 +22,7 @@ import 'package:super_editor/super_editor.dart'; /// Demo of a variety of [SuperTextField] class TextFieldDemo extends StatefulWidget { @override - _TextFieldDemoState createState() => _TextFieldDemoState(); + State createState() => _TextFieldDemoState(); } class _TextFieldDemoState extends State { @@ -91,6 +93,22 @@ class _TextFieldDemoState extends State { }); }, ), + DemoMenuItem( + label: 'TextField inside slivers', + onPressed: () { + setState(() { + _demoBuilder = (_) => const TextFieldInsideSliversDemo(); + }); + }, + ), + DemoMenuItem( + label: 'TextField inside SingleChildScrollView', + onPressed: () { + setState(() { + _demoBuilder = (_) => const TextFieldInsideSingleChildScrollViewDemo(); + }); + }, + ), ], child: _demoBuilder(context), ); diff --git a/super_editor/example/lib/demos/supertextfield/ios/demo_superiostextfield.dart b/super_editor/example/lib/demos/supertextfield/ios/demo_superiostextfield.dart index 2e839e52f6..a0e814e959 100644 --- a/super_editor/example/lib/demos/supertextfield/ios/demo_superiostextfield.dart +++ b/super_editor/example/lib/demos/supertextfield/ios/demo_superiostextfield.dart @@ -1,24 +1,36 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_text_layout/super_text_layout.dart'; import '../_mobile_textfield_demo.dart'; /// Demo of [SuperIOSTextField]. class SuperIOSTextFieldDemo extends StatefulWidget { @override - _SuperIOSTextFieldDemoState createState() => _SuperIOSTextFieldDemoState(); + State createState() => _SuperIOSTextFieldDemoState(); } class _SuperIOSTextFieldDemoState extends State { + final String _tapRegionGroupId = "iOS"; + + late final FocusNode _focusNode; + @override void initState() { super.initState(); - initLoggers(Level.FINE, {iosTextFieldLog, imeTextFieldLog}); + initLoggers(Level.FINE, { + // iosTextFieldLog, + // imeTextFieldLog, + }); + + _focusNode = FocusNode(); } @override void dispose() { + _focusNode.dispose(); + deactivateLoggers({iosTextFieldLog, imeTextFieldLog}); super.dispose(); } @@ -27,8 +39,10 @@ class _SuperIOSTextFieldDemoState extends State { Widget build(BuildContext context) { return MobileSuperTextFieldDemo( initialText: AttributedText( - text: - 'This is a custom textfield implementation called SuperIOSTextfield. It is super long so that we can mess with scrolling. This drags it out even further so that we can get multiline scrolling, too. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin tempor sapien est, in eleifend purus rhoncus fringilla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nulla varius libero lorem, eget tincidunt ante porta accumsan. Morbi quis ante at nunc molestie ullamcorper.'), + 'This is a custom textfield implementation called SuperIOSTextfield. It is super long so that we can mess with scrolling. This drags it out even further so that we can get multiline scrolling, too. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin tempor sapien est, in eleifend purus rhoncus fringilla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nulla varius libero lorem, eget tincidunt ante porta accumsan. Morbi quis ante at nunc molestie ullamcorper.', + ), + textFieldFocusNode: _focusNode, + textFieldTapRegionGroupId: _tapRegionGroupId, createTextField: _buildTextField, ); } @@ -37,23 +51,34 @@ class _SuperIOSTextFieldDemoState extends State { final genericTextStyle = config.styleBuilder({}); final lineHeight = genericTextStyle.fontSize! * (genericTextStyle.height ?? 1.0); - return SuperIOSTextField( - textController: config.controller, - textStyleBuilder: config.styleBuilder, - hintBehavior: HintBehavior.displayHintUntilTextEntered, - hintBuilder: StyledHintBuilder( - hintText: AttributedText(text: "Enter text"), + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + ), + child: SuperIOSTextField( + focusNode: _focusNode, + tapRegionGroupId: _tapRegionGroupId, + textController: config.controller, + textStyleBuilder: config.styleBuilder, + padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 24), + hintBehavior: HintBehavior.displayHintUntilTextEntered, + hintBuilder: StyledHintBuilder( + hintText: AttributedText("Enter text"), hintTextStyleBuilder: (attributions) { return config.styleBuilder(attributions).copyWith(color: Colors.grey); - }).build, - selectionColor: Colors.blue.withOpacity(0.4), - caretColor: Colors.blue, - handlesColor: Colors.blue, - minLines: config.minLines, - maxLines: config.maxLines, - lineHeight: lineHeight, - textInputAction: TextInputAction.done, - showDebugPaint: config.showDebugPaint, + }, + ).build, + selectionColor: Colors.blue.withValues(alpha: 0.4), + caretStyle: const CaretStyle(color: Colors.blue), + blinkTimingMode: BlinkTimingMode.timer, + handlesColor: Colors.blue, + minLines: config.minLines, + maxLines: config.maxLines, + lineHeight: lineHeight, + textInputAction: TextInputAction.done, + popoverToolbarBuilder: iOSSystemPopoverTextFieldToolbarWithFallback, + showDebugPaint: config.showDebugPaint, + ), ); } } diff --git a/super_editor/example/lib/demos/supertextfield/textfield_inside_single_child_scroll_view_demo.dart b/super_editor/example/lib/demos/supertextfield/textfield_inside_single_child_scroll_view_demo.dart new file mode 100644 index 0000000000..aafee2d87d --- /dev/null +++ b/super_editor/example/lib/demos/supertextfield/textfield_inside_single_child_scroll_view_demo.dart @@ -0,0 +1,66 @@ +import 'package:example/demos/supertextfield/demo_text_styles.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_text_field.dart'; + +/// Demo of [SuperDesktopTextField] inside [SingleChildScrollView] with scrollable content. +class TextFieldInsideSingleChildScrollViewDemo extends StatefulWidget { + const TextFieldInsideSingleChildScrollViewDemo({super.key}); + + @override + State createState() => _TextFieldInsideSingleChildScrollViewDemoState(); +} + +class _TextFieldInsideSingleChildScrollViewDemoState extends State { + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + children: [ + SizedBox( + // Occupy 80% of the vertical space to avoid pushing text field off-screen + // and to provide a visual clue on text field's position within demo. + height: MediaQuery.of(context).size.height * 0.8, + width: double.infinity, + child: Placeholder( + child: Center( + child: Text("Content"), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: SuperDesktopTextField( + // This demo tests scrolling text field behavior. Force the text field to be tall + // enough to easily see content scrolling by, but short enough to ensure that + // the content is scrollable. + minLines: 5, + maxLines: 5, + textStyleBuilder: demoTextStyleBuilder, + decorationBuilder: (context, child) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.grey.shade300, + width: 1, + ), + ), + child: child, + ); + }, + ), + ), + SizedBox( + height: MediaQuery.of(context).size.height * 2, + width: double.infinity, + child: Placeholder( + child: Center( + child: Text("Content"), + ), + ), + ), + ], + ), + ); + } +} diff --git a/super_editor/example/lib/demos/supertextfield/textfield_inside_slivers_demo.dart b/super_editor/example/lib/demos/supertextfield/textfield_inside_slivers_demo.dart new file mode 100644 index 0000000000..926e01c386 --- /dev/null +++ b/super_editor/example/lib/demos/supertextfield/textfield_inside_slivers_demo.dart @@ -0,0 +1,70 @@ +import 'package:example/demos/supertextfield/demo_text_styles.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_text_field.dart'; + +/// Demo of [SuperDesktopTextField] inside Slivers with scrollable content. +class TextFieldInsideSliversDemo extends StatefulWidget { + const TextFieldInsideSliversDemo({super.key}); + + @override + State createState() => _TextFieldInsideSliversDemoState(); +} + +class _TextFieldInsideSliversDemoState extends State { + @override + Widget build(BuildContext context) { + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: SizedBox( + // Occupy 80% of the vertical space to avoid pushing text field off-screen + // and to provide a visual clue on text field's position within demo. + height: MediaQuery.of(context).size.height * 0.8, + width: double.infinity, + child: Placeholder( + child: Center( + child: Text("Content"), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SuperDesktopTextField( + // This demo tests scrolling text field behavior. Force the text field to be tall + // enough to easily see content scrolling by, but short enough to ensure that + // the content is scrollable. + minLines: 5, + maxLines: 5, + textStyleBuilder: demoTextStyleBuilder, + decorationBuilder: (context, child) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.grey.shade300, + width: 1, + ), + ), + child: child, + ); + }, + ), + ), + ), + SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).size.height, + width: double.infinity, + child: Placeholder( + child: Center( + child: Text("Content"), + ), + ), + ), + ), + ], + ); + } +} diff --git a/super_editor/example/lib/flutter_demos/main_flutter_textfield.dart b/super_editor/example/lib/flutter_demos/main_flutter_textfield.dart new file mode 100644 index 0000000000..8734618333 --- /dev/null +++ b/super_editor/example/lib/flutter_demos/main_flutter_textfield.dart @@ -0,0 +1,63 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(_FlutterTextFieldDemoApp()); +} + +class _FlutterTextFieldDemoApp extends StatelessWidget { + const _FlutterTextFieldDemoApp(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: _DemoTextField(), + ), + ), + ), + ); + } +} + +class _DemoTextField extends StatefulWidget { + const _DemoTextField(); + + @override + State<_DemoTextField> createState() => _DemoTextFieldState(); +} + +class _DemoTextFieldState extends State<_DemoTextField> { + final _textController = TextEditingController(); + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _textController, + decoration: InputDecoration( + hintText: "Enter text...", + ), + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + // If supported, show the system context menu. + if (SystemContextMenu.isSupported(context)) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + ); + } + // Otherwise, show the flutter-rendered context menu for the current + // platform. + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + }); + } +} diff --git a/super_editor/example/lib/l10n/app_en.arb b/super_editor/example/lib/l10n/app_en.arb index 594f96f19e..15cd90435b 100644 --- a/super_editor/example/lib/l10n/app_en.arb +++ b/super_editor/example/lib/l10n/app_en.arb @@ -3,6 +3,8 @@ "labelBold": "Bold", "labelItalics": "Italics", "labelStrikethrough": "Strikethrough", + "labelSuperscript": "Superscript", + "labelSubscript": "Subscript", "labelLink": "Link", "labelTextAlignment": "Text Alignment", "labelMoreOptions": "More Options (not implemented)", @@ -12,5 +14,7 @@ "labelParagraph": "Paragraph", "labelBlockquote": "Blockquote", "labelOrderedListItem": "Ordered List Item", - "labelUnorderedListItem": "Unordered List Item" + "labelUnorderedListItem": "Unordered List Item", + "labelLimitedWidth": "Limited width", + "labelFullWidth": "Full width" } \ No newline at end of file diff --git a/super_editor/example/lib/l10n/app_es.arb b/super_editor/example/lib/l10n/app_es.arb index 55d6046b0a..f4ad42ce30 100644 --- a/super_editor/example/lib/l10n/app_es.arb +++ b/super_editor/example/lib/l10n/app_es.arb @@ -12,5 +12,7 @@ "labelParagraph": "Párrafo", "labelBlockquote": "Cita de bloque", "labelOrderedListItem": "Elemento de lista cerrada", - "labelUnorderedListItem": "Elemento de lista no ordenada" + "labelUnorderedListItem": "Elemento de lista no ordenada", + "labelLimitedWidth": "Ancho limitado", + "labelFullWidth": "Ancho completo" } \ No newline at end of file diff --git a/super_editor/example/lib/l10n/app_localizations.dart b/super_editor/example/lib/l10n/app_localizations.dart new file mode 100644 index 0000000000..24c0f1bb24 --- /dev/null +++ b/super_editor/example/lib/l10n/app_localizations.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_es.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('es') + ]; + + /// No description provided for @labelTextBlockType. + /// + /// In en, this message translates to: + /// **'Text block type'** + String get labelTextBlockType; + + /// No description provided for @labelBold. + /// + /// In en, this message translates to: + /// **'Bold'** + String get labelBold; + + /// No description provided for @labelItalics. + /// + /// In en, this message translates to: + /// **'Italics'** + String get labelItalics; + + /// No description provided for @labelStrikethrough. + /// + /// In en, this message translates to: + /// **'Strikethrough'** + String get labelStrikethrough; + + /// No description provided for @labelSuperscript. + /// + /// In en, this message translates to: + /// **'Superscript'** + String get labelSuperscript; + + /// No description provided for @labelSubscript. + /// + /// In en, this message translates to: + /// **'Subscript'** + String get labelSubscript; + + /// No description provided for @labelLink. + /// + /// In en, this message translates to: + /// **'Link'** + String get labelLink; + + /// No description provided for @labelTextAlignment. + /// + /// In en, this message translates to: + /// **'Text Alignment'** + String get labelTextAlignment; + + /// No description provided for @labelMoreOptions. + /// + /// In en, this message translates to: + /// **'More Options (not implemented)'** + String get labelMoreOptions; + + /// No description provided for @labelHeader1. + /// + /// In en, this message translates to: + /// **'Header 1'** + String get labelHeader1; + + /// No description provided for @labelHeader2. + /// + /// In en, this message translates to: + /// **'Header 2'** + String get labelHeader2; + + /// No description provided for @labelHeader3. + /// + /// In en, this message translates to: + /// **'Header 3'** + String get labelHeader3; + + /// No description provided for @labelParagraph. + /// + /// In en, this message translates to: + /// **'Paragraph'** + String get labelParagraph; + + /// No description provided for @labelBlockquote. + /// + /// In en, this message translates to: + /// **'Blockquote'** + String get labelBlockquote; + + /// No description provided for @labelOrderedListItem. + /// + /// In en, this message translates to: + /// **'Ordered List Item'** + String get labelOrderedListItem; + + /// No description provided for @labelUnorderedListItem. + /// + /// In en, this message translates to: + /// **'Unordered List Item'** + String get labelUnorderedListItem; + + /// No description provided for @labelLimitedWidth. + /// + /// In en, this message translates to: + /// **'Limited width'** + String get labelLimitedWidth; + + /// No description provided for @labelFullWidth. + /// + /// In en, this message translates to: + /// **'Full width'** + String get labelFullWidth; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'es'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'es': + return AppLocalizationsEs(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/super_editor/example/lib/l10n/app_localizations_en.dart b/super_editor/example/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000000..8a7b2200e5 --- /dev/null +++ b/super_editor/example/lib/l10n/app_localizations_en.dart @@ -0,0 +1,64 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get labelTextBlockType => 'Text block type'; + + @override + String get labelBold => 'Bold'; + + @override + String get labelItalics => 'Italics'; + + @override + String get labelStrikethrough => 'Strikethrough'; + + @override + String get labelSuperscript => 'Superscript'; + + @override + String get labelSubscript => 'Subscript'; + + @override + String get labelLink => 'Link'; + + @override + String get labelTextAlignment => 'Text Alignment'; + + @override + String get labelMoreOptions => 'More Options (not implemented)'; + + @override + String get labelHeader1 => 'Header 1'; + + @override + String get labelHeader2 => 'Header 2'; + + @override + String get labelHeader3 => 'Header 3'; + + @override + String get labelParagraph => 'Paragraph'; + + @override + String get labelBlockquote => 'Blockquote'; + + @override + String get labelOrderedListItem => 'Ordered List Item'; + + @override + String get labelUnorderedListItem => 'Unordered List Item'; + + @override + String get labelLimitedWidth => 'Limited width'; + + @override + String get labelFullWidth => 'Full width'; +} diff --git a/super_editor/example/lib/l10n/app_localizations_es.dart b/super_editor/example/lib/l10n/app_localizations_es.dart new file mode 100644 index 0000000000..25203b095e --- /dev/null +++ b/super_editor/example/lib/l10n/app_localizations_es.dart @@ -0,0 +1,64 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Spanish Castilian (`es`). +class AppLocalizationsEs extends AppLocalizations { + AppLocalizationsEs([String locale = 'es']) : super(locale); + + @override + String get labelTextBlockType => 'Tipo de bloque de texto'; + + @override + String get labelBold => 'Negrita'; + + @override + String get labelItalics => 'Cursiva'; + + @override + String get labelStrikethrough => 'Tachado'; + + @override + String get labelSuperscript => 'Superscript'; + + @override + String get labelSubscript => 'Subscript'; + + @override + String get labelLink => 'Enlace'; + + @override + String get labelTextAlignment => 'Alineación de texto'; + + @override + String get labelMoreOptions => 'Más Opciones (no se implanta)'; + + @override + String get labelHeader1 => 'Encabezado 1'; + + @override + String get labelHeader2 => 'Encabezado 2'; + + @override + String get labelHeader3 => 'Encabezado 3'; + + @override + String get labelParagraph => 'Párrafo'; + + @override + String get labelBlockquote => 'Cita de bloque'; + + @override + String get labelOrderedListItem => 'Elemento de lista cerrada'; + + @override + String get labelUnorderedListItem => 'Elemento de lista no ordenada'; + + @override + String get labelLimitedWidth => 'Ancho limitado'; + + @override + String get labelFullWidth => 'Ancho completo'; +} diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index 8be0047220..4145287c0d 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -1,51 +1,65 @@ import 'package:example/demos/components/demo_text_with_hint.dart'; import 'package:example/demos/components/demo_unselectable_hr.dart'; import 'package:example/demos/debugging/simple_deltas_input.dart'; +import 'package:example/demos/demo_animated_task_height.dart'; import 'package:example/demos/demo_app_shortcuts.dart'; +import 'package:example/demos/demo_attributed_text.dart'; +import 'package:example/demos/demo_document_loses_focus.dart'; import 'package:example/demos/demo_empty_document.dart'; import 'package:example/demos/demo_markdown_serialization.dart'; import 'package:example/demos/demo_paragraphs.dart'; import 'package:example/demos/demo_rtl.dart'; import 'package:example/demos/demo_selectable_text.dart'; +import 'package:example/demos/demo_switch_document_content.dart'; import 'package:example/demos/editor_configs/demo_mobile_editing_android.dart'; import 'package:example/demos/editor_configs/demo_mobile_editing_ios.dart'; import 'package:example/demos/example_editor/example_editor.dart'; import 'package:example/demos/flutter_features/demo_inline_widgets.dart'; import 'package:example/demos/flutter_features/textinputclient/basic_text_input_client.dart'; import 'package:example/demos/flutter_features/textinputclient/textfield.dart'; +import 'package:example/demos/in_the_lab/feature_action_tags.dart'; +import 'package:example/demos/in_the_lab/feature_ai_fade_in.dart'; +import 'package:example/demos/in_the_lab/feature_custom_underlines.dart'; +import 'package:example/demos/in_the_lab/feature_ios_native_context_menu.dart'; +import 'package:example/demos/in_the_lab/feature_pattern_tags.dart'; +import 'package:example/demos/in_the_lab/feature_stable_tags.dart'; +import 'package:example/demos/in_the_lab/selected_text_colors_demo.dart'; +import 'package:example/demos/in_the_lab/spelling_error_decorations.dart'; +import 'package:example/demos/interaction_spot_checks/toolbar_following_content_in_layer.dart'; +import 'package:example/demos/interaction_spot_checks/url_launching_spot_checks.dart'; +import 'package:example/demos/mobile_chat/demo_mobile_chat.dart'; import 'package:example/demos/scrolling/demo_task_and_chat_with_customscrollview.dart'; import 'package:example/demos/sliver_example_editor.dart'; import 'package:example/demos/styles/demo_doc_styles.dart'; -import 'package:example/demos/super_document/demo_super_reader.dart'; +import 'package:example/demos/super_reader/demo_super_reader.dart'; +import 'package:example/demos/super_reader/demo_super_reader_custom_scrollview.dart'; +import 'package:example/demos/super_reader/demo_super_reader_listview.dart'; +import 'package:example/demos/supertextfield/android/demo_superandroidtextfield.dart'; import 'package:example/demos/supertextfield/demo_textfield.dart'; import 'package:example/demos/supertextfield/ios/demo_superiostextfield.dart'; -import 'package:example/logging.dart'; +import 'package:example/l10n/app_localizations.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:logging/logging.dart'; import 'package:super_editor/super_editor.dart'; -import 'demos/demo_attributed_text.dart'; -import 'demos/demo_document_loses_focus.dart'; -import 'demos/demo_switch_document_content.dart'; -import 'demos/super_document/demo_read_only_scrolling_document.dart'; -import 'demos/supertextfield/android/demo_superandroidtextfield.dart'; - /// Demo of a basic text editor, as well as various widgets that /// are available in this package. Future main() async { - initLoggers(Level.FINEST, { + initLoggers(Level.FINE, { // editorScrollingLog, // editorGesturesLog, // editorImeLog, + // editorImeDeltasLog, // editorKeyLog, // editorOpsLog, // editorLayoutLog, // editorDocLog, // editorStyleLog, + // superImeLog, // textFieldLog, - appLog, + // editorUserTagsLog, + // contentLayersLog, + // appLog, }); runApp(SuperEditorDemoApp()); @@ -79,7 +93,7 @@ class SuperEditorDemoApp extends StatelessWidget { /// options in a drawer. class HomeScreen extends StatefulWidget { @override - _HomeScreenState createState() => _HomeScreenState(); + State createState() => _HomeScreenState(); } class _HomeScreenState extends State { @@ -214,6 +228,13 @@ final _menu = <_MenuGroup>[ return SliverExampleEditor(); }, ), + _MenuItem( + icon: Icons.description, + title: 'Chat Demo', + pageBuilder: (context) { + return MobileChatDemo(); + }, + ), _MenuItem( icon: Icons.description, title: 'Switch Docs Demo', @@ -256,6 +277,78 @@ final _menu = <_MenuGroup>[ return EmptyDocumentDemo(); }, ), + _MenuItem( + icon: Icons.description, + title: 'Animated task height demo', + pageBuilder: (context) { + return AnimatedTaskHeightDemo(); + }, + ), + ], + ), + _MenuGroup( + title: 'FEATURES', + items: [], + ), + _MenuGroup( + title: 'IN THE LAB', + items: [ + _MenuItem( + icon: Icons.color_lens, + title: 'Selected Text Colors', + pageBuilder: (context) { + return const SelectedTextColorsDemo(); + }, + ), + _MenuItem( + icon: Icons.spellcheck, + title: 'Spelling Error Decorations', + pageBuilder: (context) { + return const SpellingErrorDecorationsDemo(); + }, + ), + _MenuItem( + icon: Icons.tag, + title: 'Hash Tags', + pageBuilder: (context) { + return const HashTagsFeatureDemo(); + }, + ), + _MenuItem( + icon: Icons.account_circle, + title: 'User Tags', + pageBuilder: (context) { + return const UserTagsFeatureDemo(); + }, + ), + _MenuItem( + icon: Icons.task, + title: 'Action Tags', + pageBuilder: (context) { + return const ActionTagsFeatureDemo(); + }, + ), + _MenuItem( + icon: Icons.apple, + title: 'Native iOS Toolbar', + pageBuilder: (context) { + return const NativeIosContextMenuFeatureDemo(); + }, + ), + _MenuItem( + icon: Icons.line_style, + title: 'Custom Underlines', + pageBuilder: (context) { + return const CustomUnderlinesDemo(); + }, + ), + _MenuItem( + icon: Icons.task, + title: 'AI Text Fade-In', + pageBuilder: (context) { + return const AiFadeInFeatureDemo(); + }, + ), ], ), _MenuGroup( @@ -296,14 +389,40 @@ final _menu = <_MenuGroup>[ icon: Icons.text_snippet, title: 'SuperReader', pageBuilder: (context) { - return SuperReaderDemo(); + return const SuperReaderDemo(); }, ), _MenuItem( icon: Icons.text_snippet, title: 'In CustomScrollView', pageBuilder: (context) { - return ReadOnlyCustomScrollViewDemo(); + return SuperReaderCustomScrollViewDemo(); + }, + ), + _MenuItem( + icon: Icons.text_snippet, + title: 'In ListView', + pageBuilder: (context) { + return SuperReaderListViewDemo(); + }, + ), + ], + ), + _MenuGroup( + title: 'Spot Checks', + items: [ + _MenuItem( + icon: Icons.link, + title: 'URL Parsing & Launching', + pageBuilder: (context) { + return UrlLauncherSpotChecks(); + }, + ), + _MenuItem( + icon: Icons.layers, + title: 'Toolbar Following Content Layer', + pageBuilder: (context) { + return ToolbarFollowingContentInLayer(); }, ), ], @@ -502,22 +621,22 @@ class _DrawerButton extends StatelessWidget { width: double.infinity, child: ElevatedButton( style: ButtonStyle( - backgroundColor: MaterialStateColor.resolveWith((states) { + backgroundColor: WidgetStateColor.resolveWith((states) { if (isSelected) { return const Color(0xFFBBBBBB); } - if (states.contains(MaterialState.hovered)) { - return Colors.grey.withOpacity(0.1); + if (states.contains(WidgetState.hovered)) { + return Colors.grey.withValues(alpha: 0.1); } return Colors.transparent; }), // splashFactory: NoSplash.splashFactory, foregroundColor: - MaterialStateColor.resolveWith((states) => isSelected ? Colors.white : const Color(0xFFBBBBBB)), - elevation: MaterialStateProperty.resolveWith((states) => 0), - padding: MaterialStateProperty.resolveWith((states) => const EdgeInsets.all(16))), + WidgetStateColor.resolveWith((states) => isSelected ? Colors.white : const Color(0xFFBBBBBB)), + elevation: WidgetStateProperty.resolveWith((states) => 0), + padding: WidgetStateProperty.resolveWith((states) => const EdgeInsets.all(16))), onPressed: isSelected ? null : onPressed, child: Row( children: [ diff --git a/super_editor/example/lib/main_super_editor.dart b/super_editor/example/lib/main_super_editor.dart new file mode 100644 index 0000000000..6758c95290 --- /dev/null +++ b/super_editor/example/lib/main_super_editor.dart @@ -0,0 +1,255 @@ +import 'package:example/demos/example_editor/_example_document.dart'; +import 'package:example/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:logging/logging.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A demo of a [SuperEditor] experience. +/// +/// This demo only shows a single, typical [SuperEditor]. To see a variety of +/// demos, see the main demo experience in this project. +void main() { + initLoggers(Level.FINEST, { + // editorScrollingLog, + // editorGesturesLog, + // longPressSelectionLog, + // editorImeLog, + // editorImeDeltasLog, + // editorIosFloatingCursorLog, + // editorKeyLog, + // editorEditsLog, + // editorOpsLog, + // editorLayoutLog, + // editorDocLog, + // editorStyleLog, + // textFieldLog, + // editorUserTagsLog, + // contentLayersLog, + }); + + runApp( + MaterialApp( + home: Scaffold( + body: _Demo(), + ), + supportedLocales: const [ + Locale('en', ''), + Locale('es', ''), + ], + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + debugShowCheckedModeBanner: false, + ), + ); +} + +class _Demo extends StatefulWidget { + const _Demo(); + + @override + State<_Demo> createState() => _DemoState(); +} + +class _DemoState extends State<_Demo> { + late MutableDocument _document; + late MutableDocumentComposer _composer; + late Editor _docEditor; + + @override + void initState() { + super.initState(); + _document = createInitialDocument(); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _document, composer: _composer, isHistoryEnabled: true); + } + + @override + void dispose() { + _composer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _StandardEditor( + document: _document, + composer: _composer, + editor: _docEditor, + ), + ), + _buildToolbar(), + ], + ); + } + + Widget _buildToolbar() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _EditorHistoryPanel(editor: _docEditor), + Container( + width: 24, + height: double.infinity, + color: const Color(0xFF2F2F2F), + child: Column(), + ), + ], + ); + } +} + +class _EditorHistoryPanel extends StatefulWidget { + const _EditorHistoryPanel({ + required this.editor, + }); + + final Editor editor; + + @override + State<_EditorHistoryPanel> createState() => _EditorHistoryPanelState(); +} + +class _EditorHistoryPanelState extends State<_EditorHistoryPanel> { + final _scrollController = ScrollController(); + late EditListener _editListener; + + @override + void initState() { + super.initState(); + + _editListener = FunctionalEditListener(_onEditorChange); + widget.editor.addListener(_editListener); + } + + @override + void didUpdateWidget(_EditorHistoryPanel oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.editor != oldWidget.editor) { + oldWidget.editor.removeListener(_editListener); + widget.editor.addListener(_editListener); + } + } + + @override + void dispose() { + _scrollController.dispose(); + widget.editor.removeListener(_editListener); + super.dispose(); + } + + void _onEditorChange(changes) { + setState(() { + // Build the latest list of changes. + }); + + // Always scroll to bottom of transaction list. + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollController.position.jumpTo(_scrollController.position.maxScrollExtent); + }); + } + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData( + brightness: Brightness.dark, + ), + child: Container( + width: 300, + height: double.infinity, + color: const Color(0xFF333333), + child: SingleChildScrollView( + controller: _scrollController, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Column( + children: [ + for (final history in widget.editor.history) + ListTile( + title: Text("${history.changes.length} changes"), + titleTextStyle: TextStyle( + fontSize: 16, + ), + subtitle: Text( + "${history.commands.map((command) => command.describe()).join("\n\n")}\n-------------\n${history.changes.map((event) => event.describe()).join("\n\n")}", + ), + subtitleTextStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 10, + height: 1.4, + ), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _StandardEditor extends StatefulWidget { + const _StandardEditor({ + required this.document, + required this.composer, + required this.editor, + }); + + final MutableDocument document; + final MutableDocumentComposer composer; + final Editor editor; + + @override + State<_StandardEditor> createState() => _StandardEditorState(); +} + +class _StandardEditorState extends State<_StandardEditor> { + final GlobalKey _docLayoutKey = GlobalKey(); + + late FocusNode _editorFocusNode; + + late ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _editorFocusNode = FocusNode(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + _editorFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SuperEditor( + editor: widget.editor, + focusNode: _editorFocusNode, + scrollController: _scrollController, + documentLayoutKey: _docLayoutKey, + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: [ + taskStyles, + ], + ), + componentBuilders: [ + TaskComponentBuilder(widget.editor), + ...defaultComponentBuilders, + ], + ); + } +} diff --git a/super_editor/example/lib/main_super_editor_chat.dart b/super_editor/example/lib/main_super_editor_chat.dart new file mode 100644 index 0000000000..5768c5ec0c --- /dev/null +++ b/super_editor/example/lib/main_super_editor_chat.dart @@ -0,0 +1,54 @@ +import 'package:example/demos/mobile_chat/demo_mobile_chat.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A demo of a chat experience that uses [SuperEditor]. +void main() { + initLoggers(Level.FINEST, { + // editorScrollingLog, + // editorGesturesLog, + // longPressSelectionLog, + // editorImeLog, + // editorImeDeltasLog, + // editorIosFloatingCursorLog, + // editorKeyLog, + // editorEditsLog, + // editorOpsLog, + // editorLayoutLog, + // editorDocLog, + // editorStyleLog, + // textFieldLog, + // editorUserTagsLog, + // contentLayersLog, + }); + + runApp( + MaterialApp( + routes: { + "/": (context) => Scaffold( + resizeToAvoidBottomInset: false, + body: MobileChatDemo(), + ), + // We include a 2nd screen with navigation so that we can verify + // what happens to the keyboard safe area when navigating from an + // open editor to another screen with a safe area, but no keyboard + // scaffold. See issue #2419 + "/second": (context) => Scaffold( + appBar: AppBar(), + resizeToAvoidBottomInset: false, + body: KeyboardScaffoldSafeArea( + child: ListView.builder( + itemBuilder: (context, index) { + return ListTile( + title: Text("Item $index"), + ); + }, + ), + ), + ), + }, + debugShowCheckedModeBanner: false, + ), + ); +} diff --git a/super_editor/example/lib/main_super_reader.dart b/super_editor/example/lib/main_super_reader.dart new file mode 100644 index 0000000000..169cac9ed2 --- /dev/null +++ b/super_editor/example/lib/main_super_reader.dart @@ -0,0 +1,16 @@ +import 'package:example/demos/super_reader/demo_super_reader.dart'; +import 'package:flutter/material.dart'; + +/// A demo of a [SuperReader] experience. +/// +/// This demo only shows a single, typical [SuperReader]. To see a variety of +/// demos, see the main demo experience in this project. +void main() { + runApp( + MaterialApp( + home: Scaffold( + body: SuperReaderDemo(), + ), + ), + ); +} diff --git a/super_editor/example/lib/main_super_text_field.dart b/super_editor/example/lib/main_super_text_field.dart new file mode 100644 index 0000000000..1575b1e8f6 --- /dev/null +++ b/super_editor/example/lib/main_super_text_field.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_text_field.dart'; + +/// An app that demos [SuperTextField]. +void main() { + runApp( + MaterialApp( + home: _SuperTextFieldDemo(), + ), + ); +} + +class _SuperTextFieldDemo extends StatefulWidget { + const _SuperTextFieldDemo(); + + @override + State<_SuperTextFieldDemo> createState() => _SuperTextFieldDemoState(); +} + +class _SuperTextFieldDemoState extends State<_SuperTextFieldDemo> { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 500), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SingleLineTextField(), + const SizedBox(height: 16), + _MultiLineTextField(), + ], + ), + ), + ), + ); + } +} + +class _SingleLineTextField extends StatefulWidget { + const _SingleLineTextField(); + + @override + State<_SingleLineTextField> createState() => _SingleLineTextFieldState(); +} + +class _SingleLineTextFieldState extends State<_SingleLineTextField> { + final _focusNode = FocusNode(); + final _textController = ImeAttributedTextEditingController( + controller: AttributedTextEditingController(), + ); + + @override + void dispose() { + _textController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TapRegion( + groupId: "textfields", + onTapOutside: (_) => _focusNode.unfocus(), + child: TextFieldBorder( + focusNode: _focusNode, + borderBuilder: _borderBuilder, + child: SuperTextField( + focusNode: _focusNode, + textController: _textController, + textStyleBuilder: _textStyleBuilder, + hintBuilder: _createHintBuilder("Enter single line text..."), + padding: const EdgeInsets.all(4), + minLines: 1, + maxLines: 1, + inputSource: TextInputSource.ime, + ), + ), + ); + } +} + +class _MultiLineTextField extends StatefulWidget { + const _MultiLineTextField(); + + @override + State<_MultiLineTextField> createState() => _MultiLineTextFieldState(); +} + +class _MultiLineTextFieldState extends State<_MultiLineTextField> { + final _focusNode = FocusNode(); + final _textController = ImeAttributedTextEditingController( + controller: AttributedTextEditingController(), + ); + + @override + void dispose() { + _textController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TapRegion( + groupId: "textfields", + onTapOutside: (_) => _focusNode.unfocus(), + child: TextFieldBorder( + focusNode: _focusNode, + borderBuilder: _borderBuilder, + child: SuperTextField( + focusNode: _focusNode, + textController: _textController, + textStyleBuilder: _textStyleBuilder, + hintBuilder: _createHintBuilder("Type some text..."), + padding: const EdgeInsets.all(4), + minLines: 5, + maxLines: 5, + inputSource: TextInputSource.ime, + ), + ), + ); + } +} + +BoxDecoration _borderBuilder(TextFieldBorderState borderState) { + return BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: borderState.hasError // + ? Colors.red + : borderState.hasFocus + ? Colors.blue + : Colors.grey.shade300, + width: borderState.hasError ? 2 : 1, + ), + ); +} + +TextStyle _textStyleBuilder(Set attributions) { + return defaultTextFieldStyleBuilder(attributions).copyWith( + color: Colors.black, + ); +} + +WidgetBuilder _createHintBuilder(String hintText) { + return (BuildContext context) { + return Text( + hintText, + style: TextStyle(color: Colors.grey), + ); + }; +} diff --git a/super_editor/example/lib/marketing_video/main_marketing_video.dart b/super_editor/example/lib/marketing_video/main_marketing_video.dart index dcd45a3fd8..991462f8ec 100644 --- a/super_editor/example/lib/marketing_video/main_marketing_video.dart +++ b/super_editor/example/lib/marketing_video/main_marketing_video.dart @@ -14,48 +14,44 @@ void main() { class MarketingVideo extends StatefulWidget { @override - _MarketingVideoState createState() => _MarketingVideoState(); + State createState() => _MarketingVideoState(); } class _MarketingVideoState extends State { final _docLayoutKey = GlobalKey(); - late DocumentEditor _editor; - DocumentComposer? _composer; + late MutableDocument _document; + late MutableDocumentComposer _composer; + late Editor _editor; @override void initState() { super.initState(); - final doc = MutableDocument( - nodes: [ - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: ''), + _document = MutableDocument.empty(); + _composer = MutableDocumentComposer( + initialSelection: DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: _document.first.id, + nodePosition: _document.first.endPosition, ), - ], - ); - _editor = DocumentEditor(document: doc); - _composer = DocumentComposer( - initialSelection: DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: doc.nodes.first.id, - nodePosition: doc.nodes.first.endPosition, ), - )); + ); + _editor = createDefaultDocumentEditor(document: _document, composer: _composer); _startRobot(); } @override void dispose() { - _composer!.dispose(); + _composer.dispose(); super.dispose(); } Future _startRobot() async { final robot = DocumentEditingRobot( editor: _editor, - composer: _composer!, + document: _document, + composer: _composer, documentLayoutFinder: () => _docLayoutKey.currentState as DocumentLayout?, ); @@ -197,14 +193,13 @@ class _MarketingVideoState extends State { child: SuperEditor( documentLayoutKey: _docLayoutKey, editor: _editor, - composer: _composer, stylesheet: defaultStylesheet.copyWith( documentPadding: const EdgeInsets.all(16), addRulesAfter: [ StyleRule( BlockSelector.all, (doc, node) => { - "padding": const CascadingPadding.all(0.0), + Styles.padding: const CascadingPadding.all(0.0), }), ], inlineTextStyler: (attributions, style) => _textStyleBuilder(attributions), @@ -250,19 +245,23 @@ const headerAttribution = NamedAttribution('header'); class DocumentEditingRobot { DocumentEditingRobot({ - required DocumentEditor editor, + required Editor editor, + required Document document, required DocumentComposer composer, required DocumentLayoutFinder documentLayoutFinder, int? randomSeed, }) : _editor = editor, + _document = document, _composer = composer, _editorOps = CommonEditorOperations( editor: editor, + document: document, composer: composer, documentLayoutResolver: documentLayoutFinder as DocumentLayout Function()), _random = Random(randomSeed); - final DocumentEditor _editor; + final Editor _editor; + final Document _document; final DocumentComposer _composer; final CommonEditorOperations _editorOps; final _actionQueue = []; @@ -272,7 +271,13 @@ class DocumentEditingRobot { _actionQueue.add( _randomPauseBefore( () { - _composer.selection = DocumentSelection.collapsed(position: position); + _editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed(position: position), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); }, ), ); @@ -282,7 +287,13 @@ class DocumentEditingRobot { _actionQueue.add( _randomPauseBefore( () { - _composer.selection = selection; + _editor.execute([ + ChangeSelectionRequest( + selection, + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); }, ), ); @@ -292,16 +303,22 @@ class DocumentEditingRobot { _actionQueue.add( _randomPauseBefore( () { - _composer.selection = DocumentSelection( - base: DocumentPosition( - nodeId: _editor.document.nodes.first.id, - nodePosition: _editor.document.nodes.first.beginningPosition, + _editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: _document.first.id, + nodePosition: _document.first.beginningPosition, + ), + extent: DocumentPosition( + nodeId: _document.last.id, + nodePosition: _document.last.endPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, ), - extent: DocumentPosition( - nodeId: _editor.document.nodes.last.id, - nodePosition: _editor.document.nodes.last.endPosition, - ), - ); + ]); }, ), ); @@ -363,10 +380,6 @@ class DocumentEditingRobot { _randomPauseBefore( () { _editorOps.insertCharacter(character); - - if (character == ' ') { - _editorOps.convertParagraphByPatternMatching(_composer.selection!.extent.nodeId); - } }, ), ); @@ -379,10 +392,6 @@ class DocumentEditingRobot { _randomPauseBefore( () { _editorOps.insertCharacter(character); - - if (character == ' ') { - _editorOps.convertParagraphByPatternMatching(_composer.selection!.extent.nodeId); - } }, true, ), @@ -406,7 +415,9 @@ class DocumentEditingRobot { _actionQueue.add( _randomPauseBefore( () { - _editorOps.insertBlockLevelNewline(); + _editor.execute([ + InsertNewlineAtCaretRequest(), + ]); }, ), ); @@ -436,7 +447,9 @@ class DocumentEditingRobot { _actionQueue.add( _randomPauseBefore( () { - _editorOps.insertPlainText(text); + _editor.execute([ + InsertPlainTextAtCaretRequest(text), + ]); }, ), ); diff --git a/super_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift b/super_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift index 0d56f519c9..a1cdfd0cd9 100644 --- a/super_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/super_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,10 @@ import FlutterMacOS import Foundation -import path_provider_macos +import path_provider_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/super_editor/example/macos/Podfile b/super_editor/example/macos/Podfile index dade8dfad0..049abe2954 100644 --- a/super_editor/example/macos/Podfile +++ b/super_editor/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.11' +platform :osx, '10.14' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/super_editor/example/macos/Podfile.lock b/super_editor/example/macos/Podfile.lock index 11e74f9db8..e0c61bf72d 100644 --- a/super_editor/example/macos/Podfile.lock +++ b/super_editor/example/macos/Podfile.lock @@ -1,22 +1,29 @@ PODS: - FlutterMacOS (1.0.0) - - path_provider_macos (0.0.1): + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): - FlutterMacOS DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral - path_provider_macos: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: - FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811 - path_provider_macos: 160cab0d5461f0c0e02995469a98f24bdb9a3f1f + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 -PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c +PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 -COCOAPODS: 1.11.3 +COCOAPODS: 1.16.2 diff --git a/super_editor/example/macos/Runner.xcodeproj/project.pbxproj b/super_editor/example/macos/Runner.xcodeproj/project.pbxproj index 9e7ba507f3..86ab93d237 100644 --- a/super_editor/example/macos/Runner.xcodeproj/project.pbxproj +++ b/super_editor/example/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -203,7 +203,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -256,6 +256,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -404,7 +405,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -483,7 +484,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -530,7 +531,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/super_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index fb7259e177..12076c83de 100644 --- a/super_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/super_editor/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ diff --git a/super_editor/example/macos/Runner/AppDelegate.swift b/super_editor/example/macos/Runner/AppDelegate.swift index d53ef64377..b3c1761412 100644 --- a/super_editor/example/macos/Runner/AppDelegate.swift +++ b/super_editor/example/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/super_editor/example/pubspec.lock b/super_editor/example/pubspec.lock deleted file mode 100644 index f3151e53ac..0000000000 --- a/super_editor/example/pubspec.lock +++ /dev/null @@ -1,584 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - url: "https://pub.dartlang.org" - source: hosted - version: "39.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.0" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.0" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.9.0" - attributed_text: - dependency: transitive - description: - name: attributed_text - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0" - 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.1" - charcode: - dependency: "direct main" - 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.1" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.16.0" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - coverage: - dependency: transitive - description: - name: coverage - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.4" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - ffi: - dependency: transitive - description: - name: ffi - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.2" - file: - dependency: transitive - description: - name: file - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.2" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_keyboard_visibility: - dependency: "direct main" - description: - name: flutter_keyboard_visibility - url: "https://pub.dartlang.org" - source: hosted - version: "5.2.0" - 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_localizations: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_test_robots: - dependency: transitive - description: - name: flutter_test_robots - url: "https://pub.dartlang.org" - source: hosted - version: "0.0.17" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - glob: - dependency: transitive - description: - name: glob - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - url: "https://pub.dartlang.org" - source: hosted - version: "2.3.1" - http: - dependency: "direct main" - description: - name: http - url: "https://pub.dartlang.org" - source: hosted - version: "0.13.4" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - url: "https://pub.dartlang.org" - source: hosted - version: "3.2.0" - http_parser: - dependency: transitive - description: - name: http_parser - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.0" - intl: - dependency: "direct main" - description: - name: intl - url: "https://pub.dartlang.org" - source: hosted - version: "0.17.0" - io: - dependency: transitive - description: - name: io - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.4" - linkify: - dependency: "direct main" - description: - name: linkify - url: "https://pub.dartlang.org" - source: hosted - version: "4.1.0" - logging: - dependency: "direct main" - description: - name: logging - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - markdown: - dependency: transitive - description: - name: markdown - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.1" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.12" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.5" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0" - mime: - dependency: transitive - description: - name: mime - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - node_preamble: - dependency: transitive - description: - name: node_preamble - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - package_config: - dependency: transitive - description: - name: package_config - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.2" - path_provider: - dependency: transitive - description: - name: path_provider - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.9" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.12" - path_provider_ios: - dependency: transitive - description: - name: path_provider_ios - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.8" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.5" - path_provider_macos: - dependency: transitive - description: - name: path_provider_macos - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.5" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.3" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.5" - platform: - dependency: transitive - description: - name: platform - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - pool: - dependency: transitive - description: - name: pool - url: "https://pub.dartlang.org" - source: hosted - version: "1.5.0" - process: - dependency: transitive - description: - name: process - url: "https://pub.dartlang.org" - source: hosted - version: "4.2.4" - pub_semver: - dependency: transitive - description: - name: pub_semver - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - shelf: - dependency: transitive - description: - name: shelf - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0" - shelf_static: - dependency: transitive - description: - name: shelf_static - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - source_maps: - dependency: transitive - description: - name: source_maps - url: "https://pub.dartlang.org" - source: hosted - version: "0.10.10" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.9.0" - 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.1" - super_editor: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "0.2.2" - super_editor_markdown: - dependency: "direct main" - description: - path: "../../super_editor_markdown" - relative: true - source: path - version: "0.1.3" - super_text_layout: - dependency: "direct main" - description: - path: "../../super_text_layout" - relative: true - source: path - version: "0.1.4" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" - test: - dependency: transitive - description: - name: test - url: "https://pub.dartlang.org" - source: hosted - version: "1.21.4" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.12" - test_core: - dependency: transitive - description: - name: test_core - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.16" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - uuid: - dependency: "direct main" - description: - name: uuid - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.6" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - vm_service: - dependency: transitive - description: - name: vm_service - url: "https://pub.dartlang.org" - source: hosted - version: "7.5.0" - watcher: - dependency: transitive - description: - name: watcher - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - win32: - dependency: transitive - description: - name: win32 - url: "https://pub.dartlang.org" - source: hosted - version: "2.5.1" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0+1" - yaml: - dependency: transitive - description: - name: yaml - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" -sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=2.10.0-0" diff --git a/super_editor/example/pubspec.yaml b/super_editor/example/pubspec.yaml index b32d16d357..47e5cab678 100644 --- a/super_editor/example/pubspec.yaml +++ b/super_editor/example/pubspec.yaml @@ -3,7 +3,7 @@ description: A new Flutter project. # The following line prevents the package from being accidentally published to # pub.dev using `pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -18,7 +18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: '>=2.12.0 <3.0.0' + sdk: ">=3.0.0 <4.0.0" dependencies: flutter: @@ -26,44 +26,47 @@ dependencies: flutter_localizations: sdk: flutter - intl: ^0.17.0 + # An exact version pin will be provided by the Flutter SDK. + intl: any # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.1 charcode: ^1.1.3 - flutter_keyboard_visibility: ^5.0.3 - google_fonts: ^2.0.0 - http: ^0.13.1 - linkify: ^4.0.0 - logging: ^1.0.1 - uuid: ^3.0.3 + flutter_keyboard_visibility: ^6.0.0 + google_fonts: ^6.2.1 + http: ^1.2.2 + linkify: ^5.0.0 + logging: ^1.3.0 + uuid: ^4.5.1 super_editor: git: url: https://github.com/superlistapp/super_editor.git path: super_editor - super_editor_markdown: + super_text_layout: git: url: https://github.com/superlistapp/super_editor.git - path: super_editor_markdown - super_text_layout: ^0.1.0 + path: super_text_layout + super_keyboard: ^0.4.0 + follow_the_leader: ^0.5.3 + overlord: 0.4.2 dependency_overrides: # Override to local mono-repo path so devs can test this repo # against changes that they're making to other mono-repo packages super_editor: path: ../ - super_editor_markdown: - path: ../../super_editor_markdown super_text_layout: path: ../../super_text_layout - + attributed_text: + path: ../../attributed_text dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^2.0.1 # integration_test: # sdk: flutter diff --git a/super_editor/example/windows/flutter/generated_plugin_registrant.cc b/super_editor/example/windows/flutter/generated_plugin_registrant.cc index 8b6d4680af..4f7884874d 100644 --- a/super_editor/example/windows/flutter/generated_plugin_registrant.cc +++ b/super_editor/example/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/super_editor/example/windows/flutter/generated_plugins.cmake b/super_editor/example/windows/flutter/generated_plugins.cmake index b93c4c30c1..88b22e5c77 100644 --- a/super_editor/example/windows/flutter/generated_plugins.cmake +++ b/super_editor/example/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/super_editor/example_chat/.gitignore b/super_editor/example_chat/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/super_editor/example_chat/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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 +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/super_editor/example_chat/.metadata b/super_editor/example_chat/.metadata new file mode 100644 index 0000000000..b02a7e4bf2 --- /dev/null +++ b/super_editor/example_chat/.metadata @@ -0,0 +1,45 @@ +# 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: "17025dd88227cd9532c33fa78f5250d548d87e9a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: android + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: ios + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: linux + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: macos + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: web + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: windows + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_editor/example_chat/README.md b/super_editor/example_chat/README.md new file mode 100644 index 0000000000..84de68a51a --- /dev/null +++ b/super_editor/example_chat/README.md @@ -0,0 +1,16 @@ +# example_chat + +A new Flutter project. + +## 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://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/super_editor/example_chat/analysis_options.yaml b/super_editor/example_chat/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_editor/example_chat/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_editor/example_chat/android/.gitignore b/super_editor/example_chat/android/.gitignore new file mode 100644 index 0000000000..55afd919c6 --- /dev/null +++ b/super_editor/example_chat/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/super_editor/example_chat/android/app/build.gradle b/super_editor/example_chat/android/app/build.gradle new file mode 100644 index 0000000000..3dda90e890 --- /dev/null +++ b/super_editor/example_chat/android/app/build.gradle @@ -0,0 +1,44 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.example.example_chat" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.example_chat" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/super_editor/example_chat/android/app/src/debug/AndroidManifest.xml b/super_editor/example_chat/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_editor/example_chat/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_editor/example_chat/android/app/src/main/AndroidManifest.xml b/super_editor/example_chat/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..98ce372c6a --- /dev/null +++ b/super_editor/example_chat/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_chat/android/app/src/main/kotlin/com/example/example_chat/MainActivity.kt b/super_editor/example_chat/android/app/src/main/kotlin/com/example/example_chat/MainActivity.kt new file mode 100644 index 0000000000..bff0576d91 --- /dev/null +++ b/super_editor/example_chat/android/app/src/main/kotlin/com/example/example_chat/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.example_chat + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/super_editor/example_chat/android/app/src/main/res/drawable-v21/launch_background.xml b/super_editor/example_chat/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/super_editor/example_chat/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_editor/example_chat/android/app/src/main/res/drawable/launch_background.xml b/super_editor/example_chat/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..304732f884 --- /dev/null +++ b/super_editor/example_chat/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_editor/example_chat/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/super_editor/example_chat/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..db77bb4b7b Binary files /dev/null and b/super_editor/example_chat/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/super_editor/example_chat/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/super_editor/example_chat/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..17987b79bb Binary files /dev/null and b/super_editor/example_chat/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/super_editor/example_chat/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/super_editor/example_chat/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..09d4391482 Binary files /dev/null and b/super_editor/example_chat/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/super_editor/example_chat/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/super_editor/example_chat/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d5f1c8d34e Binary files /dev/null and b/super_editor/example_chat/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/super_editor/example_chat/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/super_editor/example_chat/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..4d6372eebd Binary files /dev/null and b/super_editor/example_chat/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/super_editor/example_chat/android/app/src/main/res/values-night/styles.xml b/super_editor/example_chat/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/super_editor/example_chat/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_editor/example_chat/android/app/src/main/res/values/styles.xml b/super_editor/example_chat/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..cb1ef88056 --- /dev/null +++ b/super_editor/example_chat/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_editor/example_chat/android/app/src/profile/AndroidManifest.xml b/super_editor/example_chat/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_editor/example_chat/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_editor/example_chat/android/build.gradle b/super_editor/example_chat/android/build.gradle new file mode 100644 index 0000000000..d2ffbffa4c --- /dev/null +++ b/super_editor/example_chat/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/super_editor/example_chat/android/gradle.properties b/super_editor/example_chat/android/gradle.properties new file mode 100644 index 0000000000..2597170821 --- /dev/null +++ b/super_editor/example_chat/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/super_editor/example_chat/android/gradle/wrapper/gradle-wrapper.properties b/super_editor/example_chat/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..7bb2df6ba6 --- /dev/null +++ b/super_editor/example_chat/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip diff --git a/super_editor/example_chat/android/settings.gradle b/super_editor/example_chat/android/settings.gradle new file mode 100644 index 0000000000..a42444ded0 --- /dev/null +++ b/super_editor/example_chat/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.2.1" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/super_editor/example_chat/ios/.gitignore b/super_editor/example_chat/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/super_editor/example_chat/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/super_editor/example_chat/ios/Flutter/AppFrameworkInfo.plist b/super_editor/example_chat/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..1dc6cf7652 --- /dev/null +++ b/super_editor/example_chat/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/super_editor/example_chat/ios/Flutter/Debug.xcconfig b/super_editor/example_chat/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..ec97fc6f30 --- /dev/null +++ b/super_editor/example_chat/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/super_editor/example_chat/ios/Flutter/Release.xcconfig b/super_editor/example_chat/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..c4855bfe20 --- /dev/null +++ b/super_editor/example_chat/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/super_editor/example_chat/ios/Podfile b/super_editor/example_chat/ios/Podfile new file mode 100644 index 0000000000..e51a31d9ca --- /dev/null +++ b/super_editor/example_chat/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# 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', '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 Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/super_editor/example_chat/ios/Podfile.lock b/super_editor/example_chat/ios/Podfile.lock new file mode 100644 index 0000000000..3f25b673d7 --- /dev/null +++ b/super_editor/example_chat/ios/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - Flutter (1.0.0) + - super_keyboard (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - super_keyboard (from `.symlinks/plugins/super_keyboard/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + super_keyboard: + :path: ".symlinks/plugins/super_keyboard/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + super_keyboard: 016de6ce9ab826f9a0b185608209d6a3b556d577 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + +PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694 + +COCOAPODS: 1.16.2 diff --git a/super_editor/example_chat/ios/Runner.xcodeproj/project.pbxproj b/super_editor/example_chat/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..812c6c50c4 --- /dev/null +++ b/super_editor/example_chat/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,731 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 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 */; }; + A1458447C70B49238C3BAEC4 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7BE48D258DE3DE23D1802D52 /* Pods_RunnerTests.framework */; }; + B78EBAD01CB9963BF943167B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF14231289D63B4E8CDB0C9C /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 125F3AF2F4A4082E78D4EB40 /* 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 = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 47627721A688FF697F7AD4C5 /* 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7BE48D258DE3DE23D1802D52 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 87A2C9871A8E1EF827D4A8F9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 96BECD946F965E8FB9FD5ECB /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AB4A3797C3FE69C5E7E8772A /* 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 = ""; }; + B5BC7666486A0D2C38D5EE99 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + DF14231289D63B4E8CDB0C9C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B78EBAD01CB9963BF943167B /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F4E2A752390606553E36C61B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1458447C70B49238C3BAEC4 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 5E21AEE819AE224771E1CC77 /* Pods */ = { + isa = PBXGroup; + children = ( + 47627721A688FF697F7AD4C5 /* Pods-Runner.debug.xcconfig */, + 125F3AF2F4A4082E78D4EB40 /* Pods-Runner.release.xcconfig */, + AB4A3797C3FE69C5E7E8772A /* Pods-Runner.profile.xcconfig */, + 87A2C9871A8E1EF827D4A8F9 /* Pods-RunnerTests.debug.xcconfig */, + 96BECD946F965E8FB9FD5ECB /* Pods-RunnerTests.release.xcconfig */, + B5BC7666486A0D2C38D5EE99 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 914D60B3B53306BB349E7E8D /* Frameworks */ = { + isa = PBXGroup; + children = ( + DF14231289D63B4E8CDB0C9C /* Pods_Runner.framework */, + 7BE48D258DE3DE23D1802D52 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 5E21AEE819AE224771E1CC77 /* Pods */, + 914D60B3B53306BB349E7E8D /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 1E604AB5A0022883F581B088 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + F4E2A752390606553E36C61B /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 68E943D832161F3B6F0484B3 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + CCF8107226B5AB2C98624563 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1E604AB5A0022883F581B088 /* [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-RunnerTests-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; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 68E943D832161F3B6F0484B3 /* [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; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + CCF8107226B5AB2C98624563 /* [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; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2X9AB296W2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleChat; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 87A2C9871A8E1EF827D4A8F9 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleChat.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 96BECD946F965E8FB9FD5ECB /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleChat.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B5BC7666486A0D2C38D5EE99 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleChat.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2X9AB296W2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleChat; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2X9AB296W2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleChat; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/super_editor/example_chat/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/super_editor/example_chat/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/super_editor/example_chat/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_editor/example_chat/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor/example_chat/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor/example_chat/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor/example_chat/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_editor/example_chat/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_editor/example_chat/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_editor/example_chat/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_editor/example_chat/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..e3773d42e2 --- /dev/null +++ b/super_editor/example_chat/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_chat/ios/Runner.xcworkspace/contents.xcworkspacedata b/super_editor/example_chat/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_editor/example_chat/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_editor/example_chat/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor/example_chat/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor/example_chat/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor/example_chat/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_editor/example_chat/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_editor/example_chat/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_editor/example_chat/ios/Runner/AppDelegate.swift b/super_editor/example_chat/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..626664468b --- /dev/null +++ b/super_editor/example_chat/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000..dc9ada4725 Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..7353c41ecf Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..6ed2d933e1 Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..4cd7b0099c Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..fe730945a0 Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..321773cd85 Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..502f463a9b Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..e9f5fea27c Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..84ac32ae7d Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..8953cba090 Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..0467bf12aa Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/super_editor/example_chat/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/super_editor/example_chat/ios/Runner/Base.lproj/LaunchScreen.storyboard b/super_editor/example_chat/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/super_editor/example_chat/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_chat/ios/Runner/Base.lproj/Main.storyboard b/super_editor/example_chat/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/super_editor/example_chat/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_chat/ios/Runner/Info.plist b/super_editor/example_chat/ios/Runner/Info.plist new file mode 100644 index 0000000000..e25e4f0e5e --- /dev/null +++ b/super_editor/example_chat/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example Chat + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example_chat + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/super_editor/example_chat/ios/Runner/Runner-Bridging-Header.h b/super_editor/example_chat/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/super_editor/example_chat/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/super_editor/example_chat/ios/RunnerTests/RunnerTests.swift b/super_editor/example_chat/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..86a7c3b1b6 --- /dev/null +++ b/super_editor/example_chat/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_editor/example_chat/lib/main.dart b/super_editor/example_chat/lib/main.dart new file mode 100644 index 0000000000..be52771ae5 --- /dev/null +++ b/super_editor/example_chat/lib/main.dart @@ -0,0 +1,17 @@ +import 'package:example_chat/message_page_scaffold_demo/message_page_scaffold_demo.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + initLoggers(Level.ALL, { + // messagePageLayoutLog, + // messageEditorHeightLog, + // contentLayersLog, + }); + + runApp( + MaterialApp( + home: MessagePageScaffoldDemo(), + ), + ); +} diff --git a/super_editor/example_chat/lib/main_chat_bubbles.dart b/super_editor/example_chat/lib/main_chat_bubbles.dart new file mode 100644 index 0000000000..57a69e9008 --- /dev/null +++ b/super_editor/example_chat/lib/main_chat_bubbles.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + initLoggers(Level.FINE, { + // contentLayersLog, + }); + + runApp(_ChatBubbleApp()); +} + +class _ChatBubbleApp extends StatelessWidget { + const _ChatBubbleApp(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: _ChatBubblePage(), + ), + ); + } +} + +class _ChatBubblePage extends StatefulWidget { + const _ChatBubblePage(); + + @override + State<_ChatBubblePage> createState() => _ChatBubblePageState(); +} + +class _ChatBubblePageState extends State<_ChatBubblePage> { + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16, top: 56), + children: [ + _ChatBubble( + conversationRole: _ConversationRole.me, + message: "Does Super Editor support chat use-cases?", + ), + _ChatBubble( + conversationRole: _ConversationRole.other, + message: "Yep", + ), + _ChatBubble( + conversationRole: _ConversationRole.me, + message: "How so?", + ), + _ChatBubble( + conversationRole: _ConversationRole.other, + message: + "Super Editor displays rich text messages with a SuperMessage widget. Those can be displayed in a conversation list, and styled to look like chat bubbles.", + ), + ], + ); + } +} + +class _ChatBubble extends StatelessWidget { + const _ChatBubble({ + required this.conversationRole, + required this.message, + }); + + final _ConversationRole conversationRole; + final String message; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: LayoutBuilder(builder: (context, constraints) { + return Align( + alignment: switch (conversationRole) { + _ConversationRole.me => Alignment.centerRight, + _ConversationRole.other => Alignment.centerLeft, + }, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: constraints.maxWidth * 0.8), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: switch (conversationRole) { + _ConversationRole.me => Colors.blue, + _ConversationRole.other => Colors.blueGrey, + }, + ), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: SuperMessage( + editor: createDefaultDocumentEditor( + document: MutableDocument(nodes: [ParagraphNode(id: "1", text: AttributedText(message))]), + ), + ), + ), + ), + ); + }), + ); + } +} + +enum _ConversationRole { + me, + other; +} diff --git a/super_editor/example_chat/lib/message_page_scaffold_demo/demo_chaos_monkey_message_page.dart b/super_editor/example_chat/lib/message_page_scaffold_demo/demo_chaos_monkey_message_page.dart new file mode 100644 index 0000000000..93d224e458 --- /dev/null +++ b/super_editor/example_chat/lib/message_page_scaffold_demo/demo_chaos_monkey_message_page.dart @@ -0,0 +1,507 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A chat experience, which includes a simulated list of comments, as well as +/// a bottom-mounted message editor. +/// +/// In the case of this chaos monkey demo, instead of including a message editor, +/// this demo includes a colorful rectangle that constantly changes its height, +/// which helps to verify a variety of layout situations. +class ChaosMonkeyMessagePageDemo extends StatefulWidget { + const ChaosMonkeyMessagePageDemo({super.key}); + + @override + State createState() => _ChaosMonkeyMessagePageDemoState(); +} + +class _ChaosMonkeyMessagePageDemoState extends State { + final _messagePageController = MessagePageController(); + + @override + Widget build(BuildContext context) { + return MessagePageScaffold( + controller: _messagePageController, + bottomSheetMinimumTopGap: 150, + bottomSheetMinimumHeight: 148, + contentBuilder: (contentContext, bottomSpacing) { + return MediaQuery.removePadding( + context: contentContext, + removeBottom: true, + // ^ Remove bottom padding because if we don't, when the keyboard + // opens to edit the bottom sheet, this content behind the bottom + // sheet adds some phantom space at the bottom, slightly pushing + // it up for no reason. + child: Stack( + children: [ + Positioned.fill( + child: ColoredBox(color: Colors.purpleAccent.shade100), + ), + Positioned( + left: 0, + right: 0, + top: 0, + bottom: bottomSpacing, + child: _ChatThread(), + ), + ], + ), + ); + }, + bottomSheetBuilder: (messageContext) { + return _EditorBottomSheet( + messagePageController: _messagePageController, + ); + }, + ); + } +} + +/// A simulated chat conversation thread, which is simulated as a bottom-aligned +/// list of tiles. +class _ChatThread extends StatelessWidget { + const _ChatThread(); + + @override + Widget build(BuildContext context) { + return ListView.builder( + reverse: true, + // ^ The list starts at the bottom and grows upward. This is how + // we should layout chat conversations where the most recent + // message appears at the bottom, and you want to retain the + // scroll offset near the newest messages, not the oldest. + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.white.withValues(alpha: 0.5), + child: ListTile( + title: Text("Item $index"), + ), + ), + ); + }, + ); + } +} + +/// Bottom sheet that represents where a message editor would usually appear, +/// but in this case it has a constantly animating content rectangle. +class _EditorBottomSheet extends StatefulWidget { + const _EditorBottomSheet({ + required this.messagePageController, + }); + + final MessagePageController messagePageController; + + @override + State<_EditorBottomSheet> createState() => _EditorBottomSheetState(); +} + +class _EditorBottomSheetState extends State<_EditorBottomSheet> { + final _dragIndicatorKey = GlobalKey(); + + final _scrollController = ScrollController(); + + final _editorSheetKey = GlobalKey(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _scrollController.dispose(); + + super.dispose(); + } + + double _dragTouchOffsetFromIndicator = 0; + + void _onVerticalDragStart(DragStartDetails details) { + _dragTouchOffsetFromIndicator = _dragFingerOffsetFromIndicator(details.globalPosition); + + widget.messagePageController.onDragStart( + details.globalPosition.dy - _dragIndicatorOffsetFromTop + _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragUpdate(DragUpdateDetails details) { + widget.messagePageController.onDragUpdate( + details.globalPosition.dy - _dragIndicatorOffsetFromTop + _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragEnd(DragEndDetails details) { + widget.messagePageController.onDragEnd(); + } + + void _onVerticalDragCancel() { + widget.messagePageController.onDragEnd(); + } + + double get _dragIndicatorOffsetFromTop { + final editorSheetBox = _editorSheetKey.currentContext!.findRenderObject(); + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero, ancestor: editorSheetBox).dy; + } + + double _dragFingerOffsetFromIndicator(Offset globalDragOffset) { + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero).dy - globalDragOffset.dy; + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + key: _editorSheetKey, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.5), + border: Border(top: BorderSide(color: Colors.grey)), + ), + child: KeyboardScaffoldSafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDragHandle(), + Flexible( + child: _buildSheetContent(), + ), + ], + ), + ), + ); + } + + Widget _buildSheetContent() { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + // ^ Avoid the bottom notch when the keyboard is closed. + ), + child: BottomSheetEditorHeight( + previewHeight: 72, + child: _ChatEditor( + messagePageController: widget.messagePageController, + ), + ), + ); + } + + Widget _buildDragHandle() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onVerticalDragStart: _onVerticalDragStart, + onVerticalDragUpdate: _onVerticalDragUpdate, + onVerticalDragEnd: _onVerticalDragEnd, + onVerticalDragCancel: _onVerticalDragCancel, + behavior: HitTestBehavior.opaque, + // ^ Opaque to handle tough events in our invisible padding. + child: Padding( + padding: const EdgeInsets.all(18), + // ^ Expand the hit area with invisible padding. + child: Container( + key: _dragIndicatorKey, + width: 32, + height: 5, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ], + ); + } +} + +/// An editor for composing chat messages. +class _ChatEditor extends StatefulWidget { + const _ChatEditor({ + required this.messagePageController, + }); + + final MessagePageController messagePageController; + + @override + State<_ChatEditor> createState() => _ChatEditorState(); +} + +class _ChatEditorState extends State<_ChatEditor> with SingleTickerProviderStateMixin { + final _scrollController = ScrollController(); + + late final KeyboardPanelController<_Panel> _keyboardPanelController; + late final SoftwareKeyboardController _softwareKeyboardController; + final _isImeConnected = ValueNotifier(false); + + late final AnimationController _chaosMonkeyAnimation; + Timer? _chaosPauseTimer; + + @override + void initState() { + super.initState(); + + _softwareKeyboardController = SoftwareKeyboardController() // + ..attach(_DoNothingSoftwareKeyboardControllerDelegate()); + _keyboardPanelController = KeyboardPanelController( + _softwareKeyboardController, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + // FIXME: We have to run this in a post frame callback because it requires an attached + // delegate, which isn't available until the next frame. We should create a + // setting for what's desired and let the delegate deal with it. + _keyboardPanelController.toolbarVisibility = KeyboardToolbarVisibility.visible; + }); + + widget.messagePageController.addListener(_onMessagePageControllerChange); + + _chaosMonkeyAnimation = AnimationController( + vsync: this, + lowerBound: 75, + upperBound: 750, + duration: const Duration(seconds: 3), + ) + ..addStatusListener(_onAnimationStatusChange) + ..forward(); + + _onImeConnectionChange(); + _isImeConnected.addListener(_onImeConnectionChange); + } + + @override + void didUpdateWidget(_ChatEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.messagePageController != oldWidget.messagePageController) { + oldWidget.messagePageController.removeListener(_onMessagePageControllerChange); + widget.messagePageController.addListener(_onMessagePageControllerChange); + } + } + + @override + void dispose() { + _chaosMonkeyAnimation.dispose(); + _chaosPauseTimer?.cancel(); + + widget.messagePageController.removeListener(_onMessagePageControllerChange); + + _scrollController.dispose(); + + _keyboardPanelController.dispose(); + _isImeConnected.dispose(); + + super.dispose(); + } + + void _onImeConnectionChange() { + widget.messagePageController.collapsedMode = + _isImeConnected.value ? MessagePageSheetCollapsedMode.intrinsic : MessagePageSheetCollapsedMode.preview; + } + + void _onAnimationStatusChange(AnimationStatus status) { + const pauseDuration = Duration(seconds: 3); + _chaosPauseTimer?.cancel(); + + switch (status) { + case AnimationStatus.dismissed: + _chaosPauseTimer = Timer(pauseDuration, _startAfterPause); + case AnimationStatus.completed: + _chaosPauseTimer = Timer(pauseDuration, _reverseAfterPause); + case AnimationStatus.forward: + case AnimationStatus.reverse: + // Don't care. + } + } + + void _startAfterPause() { + _chaosMonkeyAnimation.forward(); + } + + void _reverseAfterPause() { + _chaosMonkeyAnimation.reverse(); + } + + void _onMessagePageControllerChange() { + if (widget.messagePageController.isPreview) { + // Always scroll the editor to the top when in preview mode. + _scrollController.position.jumpTo(0); + } + } + + @override + Widget build(BuildContext context) { + return KeyboardPanelScaffold( + controller: _keyboardPanelController, + isImeConnected: _isImeConnected, + toolbarBuilder: (BuildContext context, _Panel? openPanel) { + return Container( + width: double.infinity, + height: 54, + color: Colors.white.withValues(alpha: 0.3), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Spacer(), + GestureDetector( + onTap: () { + _softwareKeyboardController.open(viewId: View.of(context).viewId); + _isImeConnected.value = true; + }, + child: Icon(Icons.keyboard_alt_rounded), + ), + const SizedBox(width: 16), + GestureDetector( + onTap: () { + _softwareKeyboardController.close(); + _isImeConnected.value = false; + }, + child: Icon(Icons.keyboard_hide_outlined), + ), + ], + ), + ); + }, + keyboardPanelBuilder: (BuildContext context, _Panel? openPanel) { + return SizedBox(); + }, + contentBuilder: (BuildContext context, _Panel? openPanel) { + return AnimatedBuilder( + animation: _chaosMonkeyAnimation, + builder: (context, snapshot) { + return CupertinoScrollbar( + controller: _scrollController, + thumbVisibility: true, + thickness: 10, + child: SingleChildScrollView( + controller: _scrollController, + child: Container( + height: _chaosMonkeyAnimation.value, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.purpleAccent, Colors.deepPurple], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ), + ), + ); + }, + ); + }, + ); + } +} + +enum _Panel { + thePanel; +} + +class _DoNothingSoftwareKeyboardControllerDelegate implements SoftwareKeyboardControllerDelegate { + TextInputConnection? _textInputConnection; + + @override + bool get isConnectedToIme => _textInputConnection != null; + + @override + void open({ + required int viewId, + }) { + print("ATTACHING TO TEXT INPUT CLIENT"); + _textInputConnection = TextInput.attach( + _InvisibleTextInputClient(), + TextInputConfiguration(viewId: viewId), + )..show(); + } + + @override + void hide() { + SystemChannels.textInput.invokeListMethod("TextInput.hide"); + } + + @override + void close() { + _textInputConnection?.close(); + _textInputConnection = null; + } +} + +class _InvisibleTextInputClient implements TextInputClient { + @override + void connectionClosed() { + // TODO: implement connectionClosed + } + + @override + // TODO: implement currentAutofillScope + AutofillScope? get currentAutofillScope => throw UnimplementedError(); + + @override + TextEditingValue? get currentTextEditingValue => TextEditingValue(); + + @override + void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) { + // TODO: implement didChangeInputControl + } + + @override + void insertContent(KeyboardInsertedContent content) { + // TODO: implement insertContent + } + + @override + void insertTextPlaceholder(Size size) { + // TODO: implement insertTextPlaceholder + } + + @override + void performAction(TextInputAction action) { + // TODO: implement performAction + } + + @override + void performPrivateCommand(String action, Map data) { + // TODO: implement performPrivateCommand + } + + @override + void performSelector(String selectorName) { + // TODO: implement performSelector + } + + @override + void removeTextPlaceholder() { + // TODO: implement removeTextPlaceholder + } + + @override + void showAutocorrectionPromptRect(int start, int end) { + // TODO: implement showAutocorrectionPromptRect + } + + @override + void showToolbar() { + // TODO: implement showToolbar + } + + @override + void updateEditingValue(TextEditingValue value) { + // TODO: implement updateEditingValue + } + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + // TODO: implement updateFloatingCursor + } +} diff --git a/super_editor/example_chat/lib/message_page_scaffold_demo/demo_super_editor_message_page.dart b/super_editor/example_chat/lib/message_page_scaffold_demo/demo_super_editor_message_page.dart new file mode 100644 index 0000000000..08258e6cb1 --- /dev/null +++ b/super_editor/example_chat/lib/message_page_scaffold_demo/demo_super_editor_message_page.dart @@ -0,0 +1,729 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_keyboard/super_keyboard.dart'; + +/// A chat experience, which includes a simulated list of comments, as well as +/// a bottom-mounted message editor, which uses `SuperEditor` for writing messages. +class SuperEditorMessagePageDemo extends StatefulWidget { + const SuperEditorMessagePageDemo({super.key}); + + @override + State createState() => _SuperEditorMessagePageDemoState(); +} + +class _SuperEditorMessagePageDemoState extends State { + @override + void initState() { + super.initState(); + + SKLog.startLogging(); + } + + @override + void dispose() { + SKLog.stopLogging(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _ChatPage( + inputRole: "Home", + ); + } +} + +class _ChatPage extends StatefulWidget { + const _ChatPage({ + required this.inputRole, + }); + + final String inputRole; + + @override + State<_ChatPage> createState() => _ChatPageState(); +} + +class _ChatPageState extends State<_ChatPage> { + final _messagePageController = MessagePageController(); + + @override + void dispose() { + _messagePageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MessagePageScaffold( + controller: _messagePageController, + bottomSheetMinimumTopGap: 150, + bottomSheetMinimumHeight: 148, + contentBuilder: (contentContext, bottomSpacing) { + return MediaQuery.removePadding( + context: contentContext, + removeBottom: true, + // ^ Remove bottom padding because if we don't, when the keyboard + // opens to edit the bottom sheet, this content behind the bottom + // sheet adds some phantom space at the bottom, slightly pushing + // it up for no reason. + child: Stack( + children: [ + Positioned.fill( + child: ColoredBox(color: Colors.purpleAccent.shade100), + ), + Positioned( + left: 0, + right: 0, + top: 0, + bottom: bottomSpacing, + child: _ChatThread(), + ), + ], + ), + ); + }, + bottomSheetBuilder: (messageContext) { + return _EditorBottomSheet( + messagePageController: _messagePageController, + inputRole: widget.inputRole, + ); + }, + ); + } +} + +/// A simulated chat conversation thread, which is simulated as a bottom-aligned +/// list of tiles. +class _ChatThread extends StatelessWidget { + const _ChatThread(); + + @override + Widget build(BuildContext context) { + return ListView.builder( + reverse: true, + // ^ The list starts at the bottom and grows upward. This is how + // we should layout chat conversations where the most recent + // message appears at the bottom, and you want to retain the + // scroll offset near the newest messages, not the oldest. + itemBuilder: (context, index) { + if (index == 8) { + // Arbitrarily placed text field to test moving focus between a non-editor + // and the editor. + return TextField( + decoration: InputDecoration( + hintText: "Content text field...", + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.white.withValues(alpha: 0.5), + child: ListTile( + title: Text("Item $index"), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return Scaffold( + resizeToAvoidBottomInset: false, + body: _ChatPage( + inputRole: "Subpage-${Random().nextInt(1000)}", + ), + ); + }, + ), + ); + }, + ), + ), + ); + }, + ); + } +} + +/// Bottom sheet, which includes a message editor. +class _EditorBottomSheet extends StatefulWidget { + const _EditorBottomSheet({ + required this.messagePageController, + required this.inputRole, + }); + + final MessagePageController messagePageController; + final String inputRole; + + @override + State<_EditorBottomSheet> createState() => _EditorBottomSheetState(); +} + +class _EditorBottomSheetState extends State<_EditorBottomSheet> { + final _dragIndicatorKey = GlobalKey(); + + final _scrollController = ScrollController(); + + final _editorSheetKey = GlobalKey(); + late final Editor _editor; + + final _hasSelection = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText("This is a pre-existing"), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText("message"), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText("It's tall for quick"), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText("testing of"), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText("intrinsic height that"), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText("exceeds available space"), + ), + ], + ), + composer: MutableDocumentComposer(), + ); + _editor.composer.selectionNotifier.addListener(_onSelectionChange); + } + + @override + void dispose() { + _editor.composer.selectionNotifier.removeListener(_onSelectionChange); + _editor.dispose(); + + _scrollController.dispose(); + + super.dispose(); + } + + void _onSelectionChange() { + _hasSelection.value = _editor.composer.selection != null; + + // If the editor doesn't have a selection then when it's collapsed it + // should be in preview mode. If the editor does have a selection, then + // when it's collapsed, it should be in intrinsic height mode. + widget.messagePageController.collapsedMode = + _hasSelection.value ? MessagePageSheetCollapsedMode.intrinsic : MessagePageSheetCollapsedMode.preview; + } + + double _dragTouchOffsetFromIndicator = 0; + + void _onVerticalDragStart(DragStartDetails details) { + _dragTouchOffsetFromIndicator = _dragFingerOffsetFromIndicator(details.globalPosition); + + widget.messagePageController.onDragStart( + details.globalPosition.dy - _dragIndicatorOffsetFromTop + _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragUpdate(DragUpdateDetails details) { + widget.messagePageController.onDragUpdate( + details.globalPosition.dy - _dragIndicatorOffsetFromTop + _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragEnd(DragEndDetails details) { + widget.messagePageController.onDragEnd(); + } + + void _onVerticalDragCancel() { + widget.messagePageController.onDragEnd(); + } + + double get _dragIndicatorOffsetFromTop { + final editorSheetBox = _editorSheetKey.currentContext!.findRenderObject(); + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero, ancestor: editorSheetBox).dy; + } + + double _dragFingerOffsetFromIndicator(Offset globalDragOffset) { + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero).dy - globalDragOffset.dy; + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + key: _editorSheetKey, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.5), + border: Border(top: BorderSide(color: Colors.grey)), + ), + child: KeyboardScaffoldSafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDragHandle(), + Flexible( + child: _buildSheetContent(), + ), + ], + ), + ), + ); + } + + Widget _buildSheetContent() { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + // ^ Avoid the bottom notch when the keyboard is closed. + ), + child: BottomSheetEditorHeight( + previewHeight: 72, + child: _ChatEditor( + key: _editorKey, + editor: _editor, + inputRole: widget.inputRole, + messagePageController: widget.messagePageController, + scrollController: _scrollController, + ), + ), + ); + } + + // FIXME: Keyboard keeps closing without a bunch of global keys. Either + // document why, or figure out how to operate without all the keys. + final _editorKey = GlobalKey(); + + Widget _buildDragHandle() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onVerticalDragStart: _onVerticalDragStart, + onVerticalDragUpdate: _onVerticalDragUpdate, + onVerticalDragEnd: _onVerticalDragEnd, + onVerticalDragCancel: _onVerticalDragCancel, + behavior: HitTestBehavior.opaque, + // ^ Opaque to handle tough events in our invisible padding. + child: Padding( + padding: const EdgeInsets.all(18), + // ^ Expand the hit area with invisible padding. + child: Container( + key: _dragIndicatorKey, + width: 32, + height: 5, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ], + ); + } +} + +/// An editor for composing chat messages. +class _ChatEditor extends StatefulWidget { + const _ChatEditor({ + super.key, + required this.editor, + required this.inputRole, + required this.messagePageController, + required this.scrollController, + }); + + final Editor editor; + final String inputRole; + final MessagePageController messagePageController; + final ScrollController scrollController; + + @override + State<_ChatEditor> createState() => _ChatEditorState(); +} + +class _ChatEditorState extends State<_ChatEditor> { + final _editorKey = GlobalKey(); + final _editorFocusNode = FocusNode(); + + final _previewModePlugin = ChatPreviewModePlugin(); + + late final KeyboardPanelController<_Panel> _keyboardPanelController; + late final SoftwareKeyboardController _softwareKeyboardController; + final _isImeConnected = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + _softwareKeyboardController = SoftwareKeyboardController(); + _keyboardPanelController = KeyboardPanelController( + _softwareKeyboardController, + ); + + widget.messagePageController.addListener(_onMessagePageControllerChange); + + _isImeConnected.addListener(_onImeConnectionChange); + + SuperKeyboard.instance.mobileGeometry.addListener(_onKeyboardChange); + } + + @override + void didUpdateWidget(_ChatEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.messagePageController != oldWidget.messagePageController) { + oldWidget.messagePageController.removeListener(_onMessagePageControllerChange); + widget.messagePageController.addListener(_onMessagePageControllerChange); + } + } + + @override + void dispose() { + SuperKeyboard.instance.mobileGeometry.removeListener(_onKeyboardChange); + + widget.messagePageController.removeListener(_onMessagePageControllerChange); + + _keyboardPanelController.dispose(); + _isImeConnected.dispose(); + + super.dispose(); + } + + void _onKeyboardChange() { + // FIXME: I had to comment this out so that panels can open. Otherwise, if we leave + // this behavior in, and we try to open a panel, this check triggers and closes + // the IME (and therefore the panel) when the panel tries to open. + // // On Android, we've found that when swiping to go back, the keyboard often + // // closes without Flutter reporting the closure of the IME connection. + // // Therefore, the keyboard closes, but editors and text fields retain focus, + // // selection, and a supposedly open IME connection. + // // + // // Flutter issue: https://github.com/flutter/flutter/issues/165734 + // // + // // To hack around this bug in Flutter, when super_keyboard reports keyboard + // // closure, and this controller thinks the keyboard is open, we give up + // // focus so that our app state synchronizes with the closed IME connection. + // final keyboardState = SuperKeyboard.instance.mobileGeometry.value.keyboardState; + // if (_isImeConnected.value && (keyboardState == KeyboardState.closing || keyboardState == KeyboardState.closed)) { + // _editorFocusNode.unfocus(); + // } + } + + void _onImeConnectionChange() { + widget.messagePageController.collapsedMode = + _isImeConnected.value ? MessagePageSheetCollapsedMode.intrinsic : MessagePageSheetCollapsedMode.preview; + } + + void _onMessagePageControllerChange() { + if (widget.messagePageController.isPreview) { + // Always scroll the editor to the top when in preview mode. + widget.scrollController.position.jumpTo(0); + } + } + + @override + Widget build(BuildContext context) { + return KeyboardPanelScaffold( + controller: _keyboardPanelController, + isImeConnected: _isImeConnected, + contentBuilder: (BuildContext context, _Panel? openPanel) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + if (_editorFocusNode.hasFocus) { + return const SizedBox(); + } + + return child!; + }, + child: IconButton( + onPressed: () { + _editorFocusNode.requestFocus(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + // We wait for the end of the frame to show the panel because giving + // focus to the editor will first cause the keyboard to show. If we + // opened the panel immediately then it would be covered by the keyboard. + _keyboardPanelController.showKeyboardPanel(_Panel.thePanel); + }); + }, + icon: Icon(Icons.add), + ), + ), + Expanded(child: _buildEditor()), + ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + if (_editorFocusNode.hasFocus) { + return const SizedBox(); + } + + return child!; + }, + child: IconButton(onPressed: () {}, icon: Icon(Icons.multitrack_audio)), + ), + ], + ); + }, + toolbarBuilder: (BuildContext context, _Panel? openPanel) { + return Container( + width: double.infinity, + height: 54, + color: Colors.white.withValues(alpha: 0.3), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + GestureDetector( + onTap: () { + if (!_keyboardPanelController.isKeyboardPanelOpen) { + _keyboardPanelController.showKeyboardPanel(_Panel.thePanel); + } else { + // This line is here to debug an issue in ClickUp + _keyboardPanelController.hideKeyboardPanel(); + _keyboardPanelController.showSoftwareKeyboard(); + } + }, + child: Icon(Icons.add), + ), + Spacer(), + GestureDetector( + onTap: () { + _softwareKeyboardController.close(); + }, + child: Icon(Icons.keyboard_hide_outlined), + ), + ], + ), + ); + }, + keyboardPanelBuilder: (BuildContext context, _Panel? openPanel) { + if (openPanel == null) { + return SizedBox(); + } + + return Container(width: double.infinity, height: 300, color: Colors.red); + }, + ); + } + + Widget _buildEditor() { + return SuperEditorFocusOnTap( + editorFocusNode: _editorFocusNode, + editor: widget.editor, + child: SuperEditorDryLayout( + controller: widget.scrollController, + superEditor: SuperEditor( + key: _editorKey, + focusNode: _editorFocusNode, + editor: widget.editor, + inputRole: widget.inputRole, + softwareKeyboardController: _softwareKeyboardController, + isImeConnected: _isImeConnected, + imePolicies: SuperEditorImePolicies(), + selectionPolicies: SuperEditorSelectionPolicies(), + shrinkWrap: false, + stylesheet: _chatStylesheet, + componentBuilders: [ + const HintComponentBuilder("Send a message...", _hintTextStyleBuilder), + ...defaultComponentBuilders, + ], + plugins: { + _previewModePlugin, + }, + ), + ), + ); + } +} + +final _chatStylesheet = Stylesheet( + rules: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.symmetric(horizontal: 24), + Styles.textStyle: const TextStyle( + color: Colors.black, + fontSize: 18, + height: 1.4, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 38, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 26, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 22, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("paragraph"), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(bottom: 12), + }; + }, + ), + StyleRule( + const BlockSelector("blockquote"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.grey, + fontSize: 20, + fontWeight: FontWeight.bold, + height: 1.4, + ), + }; + }, + ), + StyleRule( + BlockSelector.all.last(), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(bottom: 48), + }; + }, + ), + ], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, +); + +TextStyle _hintTextStyleBuilder(context) => TextStyle( + color: Colors.grey, + ); + +// FIXME: This widget is required because of the current shrink wrap behavior +// of Super Editor. If we set `shrinkWrap` to `false` then the bottom +// sheet always expands to max height. But if we set `shrinkWrap` to +// `true`, when we manually expand the bottom sheet, the only +// tappable area is wherever the document components actually appear. +// In the average case, that means only the top area of the bottom +// sheet can be tapped to place the caret. +// +// This widget should wrap Super Editor and make the whole area tappable. +/// A widget, that when pressed, gives focus to the [editorFocusNode], and places +/// the caret at the end of the content within an [editor]. +/// +/// It's expected that the [child] subtree contains the associated `SuperEditor`, +/// which owns the [editor] and [editorFocusNode]. +class SuperEditorFocusOnTap extends StatelessWidget { + const SuperEditorFocusOnTap({ + super.key, + required this.editorFocusNode, + required this.editor, + required this.child, + }); + + final FocusNode editorFocusNode; + + final Editor editor; + + /// The SuperEditor that we're wrapping with this tap behavior. + final Widget child; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: editorFocusNode, + builder: (context, child) { + return ListenableBuilder( + listenable: editor.composer.selectionNotifier, + builder: (context, child) { + final shouldControlTap = editor.composer.selection == null || !editorFocusNode.hasFocus; + return GestureDetector( + onTap: editor.composer.selection == null || !editorFocusNode.hasFocus ? _selectEditor : null, + behavior: HitTestBehavior.opaque, + child: IgnorePointer( + ignoring: shouldControlTap, + // ^ Prevent the Super Editor from aggressively responding to + // taps, so that we can respond. + child: child, + ), + ); + }, + child: child, + ); + }, + child: child, + ); + } + + void _selectEditor() { + editorFocusNode.requestFocus(); + + final endNode = editor.document.last; + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: endNode.id, + nodePosition: endNode.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ) + ]); + } +} + +enum _Panel { + thePanel; +} diff --git a/super_editor/example_chat/lib/message_page_scaffold_demo/demo_textfield_message_page.dart b/super_editor/example_chat/lib/message_page_scaffold_demo/demo_textfield_message_page.dart new file mode 100644 index 0000000000..5a6b73c5d8 --- /dev/null +++ b/super_editor/example_chat/lib/message_page_scaffold_demo/demo_textfield_message_page.dart @@ -0,0 +1,385 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A chat experience, which includes a simulated list of comments, as well as +/// a bottom-mounted message editor, which uses a standard Flutter `TextField` for +/// writing messages. +class TextFieldMessagePageDemo extends StatefulWidget { + const TextFieldMessagePageDemo({super.key}); + + @override + State createState() => _TextFieldMessagePageDemoState(); +} + +class _TextFieldMessagePageDemoState extends State { + final _messagePageController = MessagePageController(); + + @override + void dispose() { + _messagePageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MessagePageScaffold( + controller: _messagePageController, + bottomSheetMinimumTopGap: 150, + bottomSheetMinimumHeight: 148, + contentBuilder: (contentContext, bottomSpacing) { + return MediaQuery.removePadding( + context: contentContext, + removeBottom: true, + // ^ Remove bottom padding because if we don't, when the keyboard + // opens to edit the bottom sheet, this content behind the bottom + // sheet adds some phantom space at the bottom, slightly pushing + // it up for no reason. + child: Stack( + children: [ + Positioned.fill( + child: ColoredBox(color: Colors.purpleAccent.shade100), + ), + Positioned( + left: 0, + right: 0, + top: 0, + bottom: bottomSpacing, + child: _ChatThread(), + ), + ], + ), + ); + }, + bottomSheetBuilder: (messageContext) { + return _EditorBottomSheet( + messagePageController: _messagePageController, + ); + }, + ); + } +} + +/// A simulated chat conversation thread, which is simulated as a bottom-aligned +/// list of tiles. +class _ChatThread extends StatelessWidget { + const _ChatThread(); + + @override + Widget build(BuildContext context) { + return ListView.builder( + reverse: true, + // ^ The list starts at the bottom and grows upward. This is how + // we should layout chat conversations where the most recent + // message appears at the bottom, and you want to retain the + // scroll offset near the newest messages, not the oldest. + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.white.withValues(alpha: 0.5), + child: ListTile( + title: Text("Item $index"), + ), + ), + ); + }, + ); + } +} + +/// Bottom sheet, which includes a message editor. +class _EditorBottomSheet extends StatefulWidget { + const _EditorBottomSheet({ + required this.messagePageController, + }); + + final MessagePageController messagePageController; + + @override + State<_EditorBottomSheet> createState() => _EditorBottomSheetState(); +} + +class _EditorBottomSheetState extends State<_EditorBottomSheet> { + final _dragIndicatorKey = GlobalKey(); + + final _scrollController = ScrollController(); + + final _editorSheetKey = GlobalKey(); + + @override + void dispose() { + _scrollController.dispose(); + + super.dispose(); + } + + double _dragTouchOffsetFromIndicator = 0; + + void _onVerticalDragStart(DragStartDetails details) { + _dragTouchOffsetFromIndicator = _dragFingerOffsetFromIndicator(details.globalPosition); + + widget.messagePageController.onDragStart( + details.globalPosition.dy - _dragIndicatorOffsetFromTop + _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragUpdate(DragUpdateDetails details) { + widget.messagePageController.onDragUpdate( + details.globalPosition.dy - _dragIndicatorOffsetFromTop + _dragTouchOffsetFromIndicator, + ); + } + + void _onVerticalDragEnd(DragEndDetails details) { + widget.messagePageController.onDragEnd(); + } + + void _onVerticalDragCancel() { + widget.messagePageController.onDragEnd(); + } + + double get _dragIndicatorOffsetFromTop { + final editorSheetBox = _editorSheetKey.currentContext!.findRenderObject(); + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero, ancestor: editorSheetBox).dy; + } + + double _dragFingerOffsetFromIndicator(Offset globalDragOffset) { + final dragIndicatorBox = _dragIndicatorKey.currentContext!.findRenderObject()! as RenderBox; + + return dragIndicatorBox.localToGlobal(Offset.zero).dy - globalDragOffset.dy; + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + key: _editorSheetKey, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.5), + border: Border(top: BorderSide(color: Colors.grey)), + ), + child: KeyboardScaffoldSafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDragHandle(), + Flexible( + child: _buildSheetContent(), + ), + ], + ), + ), + ); + } + + Widget _buildSheetContent() { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + // ^ Avoid the bottom notch when the keyboard is closed. + ), + child: BottomSheetEditorHeight( + previewHeight: 72, + child: _ChatEditor( + key: _editorKey, + messagePageController: widget.messagePageController, + ), + ), + ); + } + + // FIXME: Keyboard keeps closing without a bunch of global keys. Either + // document why, or figure out how to operate without all the keys. + final _editorKey = GlobalKey(); + + Widget _buildDragHandle() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onVerticalDragStart: _onVerticalDragStart, + onVerticalDragUpdate: _onVerticalDragUpdate, + onVerticalDragEnd: _onVerticalDragEnd, + onVerticalDragCancel: _onVerticalDragCancel, + behavior: HitTestBehavior.opaque, + // ^ Opaque to handle tough events in our invisible padding. + child: Padding( + padding: const EdgeInsets.all(18), + // ^ Expand the hit area with invisible padding. + child: Container( + key: _dragIndicatorKey, + width: 32, + height: 5, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ], + ); + } +} + +/// An editor for composing chat messages. +class _ChatEditor extends StatefulWidget { + const _ChatEditor({ + super.key, + required this.messagePageController, + }); + + final MessagePageController messagePageController; + + @override + State<_ChatEditor> createState() => _ChatEditorState(); +} + +class _ChatEditorState extends State<_ChatEditor> implements SoftwareKeyboardControllerDelegate { + final _textFieldFocusNode = FocusNode(); + + final _scrollController = ScrollController(); + + late final KeyboardPanelController<_Panel> _keyboardPanelController; + late final SoftwareKeyboardController _softwareKeyboardController; + final _isImeConnected = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + _softwareKeyboardController = SoftwareKeyboardController(); + _keyboardPanelController = KeyboardPanelController( + _softwareKeyboardController, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + _keyboardPanelController.toolbarVisibility = KeyboardToolbarVisibility.auto; + }); + + widget.messagePageController.addListener(_onMessagePageControllerChange); + + _textFieldFocusNode.addListener(_onFocusChange); + + _isImeConnected.value = _textFieldFocusNode.hasFocus; + _isImeConnected.addListener(_onImeConnectionChange); + + _softwareKeyboardController.attach(this); + } + + @override + void didUpdateWidget(_ChatEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.messagePageController != oldWidget.messagePageController) { + oldWidget.messagePageController.removeListener(_onMessagePageControllerChange); + widget.messagePageController.addListener(_onMessagePageControllerChange); + } + } + + @override + void dispose() { + _softwareKeyboardController.detach(); + + widget.messagePageController.removeListener(_onMessagePageControllerChange); + + _scrollController.dispose(); + + _keyboardPanelController.dispose(); + _isImeConnected.dispose(); + + _textFieldFocusNode.dispose(); + + super.dispose(); + } + + void _onFocusChange() { + // Flutter doesn't report actual IME connection status. For simplicity, + // assume focus means IME connection is open. + _isImeConnected.value = _textFieldFocusNode.hasFocus; + } + + void _onImeConnectionChange() { + widget.messagePageController.collapsedMode = + _isImeConnected.value ? MessagePageSheetCollapsedMode.intrinsic : MessagePageSheetCollapsedMode.preview; + } + + void _onMessagePageControllerChange() { + if (widget.messagePageController.isPreview) { + // Always scroll the editor to the top when in preview mode. + _scrollController.position.jumpTo(0); + } + } + + @override + bool get isConnectedToIme => _textFieldFocusNode.hasFocus; + + @override + void open({ + required int viewId, + }) { + _textFieldFocusNode.requestFocus(); + } + + @override + void hide() { + // Can't hide without deeper IME integration. + } + + @override + void close() { + _textFieldFocusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + return KeyboardPanelScaffold( + controller: _keyboardPanelController, + isImeConnected: _isImeConnected, + toolbarBuilder: (BuildContext context, _Panel? openPanel) { + return Container( + width: double.infinity, + height: 54, + color: Colors.white.withValues(alpha: 0.3), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Spacer(), + GestureDetector( + onTap: () { + _softwareKeyboardController.close(); + }, + child: Icon(Icons.keyboard_hide_outlined), + ), + ], + ), + ); + }, + keyboardPanelBuilder: (BuildContext context, _Panel? openPanel) { + return SizedBox(); + }, + contentBuilder: (BuildContext context, _Panel? openPanel) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: IntrinsicHeight( + child: TextField( + key: _editorKey, + focusNode: _textFieldFocusNode, + scrollController: _scrollController, + decoration: InputDecoration( + hintText: "Write message...", + ), + maxLines: null, + ), + ), + ); + }, + ); + } + + final _editorKey = GlobalKey(); +} + +enum _Panel { + thePanel; +} diff --git a/super_editor/example_chat/lib/message_page_scaffold_demo/message_page_scaffold_demo.dart b/super_editor/example_chat/lib/message_page_scaffold_demo/message_page_scaffold_demo.dart new file mode 100644 index 0000000000..ff2ceccf2e --- /dev/null +++ b/super_editor/example_chat/lib/message_page_scaffold_demo/message_page_scaffold_demo.dart @@ -0,0 +1,95 @@ +import 'package:example_chat/message_page_scaffold_demo/demo_chaos_monkey_message_page.dart'; +import 'package:example_chat/message_page_scaffold_demo/demo_super_editor_message_page.dart'; +import 'package:example_chat/message_page_scaffold_demo/demo_textfield_message_page.dart'; +import 'package:flutter/material.dart'; + +class MessagePageScaffoldDemo extends StatefulWidget { + const MessagePageScaffoldDemo({super.key}); + + @override + State createState() => _MessagePageScaffoldDemoState(); +} + +class _MessagePageScaffoldDemoState extends State { + var _demo = _Demo.superEditor; + + void _showChaosMonkeyDemo() { + setState(() { + _demo = _Demo.chaosMonkey; + }); + } + + void _showTextFieldDemo() { + setState(() { + _demo = _Demo.textfield; + }); + } + + void _showSuperEditorDemo() { + setState(() { + _demo = _Demo.superEditor; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.red, + elevation: 0, + actions: [ + IconButton(onPressed: _showChaosMonkeyDemo, icon: Icon(Icons.pets)), + IconButton(onPressed: _showTextFieldDemo, icon: Icon(Icons.format_line_spacing)), + IconButton(onPressed: _showSuperEditorDemo, icon: Icon(Icons.edit)), + ], + ), + extendBodyBehindAppBar: true, + resizeToAvoidBottomInset: false, + body: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth / constraints.maxHeight <= 1) { + // Show phone experience. + return _buildDemo(); + } + + // Show the tablet experience. + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: 64, + ), + Container( + width: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + Spacer(), + Container( + width: 1, + color: Colors.black.withValues(alpha: 0.1), + ), + SizedBox( + width: 450, + child: _buildDemo(), + ), + ], + ); + }, + ), + ); + } + + Widget _buildDemo() { + return switch (_demo) { + _Demo.chaosMonkey => ChaosMonkeyMessagePageDemo(), + _Demo.textfield => TextFieldMessagePageDemo(), + _Demo.superEditor => SuperEditorMessagePageDemo(), + }; + } +} + +enum _Demo { + chaosMonkey, + textfield, + superEditor; +} diff --git a/super_editor/example_chat/linux/.gitignore b/super_editor/example_chat/linux/.gitignore new file mode 100644 index 0000000000..d3896c9844 --- /dev/null +++ b/super_editor/example_chat/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/super_editor/example_chat/linux/CMakeLists.txt b/super_editor/example_chat/linux/CMakeLists.txt new file mode 100644 index 0000000000..c873138dc0 --- /dev/null +++ b/super_editor/example_chat/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example_chat") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.example_chat") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/super_editor/example_chat/linux/flutter/CMakeLists.txt b/super_editor/example_chat/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000000..d5bd01648a --- /dev/null +++ b/super_editor/example_chat/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/super_editor/example_chat/linux/flutter/generated_plugin_registrant.cc b/super_editor/example_chat/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..f6f23bfe97 --- /dev/null +++ b/super_editor/example_chat/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/super_editor/example_chat/linux/flutter/generated_plugin_registrant.h b/super_editor/example_chat/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..e0f0a47bc0 --- /dev/null +++ b/super_editor/example_chat/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/super_editor/example_chat/linux/flutter/generated_plugins.cmake b/super_editor/example_chat/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..f16b4c3421 --- /dev/null +++ b/super_editor/example_chat/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/super_editor/example_chat/linux/runner/CMakeLists.txt b/super_editor/example_chat/linux/runner/CMakeLists.txt new file mode 100644 index 0000000000..e97dabc702 --- /dev/null +++ b/super_editor/example_chat/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/super_editor/example_chat/linux/runner/main.cc b/super_editor/example_chat/linux/runner/main.cc new file mode 100644 index 0000000000..e7c5c54370 --- /dev/null +++ b/super_editor/example_chat/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/super_editor/example_chat/linux/runner/my_application.cc b/super_editor/example_chat/linux/runner/my_application.cc new file mode 100644 index 0000000000..af5b2e4619 --- /dev/null +++ b/super_editor/example_chat/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "example_chat"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "example_chat"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/super_editor/example_chat/linux/runner/my_application.h b/super_editor/example_chat/linux/runner/my_application.h new file mode 100644 index 0000000000..72271d5e41 --- /dev/null +++ b/super_editor/example_chat/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/super_editor/example_chat/macos/.gitignore b/super_editor/example_chat/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/super_editor/example_chat/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/super_editor/example_chat/macos/Flutter/Flutter-Debug.xcconfig b/super_editor/example_chat/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..4b81f9b2d2 --- /dev/null +++ b/super_editor/example_chat/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_editor/example_chat/macos/Flutter/Flutter-Release.xcconfig b/super_editor/example_chat/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5caa9d1579 --- /dev/null +++ b/super_editor/example_chat/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_editor/example_chat/macos/Flutter/GeneratedPluginRegistrant.swift b/super_editor/example_chat/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..8236f5728c --- /dev/null +++ b/super_editor/example_chat/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/super_editor/example_chat/macos/Podfile b/super_editor/example_chat/macos/Podfile new file mode 100644 index 0000000000..c795730db8 --- /dev/null +++ b/super_editor/example_chat/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14' + +# 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__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/super_editor/example_chat/macos/Podfile.lock b/super_editor/example_chat/macos/Podfile.lock new file mode 100644 index 0000000000..3fa873283b --- /dev/null +++ b/super_editor/example_chat/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - FlutterMacOS (1.0.0) + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 + +COCOAPODS: 1.16.2 diff --git a/super_editor/example_chat/macos/Runner.xcodeproj/project.pbxproj b/super_editor/example_chat/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..777f6ef0ba --- /dev/null +++ b/super_editor/example_chat/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + 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 */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 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 */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 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 */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example_chat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example_chat.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example_chat.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 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 /* example_chat.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 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 */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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; + alwaysOutOfDate = 1; + 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 */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 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 = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleChat.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example_chat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example_chat"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleChat.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example_chat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example_chat"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleChat.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example_chat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example_chat"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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 */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 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 */; +} diff --git a/super_editor/example_chat/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor/example_chat/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor/example_chat/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor/example_chat/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_editor/example_chat/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..34d85accae --- /dev/null +++ b/super_editor/example_chat/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_chat/macos/Runner.xcworkspace/contents.xcworkspacedata b/super_editor/example_chat/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/super_editor/example_chat/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_editor/example_chat/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor/example_chat/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor/example_chat/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor/example_chat/macos/Runner/AppDelegate.swift b/super_editor/example_chat/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..b3c1761412 --- /dev/null +++ b/super_editor/example_chat/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..82b6f9d9a3 Binary files /dev/null and b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000..13b35eba55 Binary files /dev/null and b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000..0a3f5fa40f Binary files /dev/null and b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000..bdb57226d5 Binary files /dev/null and b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000000..f083318e09 Binary files /dev/null and b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000..326c0e72c9 Binary files /dev/null and b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000..2f1632cfdd Binary files /dev/null and b/super_editor/example_chat/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/super_editor/example_chat/macos/Runner/Base.lproj/MainMenu.xib b/super_editor/example_chat/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/super_editor/example_chat/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_chat/macos/Runner/Configs/AppInfo.xcconfig b/super_editor/example_chat/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..be5f5efb02 --- /dev/null +++ b/super_editor/example_chat/macos/Runner/Configs/AppInfo.xcconfig @@ -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 = example_chat + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.exampleChat + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/super_editor/example_chat/macos/Runner/Configs/Debug.xcconfig b/super_editor/example_chat/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/super_editor/example_chat/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_editor/example_chat/macos/Runner/Configs/Release.xcconfig b/super_editor/example_chat/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/super_editor/example_chat/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_editor/example_chat/macos/Runner/Configs/Warnings.xcconfig b/super_editor/example_chat/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/super_editor/example_chat/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/super_editor/example_chat/macos/Runner/DebugProfile.entitlements b/super_editor/example_chat/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/super_editor/example_chat/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/super_editor/example_chat/macos/Runner/Info.plist b/super_editor/example_chat/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/super_editor/example_chat/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/super_editor/example_chat/macos/Runner/MainFlutterWindow.swift b/super_editor/example_chat/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..3cc05eb234 --- /dev/null +++ b/super_editor/example_chat/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/super_editor/example_chat/macos/Runner/Release.entitlements b/super_editor/example_chat/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/super_editor/example_chat/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/super_editor/example_chat/macos/RunnerTests/RunnerTests.swift b/super_editor/example_chat/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..61f3bd1fc5 --- /dev/null +++ b/super_editor/example_chat/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_editor/example_chat/pubspec.lock b/super_editor/example_chat/pubspec.lock new file mode 100644 index 0000000000..8ca7229fc0 --- /dev/null +++ b/super_editor/example_chat/pubspec.lock @@ -0,0 +1,713 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.dev" + source: hosted + version: "7.7.1" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + attributed_text: + dependency: transitive + description: + name: attributed_text + sha256: "177ea01f58a8d8df279f4066834375a2009bdd304d559c084bb06f784b258477" + url: "https://pub.dev" + source: hosted + version: "0.4.5" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 + url: "https://pub.dev" + source: hosted + version: "1.11.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_quill_delta: + dependency: transitive + description: + name: dart_quill_delta + sha256: "6aa89f0903ca3e70f5ceeb1d75d722f6ca583e87a2a8893c7b9f42f7a947f6e5" + url: "https://pub.dev" + source: hosted + version: "9.6.0" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + url: "https://pub.dev" + source: hosted + version: "2.0.28" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_test_robots: + dependency: transitive + description: + name: flutter_test_robots + sha256: "3b00f2081148bde55190997c2772f934ad2f4529cbcfc4ccfa593f8ddc117a28" + url: "https://pub.dev" + source: hosted + version: "0.0.24" + flutter_test_runners: + dependency: transitive + description: + name: flutter_test_runners + sha256: cc575117ed66a79185a26995399d7048341517a1bd21188cb43753739627832d + url: "https://pub.dev" + source: hosted + version: "0.0.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + follow_the_leader: + dependency: transitive + description: + name: follow_the_leader + sha256: "2e4c4ebe6b3f1942b2385904b118ba8ba117fae0b30c8c453be0b64a271dd07a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: transitive + description: + name: http + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + url: "https://pub.dev" + source: hosted + version: "1.3.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlord: + dependency: transitive + description: + name: overlord + sha256: "532f5685ac09ee805d97ce89794a4eeda41672c32955b4a835bdfce93e720a05" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + super_editor: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.3.0-dev.40" + super_keyboard: + dependency: "direct main" + description: + name: super_keyboard + sha256: e3accebf33635f760efbd4d3c13f6484242a09e773ce8e711f4aa745d52b73b1 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + super_text_layout: + dependency: transitive + description: + name: super_text_layout + sha256: e25f01ceb809118da66fd095b3dcdc608a611bf45e364f303e7f9f0af0c5f8d1 + url: "https://pub.dev" + source: hosted + version: "0.1.18" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" + text_table: + dependency: transitive + description: + name: text_table + sha256: a42b35675be614274b884ee482d4bdf4bdf707bc65de18cb8f1ad288c1beb1f4 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.27.0" diff --git a/super_editor/example_chat/pubspec.yaml b/super_editor/example_chat/pubspec.yaml new file mode 100644 index 0000000000..105f7d9880 --- /dev/null +++ b/super_editor/example_chat/pubspec.yaml @@ -0,0 +1,62 @@ +name: example_chat +description: "An example of Super Editor chat UI" +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +version: 1.0.0+1 + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + super_editor: + path: ../ + super_keyboard: ^0.4.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +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/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # 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/to/font-from-package diff --git a/super_editor/example_chat/web/favicon.png b/super_editor/example_chat/web/favicon.png new file mode 100644 index 0000000000..8aaa46ac1a Binary files /dev/null and b/super_editor/example_chat/web/favicon.png differ diff --git a/super_editor/example_chat/web/icons/Icon-192.png b/super_editor/example_chat/web/icons/Icon-192.png new file mode 100644 index 0000000000..b749bfef07 Binary files /dev/null and b/super_editor/example_chat/web/icons/Icon-192.png differ diff --git a/super_editor/example_chat/web/icons/Icon-512.png b/super_editor/example_chat/web/icons/Icon-512.png new file mode 100644 index 0000000000..88cfd48dff Binary files /dev/null and b/super_editor/example_chat/web/icons/Icon-512.png differ diff --git a/super_editor/example_chat/web/icons/Icon-maskable-192.png b/super_editor/example_chat/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000..eb9b4d76e5 Binary files /dev/null and b/super_editor/example_chat/web/icons/Icon-maskable-192.png differ diff --git a/super_editor/example_chat/web/icons/Icon-maskable-512.png b/super_editor/example_chat/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000..d69c56691f Binary files /dev/null and b/super_editor/example_chat/web/icons/Icon-maskable-512.png differ diff --git a/super_editor/example_chat/web/index.html b/super_editor/example_chat/web/index.html new file mode 100644 index 0000000000..0bc48f1ae4 --- /dev/null +++ b/super_editor/example_chat/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + example_chat + + + + + + diff --git a/super_editor/example_chat/web/manifest.json b/super_editor/example_chat/web/manifest.json new file mode 100644 index 0000000000..c3da2b3441 --- /dev/null +++ b/super_editor/example_chat/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example_chat", + "short_name": "example_chat", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/super_editor/example_chat/windows/.gitignore b/super_editor/example_chat/windows/.gitignore new file mode 100644 index 0000000000..d492d0d98c --- /dev/null +++ b/super_editor/example_chat/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/super_editor/example_chat/windows/CMakeLists.txt b/super_editor/example_chat/windows/CMakeLists.txt new file mode 100644 index 0000000000..0eb2811b3c --- /dev/null +++ b/super_editor/example_chat/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(example_chat LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example_chat") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/super_editor/example_chat/windows/flutter/CMakeLists.txt b/super_editor/example_chat/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000000..903f4899d6 --- /dev/null +++ b/super_editor/example_chat/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/super_editor/example_chat/windows/flutter/generated_plugin_registrant.cc b/super_editor/example_chat/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..4f7884874d --- /dev/null +++ b/super_editor/example_chat/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/super_editor/example_chat/windows/flutter/generated_plugin_registrant.h b/super_editor/example_chat/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..dc139d85a9 --- /dev/null +++ b/super_editor/example_chat/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/super_editor/example_chat/windows/flutter/generated_plugins.cmake b/super_editor/example_chat/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..88b22e5c77 --- /dev/null +++ b/super_editor/example_chat/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/super_editor/example_chat/windows/runner/CMakeLists.txt b/super_editor/example_chat/windows/runner/CMakeLists.txt new file mode 100644 index 0000000000..394917c053 --- /dev/null +++ b/super_editor/example_chat/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/super_editor/example_chat/windows/runner/Runner.rc b/super_editor/example_chat/windows/runner/Runner.rc new file mode 100644 index 0000000000..b25eafe782 --- /dev/null +++ b/super_editor/example_chat/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "example_chat" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example_chat" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "example_chat.exe" "\0" + VALUE "ProductName", "example_chat" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/super_editor/example_chat/windows/runner/flutter_window.cpp b/super_editor/example_chat/windows/runner/flutter_window.cpp new file mode 100644 index 0000000000..955ee3038f --- /dev/null +++ b/super_editor/example_chat/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/super_editor/example_chat/windows/runner/flutter_window.h b/super_editor/example_chat/windows/runner/flutter_window.h new file mode 100644 index 0000000000..6da0652f05 --- /dev/null +++ b/super_editor/example_chat/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/super_editor/example_chat/windows/runner/main.cpp b/super_editor/example_chat/windows/runner/main.cpp new file mode 100644 index 0000000000..beb9f1bf1a --- /dev/null +++ b/super_editor/example_chat/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"example_chat", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/super_editor/example_chat/windows/runner/resource.h b/super_editor/example_chat/windows/runner/resource.h new file mode 100644 index 0000000000..66a65d1e4a --- /dev/null +++ b/super_editor/example_chat/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/super_editor/example_chat/windows/runner/resources/app_icon.ico b/super_editor/example_chat/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000..c04e20caf6 Binary files /dev/null and b/super_editor/example_chat/windows/runner/resources/app_icon.ico differ diff --git a/super_editor/example_chat/windows/runner/runner.exe.manifest b/super_editor/example_chat/windows/runner/runner.exe.manifest new file mode 100644 index 0000000000..153653e8d6 --- /dev/null +++ b/super_editor/example_chat/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/super_editor/example_chat/windows/runner/utils.cpp b/super_editor/example_chat/windows/runner/utils.cpp new file mode 100644 index 0000000000..3a0b46511a --- /dev/null +++ b/super_editor/example_chat/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/super_editor/example_chat/windows/runner/utils.h b/super_editor/example_chat/windows/runner/utils.h new file mode 100644 index 0000000000..3879d54755 --- /dev/null +++ b/super_editor/example_chat/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/super_editor/example_chat/windows/runner/win32_window.cpp b/super_editor/example_chat/windows/runner/win32_window.cpp new file mode 100644 index 0000000000..60608d0fe5 --- /dev/null +++ b/super_editor/example_chat/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/super_editor/example_chat/windows/runner/win32_window.h b/super_editor/example_chat/windows/runner/win32_window.h new file mode 100644 index 0000000000..e901dde684 --- /dev/null +++ b/super_editor/example_chat/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/super_editor/example_perf/.gitignore b/super_editor/example_perf/.gitignore new file mode 100644 index 0000000000..29a3a5017f --- /dev/null +++ b/super_editor/example_perf/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# 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 +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/super_editor/example_perf/.metadata b/super_editor/example_perf/.metadata new file mode 100644 index 0000000000..c020117573 --- /dev/null +++ b/super_editor/example_perf/.metadata @@ -0,0 +1,45 @@ +# 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: "4648f54430f4f65fe787930c9decb769f0ce7bf5" + channel: "master" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + base_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + - platform: android + create_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + base_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + - platform: ios + create_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + base_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + - platform: linux + create_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + base_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + - platform: macos + create_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + base_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + - platform: web + create_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + base_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + - platform: windows + create_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + base_revision: 4648f54430f4f65fe787930c9decb769f0ce7bf5 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_editor/example_perf/README.md b/super_editor/example_perf/README.md new file mode 100644 index 0000000000..477a1d886b --- /dev/null +++ b/super_editor/example_perf/README.md @@ -0,0 +1,16 @@ +# example_perf + +A new Flutter project. + +## 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://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/super_editor/example_perf/analysis_options.yaml b/super_editor/example_perf/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_editor/example_perf/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_editor/example_perf/android/.gitignore b/super_editor/example_perf/android/.gitignore new file mode 100644 index 0000000000..6f568019d3 --- /dev/null +++ b/super_editor/example_perf/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/super_editor/example_perf/android/app/build.gradle b/super_editor/example_perf/android/app/build.gradle new file mode 100644 index 0000000000..8ad7e10da5 --- /dev/null +++ b/super_editor/example_perf/android/app/build.gradle @@ -0,0 +1,67 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "com.example.example_perf" + compileSdk flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.example_perf" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdkVersion flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies {} diff --git a/super_editor/example_perf/android/app/src/debug/AndroidManifest.xml b/super_editor/example_perf/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_editor/example_perf/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_editor/example_perf/android/app/src/main/AndroidManifest.xml b/super_editor/example_perf/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..55b72982f4 --- /dev/null +++ b/super_editor/example_perf/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_perf/android/app/src/main/kotlin/com/example/example_perf/MainActivity.kt b/super_editor/example_perf/android/app/src/main/kotlin/com/example/example_perf/MainActivity.kt new file mode 100644 index 0000000000..ad95b8ed9c --- /dev/null +++ b/super_editor/example_perf/android/app/src/main/kotlin/com/example/example_perf/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.example_perf + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/super_editor/example_perf/android/app/src/main/res/drawable-v21/launch_background.xml b/super_editor/example_perf/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/super_editor/example_perf/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_editor/example_perf/android/app/src/main/res/drawable/launch_background.xml b/super_editor/example_perf/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..304732f884 --- /dev/null +++ b/super_editor/example_perf/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_editor/example_perf/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/super_editor/example_perf/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..db77bb4b7b Binary files /dev/null and b/super_editor/example_perf/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/super_editor/example_perf/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/super_editor/example_perf/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..17987b79bb Binary files /dev/null and b/super_editor/example_perf/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/super_editor/example_perf/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/super_editor/example_perf/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..09d4391482 Binary files /dev/null and b/super_editor/example_perf/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/super_editor/example_perf/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/super_editor/example_perf/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d5f1c8d34e Binary files /dev/null and b/super_editor/example_perf/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/super_editor/example_perf/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/super_editor/example_perf/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..4d6372eebd Binary files /dev/null and b/super_editor/example_perf/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/super_editor/example_perf/android/app/src/main/res/values-night/styles.xml b/super_editor/example_perf/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/super_editor/example_perf/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_editor/example_perf/android/app/src/main/res/values/styles.xml b/super_editor/example_perf/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..cb1ef88056 --- /dev/null +++ b/super_editor/example_perf/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_editor/example_perf/android/app/src/profile/AndroidManifest.xml b/super_editor/example_perf/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_editor/example_perf/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_editor/example_perf/android/build.gradle b/super_editor/example_perf/android/build.gradle new file mode 100644 index 0000000000..bc157bd1a1 --- /dev/null +++ b/super_editor/example_perf/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/super_editor/example_perf/android/gradle.properties b/super_editor/example_perf/android/gradle.properties new file mode 100644 index 0000000000..598d13fee4 --- /dev/null +++ b/super_editor/example_perf/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true +android.enableJetifier=true diff --git a/super_editor/example_perf/android/gradle/wrapper/gradle-wrapper.properties b/super_editor/example_perf/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..3c472b99c6 --- /dev/null +++ b/super_editor/example_perf/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/super_editor/example_perf/android/settings.gradle b/super_editor/example_perf/android/settings.gradle new file mode 100644 index 0000000000..1d6d19b7f8 --- /dev/null +++ b/super_editor/example_perf/android/settings.gradle @@ -0,0 +1,26 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.3.0" apply false + id "org.jetbrains.kotlin.android" version "1.7.10" apply false +} + +include ":app" diff --git a/super_editor/example_perf/ios/.gitignore b/super_editor/example_perf/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/super_editor/example_perf/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/super_editor/example_perf/ios/Flutter/AppFrameworkInfo.plist b/super_editor/example_perf/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..9625e105df --- /dev/null +++ b/super_editor/example_perf/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/super_editor/example_perf/ios/Flutter/Debug.xcconfig b/super_editor/example_perf/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..ec97fc6f30 --- /dev/null +++ b/super_editor/example_perf/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/super_editor/example_perf/ios/Flutter/Release.xcconfig b/super_editor/example_perf/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..c4855bfe20 --- /dev/null +++ b/super_editor/example_perf/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/super_editor/example_perf/ios/Podfile b/super_editor/example_perf/ios/Podfile new file mode 100644 index 0000000000..fdcc671eb3 --- /dev/null +++ b/super_editor/example_perf/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.0' + +# 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', '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 Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/super_editor/example_perf/ios/Runner.xcodeproj/project.pbxproj b/super_editor/example_perf/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..df230fcafa --- /dev/null +++ b/super_editor/example_perf/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,614 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 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 */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807E294A63A400263BE5 /* Frameworks */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.examplePerf; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AE0B7B92F70575B8D7E0D07E /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.examplePerf.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 89B67EB44CE7B6631473024E /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.examplePerf.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 640959BDD8F10B91D80A66BE /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.examplePerf.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.examplePerf; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.examplePerf; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/super_editor/example_perf/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/super_editor/example_perf/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/super_editor/example_perf/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_editor/example_perf/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor/example_perf/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor/example_perf/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor/example_perf/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_editor/example_perf/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_editor/example_perf/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_editor/example_perf/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_editor/example_perf/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..87131a09be --- /dev/null +++ b/super_editor/example_perf/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_perf/ios/Runner.xcworkspace/contents.xcworkspacedata b/super_editor/example_perf/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/super_editor/example_perf/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_editor/example_perf/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor/example_perf/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor/example_perf/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor/example_perf/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_editor/example_perf/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_editor/example_perf/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_editor/example_perf/ios/Runner/AppDelegate.swift b/super_editor/example_perf/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..70693e4a8c --- /dev/null +++ b/super_editor/example_perf/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000..dc9ada4725 Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..7353c41ecf Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..6ed2d933e1 Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..4cd7b0099c Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..fe730945a0 Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..321773cd85 Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..502f463a9b Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..e9f5fea27c Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..84ac32ae7d Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..8953cba090 Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..0467bf12aa Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/super_editor/example_perf/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/super_editor/example_perf/ios/Runner/Base.lproj/LaunchScreen.storyboard b/super_editor/example_perf/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/super_editor/example_perf/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_perf/ios/Runner/Base.lproj/Main.storyboard b/super_editor/example_perf/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/super_editor/example_perf/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_perf/ios/Runner/Info.plist b/super_editor/example_perf/ios/Runner/Info.plist new file mode 100644 index 0000000000..a2b4229c5e --- /dev/null +++ b/super_editor/example_perf/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example Perf + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example_perf + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/super_editor/example_perf/ios/Runner/Runner-Bridging-Header.h b/super_editor/example_perf/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/super_editor/example_perf/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/super_editor/example_perf/ios/RunnerTests/RunnerTests.swift b/super_editor/example_perf/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..86a7c3b1b6 --- /dev/null +++ b/super_editor/example_perf/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_editor/example_perf/lib/demos/long_doc_demo.dart b/super_editor/example_perf/lib/demos/long_doc_demo.dart new file mode 100644 index 0000000000..ca47f21ea3 --- /dev/null +++ b/super_editor/example_perf/lib/demos/long_doc_demo.dart @@ -0,0 +1,32 @@ +import 'package:example_perf/documents/frankenstein.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +class LongDocDemo extends StatefulWidget { + const LongDocDemo({super.key}); + + @override + State createState() => _LongDocDemoState(); +} + +class _LongDocDemoState extends State { + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; + + @override + void initState() { + super.initState(); + _doc = MutableDocument(nodes: frankNodes()); + _composer = MutableDocumentComposer(); + + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SuperEditor(editor: _docEditor), + ); + } +} diff --git a/super_editor/example_perf/lib/demos/rebuild_demo.dart b/super_editor/example_perf/lib/demos/rebuild_demo.dart new file mode 100644 index 0000000000..6903d1cfa0 --- /dev/null +++ b/super_editor/example_perf/lib/demos/rebuild_demo.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; + +import 'package:super_editor/super_editor.dart'; + +/// Example of a task component whose height is animated. +class RebuildCountDemo extends StatefulWidget { + const RebuildCountDemo({super.key}); + + @override + State createState() => _RebuildCountDemoState(); +} + +class _RebuildCountDemoState extends State { + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; + + @override + void initState() { + super.initState(); + _doc = _createDocument(); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); + } + + @override + void dispose() { + _doc.dispose(); + super.dispose(); + } + + MutableDocument _createDocument() { + return MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + "Below are several tasks. These tasks will animate the appearance of a subtitle depending on whether they have selection. Only changed tasks will rebuild. Try and find out:\n", + ), + ), + ...List.generate( + 10, + (index) => TaskNode( + id: Editor.createNodeId(), + text: AttributedText("Task ${index + 1}"), + isComplete: false, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return SuperEditor( + editor: _docEditor, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), + ), + // Add a new component builder that creates a task that animates its height, + // instead of creating the usual static kind. + componentBuilders: [ + const AnimatedTaskComponentBuilder(), + TaskComponentBuilder(_docEditor), + ...defaultComponentBuilders, + ], + ); + } +} + +/// SuperEditor [ComponentBuilder] that builds a task that is animates the appearance of +/// a subtitle depending on whether it has selection. +class AnimatedTaskComponentBuilder implements ComponentBuilder { + const AnimatedTaskComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + // This builder can work with the standard task view model, so + // we'll defer to the standard task builder. + return null; + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! TaskComponentViewModel) { + return null; + } + + return _AnimatedTaskComponent( + key: componentContext.componentKey, + viewModel: componentViewModel, + ); + } +} + +class _AnimatedTaskComponent extends StatefulWidget { + const _AnimatedTaskComponent({ + super.key, + required this.viewModel, + // ignore: unused_element + this.showDebugPaint = false, + }); + + final TaskComponentViewModel viewModel; + final bool showDebugPaint; + + @override + State<_AnimatedTaskComponent> createState() => _AnimatedTaskComponentState(); +} + +class _AnimatedTaskComponentState extends State<_AnimatedTaskComponent> + with ProxyDocumentComponent<_AnimatedTaskComponent>, ProxyTextComposable { + final _textKey = GlobalKey(); + + @override + GlobalKey> get childDocumentComponentKey => _textKey; + + @override + TextComposable get childTextComposable => childDocumentComponentKey.currentState as TextComposable; + + int _counter = 0; + + @override + Widget build(BuildContext context) { + ++_counter; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Rebuild count $_counter'), + Padding( + padding: const EdgeInsets.only(left: 16, right: 4), + child: Checkbox( + value: widget.viewModel.isComplete, + onChanged: (newValue) { + widget.viewModel.setComplete?.call(newValue!); + }, + ), + ), + Expanded( + child: TextComponent( + key: _textKey, + text: widget.viewModel.text, + textStyleBuilder: (attributions) { + // Show a strikethrough across the entire task if it's complete. + final style = widget.viewModel.textStyleBuilder(attributions); + return widget.viewModel.isComplete + ? style.copyWith( + decoration: style.decoration == null + ? TextDecoration.lineThrough + : TextDecoration.combine([TextDecoration.lineThrough, style.decoration!]), + ) + : style; + }, + textSelection: widget.viewModel.selection, + selectionColor: widget.viewModel.selectionColor, + highlightWhenEmpty: widget.viewModel.highlightWhenEmpty, + showDebugPaint: widget.showDebugPaint, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 56), + child: SizeChangedLayoutNotifier( + child: AnimatedSize( + key: _animatedSizeKey, + duration: const Duration(milliseconds: 100), + child: widget.viewModel.selection != null + ? const SizedBox( + height: 20, + child: Row( + children: [ + Icon(Icons.label_important_outline, size: 16), + SizedBox(width: 4), + Icon(Icons.timelapse_sharp, size: 16), + ], + ), + ) + : const SizedBox.shrink(), + ), + ), + ), + ], + ); + } + + final _animatedSizeKey = GlobalKey(); +} diff --git a/super_editor/example_perf/lib/documents/frankenstein.dart b/super_editor/example_perf/lib/documents/frankenstein.dart new file mode 100644 index 0000000000..8c54fc688c --- /dev/null +++ b/super_editor/example_perf/lib/documents/frankenstein.dart @@ -0,0 +1,1597 @@ +import 'package:super_editor/super_editor.dart'; + +List frankNodes() { + return frank + .split('\n') + .where((line) => line.isNotEmpty) + .map( + (line) => ParagraphNode(id: Editor.createNodeId(), text: AttributedText(line)), + ) + .toList(); +} + +Document createInitialDocument() { + return MutableDocument( + nodes: frankNodes(), + ); +} + +const frank = ''' +Letter 1 + +To Mrs. Saville, England. + +St. Petersburgh, Dec. 11th, 17—. + +You will rejoice to hear that no disaster has accompanied the commencement of an enterprise which you have regarded with such evil forebodings. I arrived here yesterday, and my first task is to assure my dear sister of my welfare and increasing confidence in the success of my undertaking. + +I am already far north of London, and as I walk in the streets of Petersburgh, I feel a cold northern breeze play upon my cheeks, which braces my nerves and fills me with delight. Do you understand this feeling? This breeze, which has travelled from the regions towards which I am advancing, gives me a foretaste of those icy climes. Inspirited by this wind of promise, my daydreams become more fervent and vivid. I try in vain to be persuaded that the pole is the seat of frost and desolation; it ever presents itself to my imagination as the region of beauty and delight. There, Margaret, the sun is for ever visible, its broad disk just skirting the horizon and diffusing a perpetual splendour. There—for with your leave, my sister, I will put some trust in preceding navigators—there snow and frost are banished; and, sailing over a calm sea, we may be wafted to a land surpassing in wonders and in beauty every region hitherto discovered on the habitable globe. Its productions and features may be without example, as the phenomena of the heavenly bodies undoubtedly are in those undiscovered solitudes. What may not be expected in a country of eternal light? I may there discover the wondrous power which attracts the needle and may regulate a thousand celestial observations that require only this voyage to render their seeming eccentricities consistent for ever. I shall satiate my ardent curiosity with the sight of a part of the world never before visited, and may tread a land never before imprinted by the foot of man. These are my enticements, and they are sufficient to conquer all fear of danger or death and to induce me to commence this laborious voyage with the joy a child feels when he embarks in a little boat, with his holiday mates, on an expedition of discovery up his native river. But supposing all these conjectures to be false, you cannot contest the inestimable benefit which I shall confer on all mankind, to the last generation, by discovering a passage near the pole to those countries, to reach which at present so many months are requisite; or by ascertaining the secret of the magnet, which, if at all possible, can only be effected by an undertaking such as mine. + +These reflections have dispelled the agitation with which I began my letter, and I feel my heart glow with an enthusiasm which elevates me to heaven, for nothing contributes so much to tranquillise the mind as a steady purpose—a point on which the soul may fix its intellectual eye. This expedition has been the favourite dream of my early years. I have read with ardour the accounts of the various voyages which have been made in the prospect of arriving at the North Pacific Ocean through the seas which surround the pole. You may remember that a history of all the voyages made for purposes of discovery composed the whole of our good Uncle Thomas’ library. My education was neglected, yet I was passionately fond of reading. These volumes were my study day and night, and my familiarity with them increased that regret which I had felt, as a child, on learning that my father’s dying injunction had forbidden my uncle to allow me to embark in a seafaring life. + +These visions faded when I perused, for the first time, those poets whose effusions entranced my soul and lifted it to heaven. I also became a poet and for one year lived in a paradise of my own creation; I imagined that I also might obtain a niche in the temple where the names of Homer and Shakespeare are consecrated. You are well acquainted with my failure and how heavily I bore the disappointment. But just at that time I inherited the fortune of my cousin, and my thoughts were turned into the channel of their earlier bent. + +Six years have passed since I resolved on my present undertaking. I can, even now, remember the hour from which I dedicated myself to this great enterprise. I commenced by inuring my body to hardship. I accompanied the whale-fishers on several expeditions to the North Sea; I voluntarily endured cold, famine, thirst, and want of sleep; I often worked harder than the common sailors during the day and devoted my nights to the study of mathematics, the theory of medicine, and those branches of physical science from which a naval adventurer might derive the greatest practical advantage. Twice I actually hired myself as an under-mate in a Greenland whaler, and acquitted myself to admiration. I must own I felt a little proud when my captain offered me the second dignity in the vessel and entreated me to remain with the greatest earnestness, so valuable did he consider my services. + +And now, dear Margaret, do I not deserve to accomplish some great purpose? My life might have been passed in ease and luxury, but I preferred glory to every enticement that wealth placed in my path. Oh, that some encouraging voice would answer in the affirmative! My courage and my resolution is firm; but my hopes fluctuate, and my spirits are often depressed. I am about to proceed on a long and difficult voyage, the emergencies of which will demand all my fortitude: I am required not only to raise the spirits of others, but sometimes to sustain my own, when theirs are failing. + +This is the most favourable period for travelling in Russia. They fly quickly over the snow in their sledges; the motion is pleasant, and, in my opinion, far more agreeable than that of an English stagecoach. The cold is not excessive, if you are wrapped in furs—a dress which I have already adopted, for there is a great difference between walking the deck and remaining seated motionless for hours, when no exercise prevents the blood from actually freezing in your veins. I have no ambition to lose my life on the post-road between St. Petersburgh and Archangel. + +I shall depart for the latter town in a fortnight or three weeks; and my intention is to hire a ship there, which can easily be done by paying the insurance for the owner, and to engage as many sailors as I think necessary among those who are accustomed to the whale-fishing. I do not intend to sail until the month of June; and when shall I return? Ah, dear sister, how can I answer this question? If I succeed, many, many months, perhaps years, will pass before you and I may meet. If I fail, you will see me again soon, or never. + +Farewell, my dear, excellent Margaret. Heaven shower down blessings on you, and save me, that I may again and again testify my gratitude for all your love and kindness. + +Your affectionate brother, +R. Walton +Letter 2 + +To Mrs. Saville, England. + +Archangel, 28th March, 17—. + +How slowly the time passes here, encompassed as I am by frost and snow! Yet a second step is taken towards my enterprise. I have hired a vessel and am occupied in collecting my sailors; those whom I have already engaged appear to be men on whom I can depend and are certainly possessed of dauntless courage. + +But I have one want which I have never yet been able to satisfy, and the absence of the object of which I now feel as a most severe evil, I have no friend, Margaret: when I am glowing with the enthusiasm of success, there will be none to participate my joy; if I am assailed by disappointment, no one will endeavour to sustain me in dejection. I shall commit my thoughts to paper, it is true; but that is a poor medium for the communication of feeling. I desire the company of a man who could sympathise with me, whose eyes would reply to mine. You may deem me romantic, my dear sister, but I bitterly feel the want of a friend. I have no one near me, gentle yet courageous, possessed of a cultivated as well as of a capacious mind, whose tastes are like my own, to approve or amend my plans. How would such a friend repair the faults of your poor brother! I am too ardent in execution and too impatient of difficulties. But it is a still greater evil to me that I am self-educated: for the first fourteen years of my life I ran wild on a common and read nothing but our Uncle Thomas’ books of voyages. At that age I became acquainted with the celebrated poets of our own country; but it was only when it had ceased to be in my power to derive its most important benefits from such a conviction that I perceived the necessity of becoming acquainted with more languages than that of my native country. Now I am twenty-eight and am in reality more illiterate than many schoolboys of fifteen. It is true that I have thought more and that my daydreams are more extended and magnificent, but they want (as the painters call it) keeping; and I greatly need a friend who would have sense enough not to despise me as romantic, and affection enough for me to endeavour to regulate my mind. + +Well, these are useless complaints; I shall certainly find no friend on the wide ocean, nor even here in Archangel, among merchants and seamen. Yet some feelings, unallied to the dross of human nature, beat even in these rugged bosoms. My lieutenant, for instance, is a man of wonderful courage and enterprise; he is madly desirous of glory, or rather, to word my phrase more characteristically, of advancement in his profession. He is an Englishman, and in the midst of national and professional prejudices, unsoftened by cultivation, retains some of the noblest endowments of humanity. I first became acquainted with him on board a whale vessel; finding that he was unemployed in this city, I easily engaged him to assist in my enterprise. + +The master is a person of an excellent disposition and is remarkable in the ship for his gentleness and the mildness of his discipline. This circumstance, added to his well-known integrity and dauntless courage, made me very desirous to engage him. A youth passed in solitude, my best years spent under your gentle and feminine fosterage, has so refined the groundwork of my character that I cannot overcome an intense distaste to the usual brutality exercised on board ship: I have never believed it to be necessary, and when I heard of a mariner equally noted for his kindliness of heart and the respect and obedience paid to him by his crew, I felt myself peculiarly fortunate in being able to secure his services. I heard of him first in rather a romantic manner, from a lady who owes to him the happiness of her life. This, briefly, is his story. Some years ago he loved a young Russian lady of moderate fortune, and having amassed a considerable sum in prize-money, the father of the girl consented to the match. He saw his mistress once before the destined ceremony; but she was bathed in tears, and throwing herself at his feet, entreated him to spare her, confessing at the same time that she loved another, but that he was poor, and that her father would never consent to the union. My generous friend reassured the suppliant, and on being informed of the name of her lover, instantly abandoned his pursuit. He had already bought a farm with his money, on which he had designed to pass the remainder of his life; but he bestowed the whole on his rival, together with the remains of his prize-money to purchase stock, and then himself solicited the young woman’s father to consent to her marriage with her lover. But the old man decidedly refused, thinking himself bound in honour to my friend, who, when he found the father inexorable, quitted his country, nor returned until he heard that his former mistress was married according to her inclinations. “What a noble fellow!” you will exclaim. He is so; but then he is wholly uneducated: he is as silent as a Turk, and a kind of ignorant carelessness attends him, which, while it renders his conduct the more astonishing, detracts from the interest and sympathy which otherwise he would command. + +Yet do not suppose, because I complain a little or because I can conceive a consolation for my toils which I may never know, that I am wavering in my resolutions. Those are as fixed as fate, and my voyage is only now delayed until the weather shall permit my embarkation. The winter has been dreadfully severe, but the spring promises well, and it is considered as a remarkably early season, so that perhaps I may sail sooner than I expected. I shall do nothing rashly: you know me sufficiently to confide in my prudence and considerateness whenever the safety of others is committed to my care. + +I cannot describe to you my sensations on the near prospect of my undertaking. It is impossible to communicate to you a conception of the trembling sensation, half pleasurable and half fearful, with which I am preparing to depart. I am going to unexplored regions, to “the land of mist and snow,” but I shall kill no albatross; therefore do not be alarmed for my safety or if I should come back to you as worn and woeful as the “Ancient Mariner.” You will smile at my allusion, but I will disclose a secret. I have often attributed my attachment to, my passionate enthusiasm for, the dangerous mysteries of ocean to that production of the most imaginative of modern poets. There is something at work in my soul which I do not understand. I am practically industrious—painstaking, a workman to execute with perseverance and labour—but besides this there is a love for the marvellous, a belief in the marvellous, intertwined in all my projects, which hurries me out of the common pathways of men, even to the wild sea and unvisited regions I am about to explore. + +But to return to dearer considerations. Shall I meet you again, after having traversed immense seas, and returned by the most southern cape of Africa or America? I dare not expect such success, yet I cannot bear to look on the reverse of the picture. Continue for the present to write to me by every opportunity: I may receive your letters on some occasions when I need them most to support my spirits. I love you very tenderly. Remember me with affection, should you never hear from me again. + +Your affectionate brother, +Robert Walton +Letter 3 + +To Mrs. Saville, England. + +July 7th, 17—. + +My dear Sister, + +I write a few lines in haste to say that I am safe—and well advanced on my voyage. This letter will reach England by a merchantman now on its homeward voyage from Archangel; more fortunate than I, who may not see my native land, perhaps, for many years. I am, however, in good spirits: my men are bold and apparently firm of purpose, nor do the floating sheets of ice that continually pass us, indicating the dangers of the region towards which we are advancing, appear to dismay them. We have already reached a very high latitude; but it is the height of summer, and although not so warm as in England, the southern gales, which blow us speedily towards those shores which I so ardently desire to attain, breathe a degree of renovating warmth which I had not expected. + +No incidents have hitherto befallen us that would make a figure in a letter. One or two stiff gales and the springing of a leak are accidents which experienced navigators scarcely remember to record, and I shall be well content if nothing worse happen to us during our voyage. + +Adieu, my dear Margaret. Be assured that for my own sake, as well as yours, I will not rashly encounter danger. I will be cool, persevering, and prudent. + +But success shall crown my endeavours. Wherefore not? Thus far I have gone, tracing a secure way over the pathless seas, the very stars themselves being witnesses and testimonies of my triumph. Why not still proceed over the untamed yet obedient element? What can stop the determined heart and resolved will of man? + +My swelling heart involuntarily pours itself out thus. But I must finish. Heaven bless my beloved sister! + +R.W. +Letter 4 + +To Mrs. Saville, England. + +August 5th, 17—. + +So strange an accident has happened to us that I cannot forbear recording it, although it is very probable that you will see me before these papers can come into your possession. + +Last Monday (July 31st) we were nearly surrounded by ice, which closed in the ship on all sides, scarcely leaving her the sea-room in which she floated. Our situation was somewhat dangerous, especially as we were compassed round by a very thick fog. We accordingly lay to, hoping that some change would take place in the atmosphere and weather. + +About two o’clock the mist cleared away, and we beheld, stretched out in every direction, vast and irregular plains of ice, which seemed to have no end. Some of my comrades groaned, and my own mind began to grow watchful with anxious thoughts, when a strange sight suddenly attracted our attention and diverted our solicitude from our own situation. We perceived a low carriage, fixed on a sledge and drawn by dogs, pass on towards the north, at the distance of half a mile; a being which had the shape of a man, but apparently of gigantic stature, sat in the sledge and guided the dogs. We watched the rapid progress of the traveller with our telescopes until he was lost among the distant inequalities of the ice. + +This appearance excited our unqualified wonder. We were, as we believed, many hundred miles from any land; but this apparition seemed to denote that it was not, in reality, so distant as we had supposed. Shut in, however, by ice, it was impossible to follow his track, which we had observed with the greatest attention. + +About two hours after this occurrence we heard the ground sea, and before night the ice broke and freed our ship. We, however, lay to until the morning, fearing to encounter in the dark those large loose masses which float about after the breaking up of the ice. I profited of this time to rest for a few hours. + +In the morning, however, as soon as it was light, I went upon deck and found all the sailors busy on one side of the vessel, apparently talking to someone in the sea. It was, in fact, a sledge, like that we had seen before, which had drifted towards us in the night on a large fragment of ice. Only one dog remained alive; but there was a human being within it whom the sailors were persuading to enter the vessel. He was not, as the other traveller seemed to be, a savage inhabitant of some undiscovered island, but a European. When I appeared on deck the master said, “Here is our captain, and he will not allow you to perish on the open sea.” + +On perceiving me, the stranger addressed me in English, although with a foreign accent. “Before I come on board your vessel,” said he, “will you have the kindness to inform me whither you are bound?” + +You may conceive my astonishment on hearing such a question addressed to me from a man on the brink of destruction and to whom I should have supposed that my vessel would have been a resource which he would not have exchanged for the most precious wealth the earth can afford. I replied, however, that we were on a voyage of discovery towards the northern pole. + +Upon hearing this he appeared satisfied and consented to come on board. Good God! Margaret, if you had seen the man who thus capitulated for his safety, your surprise would have been boundless. His limbs were nearly frozen, and his body dreadfully emaciated by fatigue and suffering. I never saw a man in so wretched a condition. We attempted to carry him into the cabin, but as soon as he had quitted the fresh air he fainted. We accordingly brought him back to the deck and restored him to animation by rubbing him with brandy and forcing him to swallow a small quantity. As soon as he showed signs of life we wrapped him up in blankets and placed him near the chimney of the kitchen stove. By slow degrees he recovered and ate a little soup, which restored him wonderfully. + +Two days passed in this manner before he was able to speak, and I often feared that his sufferings had deprived him of understanding. When he had in some measure recovered, I removed him to my own cabin and attended on him as much as my duty would permit. I never saw a more interesting creature: his eyes have generally an expression of wildness, and even madness, but there are moments when, if anyone performs an act of kindness towards him or does him any the most trifling service, his whole countenance is lighted up, as it were, with a beam of benevolence and sweetness that I never saw equalled. But he is generally melancholy and despairing, and sometimes he gnashes his teeth, as if impatient of the weight of woes that oppresses him. + +When my guest was a little recovered I had great trouble to keep off the men, who wished to ask him a thousand questions; but I would not allow him to be tormented by their idle curiosity, in a state of body and mind whose restoration evidently depended upon entire repose. Once, however, the lieutenant asked why he had come so far upon the ice in so strange a vehicle. + +His countenance instantly assumed an aspect of the deepest gloom, and he replied, “To seek one who fled from me.” + +“And did the man whom you pursued travel in the same fashion?” + +“Yes.” + +“Then I fancy we have seen him, for the day before we picked you up we saw some dogs drawing a sledge, with a man in it, across the ice.” + +This aroused the stranger’s attention, and he asked a multitude of questions concerning the route which the dæmon, as he called him, had pursued. Soon after, when he was alone with me, he said, “I have, doubtless, excited your curiosity, as well as that of these good people; but you are too considerate to make inquiries.” + +“Certainly; it would indeed be very impertinent and inhuman in me to trouble you with any inquisitiveness of mine.” + +“And yet you rescued me from a strange and perilous situation; you have benevolently restored me to life.” + +Soon after this he inquired if I thought that the breaking up of the ice had destroyed the other sledge. I replied that I could not answer with any degree of certainty, for the ice had not broken until near midnight, and the traveller might have arrived at a place of safety before that time; but of this I could not judge. + +From this time a new spirit of life animated the decaying frame of the stranger. He manifested the greatest eagerness to be upon deck to watch for the sledge which had before appeared; but I have persuaded him to remain in the cabin, for he is far too weak to sustain the rawness of the atmosphere. I have promised that someone should watch for him and give him instant notice if any new object should appear in sight. + +Such is my journal of what relates to this strange occurrence up to the present day. The stranger has gradually improved in health but is very silent and appears uneasy when anyone except myself enters his cabin. Yet his manners are so conciliating and gentle that the sailors are all interested in him, although they have had very little communication with him. For my own part, I begin to love him as a brother, and his constant and deep grief fills me with sympathy and compassion. He must have been a noble creature in his better days, being even now in wreck so attractive and amiable. + +I said in one of my letters, my dear Margaret, that I should find no friend on the wide ocean; yet I have found a man who, before his spirit had been broken by misery, I should have been happy to have possessed as the brother of my heart. + +I shall continue my journal concerning the stranger at intervals, should I have any fresh incidents to record. + + +August 13th, 17—. + +My affection for my guest increases every day. He excites at once my admiration and my pity to an astonishing degree. How can I see so noble a creature destroyed by misery without feeling the most poignant grief? He is so gentle, yet so wise; his mind is so cultivated, and when he speaks, although his words are culled with the choicest art, yet they flow with rapidity and unparalleled eloquence. + +He is now much recovered from his illness and is continually on the deck, apparently watching for the sledge that preceded his own. Yet, although unhappy, he is not so utterly occupied by his own misery but that he interests himself deeply in the projects of others. He has frequently conversed with me on mine, which I have communicated to him without disguise. He entered attentively into all my arguments in favour of my eventual success and into every minute detail of the measures I had taken to secure it. I was easily led by the sympathy which he evinced to use the language of my heart, to give utterance to the burning ardour of my soul and to say, with all the fervour that warmed me, how gladly I would sacrifice my fortune, my existence, my every hope, to the furtherance of my enterprise. One man’s life or death were but a small price to pay for the acquirement of the knowledge which I sought, for the dominion I should acquire and transmit over the elemental foes of our race. As I spoke, a dark gloom spread over my listener’s countenance. At first I perceived that he tried to suppress his emotion; he placed his hands before his eyes, and my voice quivered and failed me as I beheld tears trickle fast from between his fingers; a groan burst from his heaving breast. I paused; at length he spoke, in broken accents: “Unhappy man! Do you share my madness? Have you drunk also of the intoxicating draught? Hear me; let me reveal my tale, and you will dash the cup from your lips!” + +Such words, you may imagine, strongly excited my curiosity; but the paroxysm of grief that had seized the stranger overcame his weakened powers, and many hours of repose and tranquil conversation were necessary to restore his composure. + +Having conquered the violence of his feelings, he appeared to despise himself for being the slave of passion; and quelling the dark tyranny of despair, he led me again to converse concerning myself personally. He asked me the history of my earlier years. The tale was quickly told, but it awakened various trains of reflection. I spoke of my desire of finding a friend, of my thirst for a more intimate sympathy with a fellow mind than had ever fallen to my lot, and expressed my conviction that a man could boast of little happiness who did not enjoy this blessing. + +“I agree with you,” replied the stranger; “we are unfashioned creatures, but half made up, if one wiser, better, dearer than ourselves—such a friend ought to be—do not lend his aid to perfectionate our weak and faulty natures. I once had a friend, the most noble of human creatures, and am entitled, therefore, to judge respecting friendship. You have hope, and the world before you, and have no cause for despair. But I—I have lost everything and cannot begin life anew.” + +As he said this his countenance became expressive of a calm, settled grief that touched me to the heart. But he was silent and presently retired to his cabin. + +Even broken in spirit as he is, no one can feel more deeply than he does the beauties of nature. The starry sky, the sea, and every sight afforded by these wonderful regions seem still to have the power of elevating his soul from earth. Such a man has a double existence: he may suffer misery and be overwhelmed by disappointments, yet when he has retired into himself, he will be like a celestial spirit that has a halo around him, within whose circle no grief or folly ventures. + +Will you smile at the enthusiasm I express concerning this divine wanderer? You would not if you saw him. You have been tutored and refined by books and retirement from the world, and you are therefore somewhat fastidious; but this only renders you the more fit to appreciate the extraordinary merits of this wonderful man. Sometimes I have endeavoured to discover what quality it is which he possesses that elevates him so immeasurably above any other person I ever knew. I believe it to be an intuitive discernment, a quick but never-failing power of judgment, a penetration into the causes of things, unequalled for clearness and precision; add to this a facility of expression and a voice whose varied intonations are soul-subduing music. + + +August 19th, 17—. + +Yesterday the stranger said to me, “You may easily perceive, Captain Walton, that I have suffered great and unparalleled misfortunes. I had determined at one time that the memory of these evils should die with me, but you have won me to alter my determination. You seek for knowledge and wisdom, as I once did; and I ardently hope that the gratification of your wishes may not be a serpent to sting you, as mine has been. I do not know that the relation of my disasters will be useful to you; yet, when I reflect that you are pursuing the same course, exposing yourself to the same dangers which have rendered me what I am, I imagine that you may deduce an apt moral from my tale, one that may direct you if you succeed in your undertaking and console you in case of failure. Prepare to hear of occurrences which are usually deemed marvellous. Were we among the tamer scenes of nature I might fear to encounter your unbelief, perhaps your ridicule; but many things will appear possible in these wild and mysterious regions which would provoke the laughter of those unacquainted with the ever-varied powers of nature; nor can I doubt but that my tale conveys in its series internal evidence of the truth of the events of which it is composed.” + +You may easily imagine that I was much gratified by the offered communication, yet I could not endure that he should renew his grief by a recital of his misfortunes. I felt the greatest eagerness to hear the promised narrative, partly from curiosity and partly from a strong desire to ameliorate his fate if it were in my power. I expressed these feelings in my answer. + +“I thank you,” he replied, “for your sympathy, but it is useless; my fate is nearly fulfilled. I wait but for one event, and then I shall repose in peace. I understand your feeling,” continued he, perceiving that I wished to interrupt him; “but you are mistaken, my friend, if thus you will allow me to name you; nothing can alter my destiny; listen to my history, and you will perceive how irrevocably it is determined.” + +He then told me that he would commence his narrative the next day when I should be at leisure. This promise drew from me the warmest thanks. I have resolved every night, when I am not imperatively occupied by my duties, to record, as nearly as possible in his own words, what he has related during the day. If I should be engaged, I will at least make notes. This manuscript will doubtless afford you the greatest pleasure; but to me, who know him, and who hear it from his own lips—with what interest and sympathy shall I read it in some future day! Even now, as I commence my task, his full-toned voice swells in my ears; his lustrous eyes dwell on me with all their melancholy sweetness; I see his thin hand raised in animation, while the lineaments of his face are irradiated by the soul within. Strange and harrowing must be his story, frightful the storm which embraced the gallant vessel on its course and wrecked it—thus! +Chapter 1 + +I am by birth a Genevese, and my family is one of the most distinguished of that republic. My ancestors had been for many years counsellors and syndics, and my father had filled several public situations with honour and reputation. He was respected by all who knew him for his integrity and indefatigable attention to public business. He passed his younger days perpetually occupied by the affairs of his country; a variety of circumstances had prevented his marrying early, nor was it until the decline of life that he became a husband and the father of a family. + +As the circumstances of his marriage illustrate his character, I cannot refrain from relating them. One of his most intimate friends was a merchant who, from a flourishing state, fell, through numerous mischances, into poverty. This man, whose name was Beaufort, was of a proud and unbending disposition and could not bear to live in poverty and oblivion in the same country where he had formerly been distinguished for his rank and magnificence. Having paid his debts, therefore, in the most honourable manner, he retreated with his daughter to the town of Lucerne, where he lived unknown and in wretchedness. My father loved Beaufort with the truest friendship and was deeply grieved by his retreat in these unfortunate circumstances. He bitterly deplored the false pride which led his friend to a conduct so little worthy of the affection that united them. He lost no time in endeavouring to seek him out, with the hope of persuading him to begin the world again through his credit and assistance. + +Beaufort had taken effectual measures to conceal himself, and it was ten months before my father discovered his abode. Overjoyed at this discovery, he hastened to the house, which was situated in a mean street near the Reuss. But when he entered, misery and despair alone welcomed him. Beaufort had saved but a very small sum of money from the wreck of his fortunes, but it was sufficient to provide him with sustenance for some months, and in the meantime he hoped to procure some respectable employment in a merchant’s house. The interval was, consequently, spent in inaction; his grief only became more deep and rankling when he had leisure for reflection, and at length it took so fast hold of his mind that at the end of three months he lay on a bed of sickness, incapable of any exertion. + +His daughter attended him with the greatest tenderness, but she saw with despair that their little fund was rapidly decreasing and that there was no other prospect of support. But Caroline Beaufort possessed a mind of an uncommon mould, and her courage rose to support her in her adversity. She procured plain work; she plaited straw and by various means contrived to earn a pittance scarcely sufficient to support life. + +Several months passed in this manner. Her father grew worse; her time was more entirely occupied in attending him; her means of subsistence decreased; and in the tenth month her father died in her arms, leaving her an orphan and a beggar. This last blow overcame her, and she knelt by Beaufort’s coffin weeping bitterly, when my father entered the chamber. He came like a protecting spirit to the poor girl, who committed herself to his care; and after the interment of his friend he conducted her to Geneva and placed her under the protection of a relation. Two years after this event Caroline became his wife. + +There was a considerable difference between the ages of my parents, but this circumstance seemed to unite them only closer in bonds of devoted affection. There was a sense of justice in my father’s upright mind which rendered it necessary that he should approve highly to love strongly. Perhaps during former years he had suffered from the late-discovered unworthiness of one beloved and so was disposed to set a greater value on tried worth. There was a show of gratitude and worship in his attachment to my mother, differing wholly from the doting fondness of age, for it was inspired by reverence for her virtues and a desire to be the means of, in some degree, recompensing her for the sorrows she had endured, but which gave inexpressible grace to his behaviour to her. Everything was made to yield to her wishes and her convenience. He strove to shelter her, as a fair exotic is sheltered by the gardener, from every rougher wind and to surround her with all that could tend to excite pleasurable emotion in her soft and benevolent mind. Her health, and even the tranquillity of her hitherto constant spirit, had been shaken by what she had gone through. During the two years that had elapsed previous to their marriage my father had gradually relinquished all his public functions; and immediately after their union they sought the pleasant climate of Italy, and the change of scene and interest attendant on a tour through that land of wonders, as a restorative for her weakened frame. + +From Italy they visited Germany and France. I, their eldest child, was born at Naples, and as an infant accompanied them in their rambles. I remained for several years their only child. Much as they were attached to each other, they seemed to draw inexhaustible stores of affection from a very mine of love to bestow them upon me. My mother’s tender caresses and my father’s smile of benevolent pleasure while regarding me are my first recollections. I was their plaything and their idol, and something better—their child, the innocent and helpless creature bestowed on them by Heaven, whom to bring up to good, and whose future lot it was in their hands to direct to happiness or misery, according as they fulfilled their duties towards me. With this deep consciousness of what they owed towards the being to which they had given life, added to the active spirit of tenderness that animated both, it may be imagined that while during every hour of my infant life I received a lesson of patience, of charity, and of self-control, I was so guided by a silken cord that all seemed but one train of enjoyment to me. + +For a long time I was their only care. My mother had much desired to have a daughter, but I continued their single offspring. When I was about five years old, while making an excursion beyond the frontiers of Italy, they passed a week on the shores of the Lake of Como. Their benevolent disposition often made them enter the cottages of the poor. This, to my mother, was more than a duty; it was a necessity, a passion—remembering what she had suffered, and how she had been relieved—for her to act in her turn the guardian angel to the afflicted. During one of their walks a poor cot in the foldings of a vale attracted their notice as being singularly disconsolate, while the number of half-clothed children gathered about it spoke of penury in its worst shape. One day, when my father had gone by himself to Milan, my mother, accompanied by me, visited this abode. She found a peasant and his wife, hard working, bent down by care and labour, distributing a scanty meal to five hungry babes. Among these there was one which attracted my mother far above all the rest. She appeared of a different stock. The four others were dark-eyed, hardy little vagrants; this child was thin and very fair. Her hair was the brightest living gold, and despite the poverty of her clothing, seemed to set a crown of distinction on her head. Her brow was clear and ample, her blue eyes cloudless, and her lips and the moulding of her face so expressive of sensibility and sweetness that none could behold her without looking on her as of a distinct species, a being heaven-sent, and bearing a celestial stamp in all her features. + +The peasant woman, perceiving that my mother fixed eyes of wonder and admiration on this lovely girl, eagerly communicated her history. She was not her child, but the daughter of a Milanese nobleman. Her mother was a German and had died on giving her birth. The infant had been placed with these good people to nurse: they were better off then. They had not been long married, and their eldest child was but just born. The father of their charge was one of those Italians nursed in the memory of the antique glory of Italy—one among the schiavi ognor frementi, who exerted himself to obtain the liberty of his country. He became the victim of its weakness. Whether he had died or still lingered in the dungeons of Austria was not known. His property was confiscated; his child became an orphan and a beggar. She continued with her foster parents and bloomed in their rude abode, fairer than a garden rose among dark-leaved brambles. + +When my father returned from Milan, he found playing with me in the hall of our villa a child fairer than pictured cherub—a creature who seemed to shed radiance from her looks and whose form and motions were lighter than the chamois of the hills. The apparition was soon explained. With his permission my mother prevailed on her rustic guardians to yield their charge to her. They were fond of the sweet orphan. Her presence had seemed a blessing to them, but it would be unfair to her to keep her in poverty and want when Providence afforded her such powerful protection. They consulted their village priest, and the result was that Elizabeth Lavenza became the inmate of my parents’ house—my more than sister—the beautiful and adored companion of all my occupations and my pleasures. + +Everyone loved Elizabeth. The passionate and almost reverential attachment with which all regarded her became, while I shared it, my pride and my delight. On the evening previous to her being brought to my home, my mother had said playfully, “I have a pretty present for my Victor—tomorrow he shall have it.” And when, on the morrow, she presented Elizabeth to me as her promised gift, I, with childish seriousness, interpreted her words literally and looked upon Elizabeth as mine—mine to protect, love, and cherish. All praises bestowed on her I received as made to a possession of my own. We called each other familiarly by the name of cousin. No word, no expression could body forth the kind of relation in which she stood to me—my more than sister, since till death she was to be mine only. +Chapter 2 + +We were brought up together; there was not quite a year difference in our ages. I need not say that we were strangers to any species of disunion or dispute. Harmony was the soul of our companionship, and the diversity and contrast that subsisted in our characters drew us nearer together. Elizabeth was of a calmer and more concentrated disposition; but, with all my ardour, I was capable of a more intense application and was more deeply smitten with the thirst for knowledge. She busied herself with following the aerial creations of the poets; and in the majestic and wondrous scenes which surrounded our Swiss home —the sublime shapes of the mountains, the changes of the seasons, tempest and calm, the silence of winter, and the life and turbulence of our Alpine summers—she found ample scope for admiration and delight. While my companion contemplated with a serious and satisfied spirit the magnificent appearances of things, I delighted in investigating their causes. The world was to me a secret which I desired to divine. Curiosity, earnest research to learn the hidden laws of nature, gladness akin to rapture, as they were unfolded to me, are among the earliest sensations I can remember. + +On the birth of a second son, my junior by seven years, my parents gave up entirely their wandering life and fixed themselves in their native country. We possessed a house in Geneva, and a campagne on Belrive, the eastern shore of the lake, at the distance of rather more than a league from the city. We resided principally in the latter, and the lives of my parents were passed in considerable seclusion. It was my temper to avoid a crowd and to attach myself fervently to a few. I was indifferent, therefore, to my school-fellows in general; but I united myself in the bonds of the closest friendship to one among them. Henry Clerval was the son of a merchant of Geneva. He was a boy of singular talent and fancy. He loved enterprise, hardship, and even danger for its own sake. He was deeply read in books of chivalry and romance. He composed heroic songs and began to write many a tale of enchantment and knightly adventure. He tried to make us act plays and to enter into masquerades, in which the characters were drawn from the heroes of Roncesvalles, of the Round Table of King Arthur, and the chivalrous train who shed their blood to redeem the holy sepulchre from the hands of the infidels. + +No human being could have passed a happier childhood than myself. My parents were possessed by the very spirit of kindness and indulgence. We felt that they were not the tyrants to rule our lot according to their caprice, but the agents and creators of all the many delights which we enjoyed. When I mingled with other families I distinctly discerned how peculiarly fortunate my lot was, and gratitude assisted the development of filial love. + +My temper was sometimes violent, and my passions vehement; but by some law in my temperature they were turned not towards childish pursuits but to an eager desire to learn, and not to learn all things indiscriminately. I confess that neither the structure of languages, nor the code of governments, nor the politics of various states possessed attractions for me. It was the secrets of heaven and earth that I desired to learn; and whether it was the outward substance of things or the inner spirit of nature and the mysterious soul of man that occupied me, still my inquiries were directed to the metaphysical, or in its highest sense, the physical secrets of the world. + +Meanwhile Clerval occupied himself, so to speak, with the moral relations of things. The busy stage of life, the virtues of heroes, and the actions of men were his theme; and his hope and his dream was to become one among those whose names are recorded in story as the gallant and adventurous benefactors of our species. The saintly soul of Elizabeth shone like a shrine-dedicated lamp in our peaceful home. Her sympathy was ours; her smile, her soft voice, the sweet glance of her celestial eyes, were ever there to bless and animate us. She was the living spirit of love to soften and attract; I might have become sullen in my study, rough through the ardour of my nature, but that she was there to subdue me to a semblance of her own gentleness. And Clerval—could aught ill entrench on the noble spirit of Clerval? Yet he might not have been so perfectly humane, so thoughtful in his generosity, so full of kindness and tenderness amidst his passion for adventurous exploit, had she not unfolded to him the real loveliness of beneficence and made the doing good the end and aim of his soaring ambition. + +I feel exquisite pleasure in dwelling on the recollections of childhood, before misfortune had tainted my mind and changed its bright visions of extensive usefulness into gloomy and narrow reflections upon self. Besides, in drawing the picture of my early days, I also record those events which led, by insensible steps, to my after tale of misery, for when I would account to myself for the birth of that passion which afterwards ruled my destiny I find it arise, like a mountain river, from ignoble and almost forgotten sources; but, swelling as it proceeded, it became the torrent which, in its course, has swept away all my hopes and joys. + +Natural philosophy is the genius that has regulated my fate; I desire, therefore, in this narration, to state those facts which led to my predilection for that science. When I was thirteen years of age we all went on a party of pleasure to the baths near Thonon; the inclemency of the weather obliged us to remain a day confined to the inn. In this house I chanced to find a volume of the works of Cornelius Agrippa. I opened it with apathy; the theory which he attempts to demonstrate and the wonderful facts which he relates soon changed this feeling into enthusiasm. A new light seemed to dawn upon my mind, and bounding with joy, I communicated my discovery to my father. My father looked carelessly at the title page of my book and said, “Ah! Cornelius Agrippa! My dear Victor, do not waste your time upon this; it is sad trash.” + +If, instead of this remark, my father had taken the pains to explain to me that the principles of Agrippa had been entirely exploded and that a modern system of science had been introduced which possessed much greater powers than the ancient, because the powers of the latter were chimerical, while those of the former were real and practical, under such circumstances I should certainly have thrown Agrippa aside and have contented my imagination, warmed as it was, by returning with greater ardour to my former studies. It is even possible that the train of my ideas would never have received the fatal impulse that led to my ruin. But the cursory glance my father had taken of my volume by no means assured me that he was acquainted with its contents, and I continued to read with the greatest avidity. + +When I returned home my first care was to procure the whole works of this author, and afterwards of Paracelsus and Albertus Magnus. I read and studied the wild fancies of these writers with delight; they appeared to me treasures known to few besides myself. I have described myself as always having been imbued with a fervent longing to penetrate the secrets of nature. In spite of the intense labour and wonderful discoveries of modern philosophers, I always came from my studies discontented and unsatisfied. Sir Isaac Newton is said to have avowed that he felt like a child picking up shells beside the great and unexplored ocean of truth. Those of his successors in each branch of natural philosophy with whom I was acquainted appeared even to my boy’s apprehensions as tyros engaged in the same pursuit. + +The untaught peasant beheld the elements around him and was acquainted with their practical uses. The most learned philosopher knew little more. He had partially unveiled the face of Nature, but her immortal lineaments were still a wonder and a mystery. He might dissect, anatomise, and give names; but, not to speak of a final cause, causes in their secondary and tertiary grades were utterly unknown to him. I had gazed upon the fortifications and impediments that seemed to keep human beings from entering the citadel of nature, and rashly and ignorantly I had repined. + +But here were books, and here were men who had penetrated deeper and knew more. I took their word for all that they averred, and I became their disciple. It may appear strange that such should arise in the eighteenth century; but while I followed the routine of education in the schools of Geneva, I was, to a great degree, self-taught with regard to my favourite studies. My father was not scientific, and I was left to struggle with a child’s blindness, added to a student’s thirst for knowledge. Under the guidance of my new preceptors I entered with the greatest diligence into the search of the philosopher’s stone and the elixir of life; but the latter soon obtained my undivided attention. Wealth was an inferior object, but what glory would attend the discovery if I could banish disease from the human frame and render man invulnerable to any but a violent death! + +Nor were these my only visions. The raising of ghosts or devils was a promise liberally accorded by my favourite authors, the fulfilment of which I most eagerly sought; and if my incantations were always unsuccessful, I attributed the failure rather to my own inexperience and mistake than to a want of skill or fidelity in my instructors. And thus for a time I was occupied by exploded systems, mingling, like an unadept, a thousand contradictory theories and floundering desperately in a very slough of multifarious knowledge, guided by an ardent imagination and childish reasoning, till an accident again changed the current of my ideas. + +When I was about fifteen years old we had retired to our house near Belrive, when we witnessed a most violent and terrible thunderstorm. It advanced from behind the mountains of Jura, and the thunder burst at once with frightful loudness from various quarters of the heavens. I remained, while the storm lasted, watching its progress with curiosity and delight. As I stood at the door, on a sudden I beheld a stream of fire issue from an old and beautiful oak which stood about twenty yards from our house; and so soon as the dazzling light vanished, the oak had disappeared, and nothing remained but a blasted stump. When we visited it the next morning, we found the tree shattered in a singular manner. It was not splintered by the shock, but entirely reduced to thin ribbons of wood. I never beheld anything so utterly destroyed. + +Before this I was not unacquainted with the more obvious laws of electricity. On this occasion a man of great research in natural philosophy was with us, and excited by this catastrophe, he entered on the explanation of a theory which he had formed on the subject of electricity and galvanism, which was at once new and astonishing to me. All that he said threw greatly into the shade Cornelius Agrippa, Albertus Magnus, and Paracelsus, the lords of my imagination; but by some fatality the overthrow of these men disinclined me to pursue my accustomed studies. It seemed to me as if nothing would or could ever be known. All that had so long engaged my attention suddenly grew despicable. By one of those caprices of the mind which we are perhaps most subject to in early youth, I at once gave up my former occupations, set down natural history and all its progeny as a deformed and abortive creation, and entertained the greatest disdain for a would-be science which could never even step within the threshold of real knowledge. In this mood of mind I betook myself to the mathematics and the branches of study appertaining to that science as being built upon secure foundations, and so worthy of my consideration. + +Thus strangely are our souls constructed, and by such slight ligaments are we bound to prosperity or ruin. When I look back, it seems to me as if this almost miraculous change of inclination and will was the immediate suggestion of the guardian angel of my life—the last effort made by the spirit of preservation to avert the storm that was even then hanging in the stars and ready to envelop me. Her victory was announced by an unusual tranquillity and gladness of soul which followed the relinquishing of my ancient and latterly tormenting studies. It was thus that I was to be taught to associate evil with their prosecution, happiness with their disregard. + +It was a strong effort of the spirit of good, but it was ineffectual. Destiny was too potent, and her immutable laws had decreed my utter and terrible destruction. +Chapter 3 + +When I had attained the age of seventeen my parents resolved that I should become a student at the university of Ingolstadt. I had hitherto attended the schools of Geneva, but my father thought it necessary for the completion of my education that I should be made acquainted with other customs than those of my native country. My departure was therefore fixed at an early date, but before the day resolved upon could arrive, the first misfortune of my life occurred—an omen, as it were, of my future misery. + +Elizabeth had caught the scarlet fever; her illness was severe, and she was in the greatest danger. During her illness many arguments had been urged to persuade my mother to refrain from attending upon her. She had at first yielded to our entreaties, but when she heard that the life of her favourite was menaced, she could no longer control her anxiety. She attended her sickbed; her watchful attentions triumphed over the malignity of the distemper—Elizabeth was saved, but the consequences of this imprudence were fatal to her preserver. On the third day my mother sickened; her fever was accompanied by the most alarming symptoms, and the looks of her medical attendants prognosticated the worst event. On her deathbed the fortitude and benignity of this best of women did not desert her. She joined the hands of Elizabeth and myself. “My children,” she said, “my firmest hopes of future happiness were placed on the prospect of your union. This expectation will now be the consolation of your father. Elizabeth, my love, you must supply my place to my younger children. Alas! I regret that I am taken from you; and, happy and beloved as I have been, is it not hard to quit you all? But these are not thoughts befitting me; I will endeavour to resign myself cheerfully to death and will indulge a hope of meeting you in another world.” + +She died calmly, and her countenance expressed affection even in death. I need not describe the feelings of those whose dearest ties are rent by that most irreparable evil, the void that presents itself to the soul, and the despair that is exhibited on the countenance. It is so long before the mind can persuade itself that she whom we saw every day and whose very existence appeared a part of our own can have departed for ever—that the brightness of a beloved eye can have been extinguished and the sound of a voice so familiar and dear to the ear can be hushed, never more to be heard. These are the reflections of the first days; but when the lapse of time proves the reality of the evil, then the actual bitterness of grief commences. Yet from whom has not that rude hand rent away some dear connection? And why should I describe a sorrow which all have felt, and must feel? The time at length arrives when grief is rather an indulgence than a necessity; and the smile that plays upon the lips, although it may be deemed a sacrilege, is not banished. My mother was dead, but we had still duties which we ought to perform; we must continue our course with the rest and learn to think ourselves fortunate whilst one remains whom the spoiler has not seized. + +My departure for Ingolstadt, which had been deferred by these events, was now again determined upon. I obtained from my father a respite of some weeks. It appeared to me sacrilege so soon to leave the repose, akin to death, of the house of mourning and to rush into the thick of life. I was new to sorrow, but it did not the less alarm me. I was unwilling to quit the sight of those that remained to me, and above all, I desired to see my sweet Elizabeth in some degree consoled. + +She indeed veiled her grief and strove to act the comforter to us all. She looked steadily on life and assumed its duties with courage and zeal. She devoted herself to those whom she had been taught to call her uncle and cousins. Never was she so enchanting as at this time, when she recalled the sunshine of her smiles and spent them upon us. She forgot even her own regret in her endeavours to make us forget. + +The day of my departure at length arrived. Clerval spent the last evening with us. He had endeavoured to persuade his father to permit him to accompany me and to become my fellow student, but in vain. His father was a narrow-minded trader and saw idleness and ruin in the aspirations and ambition of his son. Henry deeply felt the misfortune of being debarred from a liberal education. He said little, but when he spoke I read in his kindling eye and in his animated glance a restrained but firm resolve not to be chained to the miserable details of commerce. + +We sat late. We could not tear ourselves away from each other nor persuade ourselves to say the word “Farewell!” It was said, and we retired under the pretence of seeking repose, each fancying that the other was deceived; but when at morning’s dawn I descended to the carriage which was to convey me away, they were all there—my father again to bless me, Clerval to press my hand once more, my Elizabeth to renew her entreaties that I would write often and to bestow the last feminine attentions on her playmate and friend. + +I threw myself into the chaise that was to convey me away and indulged in the most melancholy reflections. I, who had ever been surrounded by amiable companions, continually engaged in endeavouring to bestow mutual pleasure—I was now alone. In the university whither I was going I must form my own friends and be my own protector. My life had hitherto been remarkably secluded and domestic, and this had given me invincible repugnance to new countenances. I loved my brothers, Elizabeth, and Clerval; these were “old familiar faces,” but I believed myself totally unfitted for the company of strangers. Such were my reflections as I commenced my journey; but as I proceeded, my spirits and hopes rose. I ardently desired the acquisition of knowledge. I had often, when at home, thought it hard to remain during my youth cooped up in one place and had longed to enter the world and take my station among other human beings. Now my desires were complied with, and it would, indeed, have been folly to repent. + +I had sufficient leisure for these and many other reflections during my journey to Ingolstadt, which was long and fatiguing. At length the high white steeple of the town met my eyes. I alighted and was conducted to my solitary apartment to spend the evening as I pleased. + +The next morning I delivered my letters of introduction and paid a visit to some of the principal professors. Chance—or rather the evil influence, the Angel of Destruction, which asserted omnipotent sway over me from the moment I turned my reluctant steps from my father’s door—led me first to M. Krempe, professor of natural philosophy. He was an uncouth man, but deeply imbued in the secrets of his science. He asked me several questions concerning my progress in the different branches of science appertaining to natural philosophy. I replied carelessly, and partly in contempt, mentioned the names of my alchemists as the principal authors I had studied. The professor stared. “Have you,” he said, “really spent your time in studying such nonsense?” + +I replied in the affirmative. “Every minute,” continued M. Krempe with warmth, “every instant that you have wasted on those books is utterly and entirely lost. You have burdened your memory with exploded systems and useless names. Good God! In what desert land have you lived, where no one was kind enough to inform you that these fancies which you have so greedily imbibed are a thousand years old and as musty as they are ancient? I little expected, in this enlightened and scientific age, to find a disciple of Albertus Magnus and Paracelsus. My dear sir, you must begin your studies entirely anew.” + +So saying, he stepped aside and wrote down a list of several books treating of natural philosophy which he desired me to procure, and dismissed me after mentioning that in the beginning of the following week he intended to commence a course of lectures upon natural philosophy in its general relations, and that M. Waldman, a fellow professor, would lecture upon chemistry the alternate days that he omitted. + +I returned home not disappointed, for I have said that I had long considered those authors useless whom the professor reprobated; but I returned not at all the more inclined to recur to these studies in any shape. M. Krempe was a little squat man with a gruff voice and a repulsive countenance; the teacher, therefore, did not prepossess me in favour of his pursuits. In rather a too philosophical and connected a strain, perhaps, I have given an account of the conclusions I had come to concerning them in my early years. As a child I had not been content with the results promised by the modern professors of natural science. With a confusion of ideas only to be accounted for by my extreme youth and my want of a guide on such matters, I had retrod the steps of knowledge along the paths of time and exchanged the discoveries of recent inquirers for the dreams of forgotten alchemists. Besides, I had a contempt for the uses of modern natural philosophy. It was very different when the masters of the science sought immortality and power; such views, although futile, were grand; but now the scene was changed. The ambition of the inquirer seemed to limit itself to the annihilation of those visions on which my interest in science was chiefly founded. I was required to exchange chimeras of boundless grandeur for realities of little worth. + +Such were my reflections during the first two or three days of my residence at Ingolstadt, which were chiefly spent in becoming acquainted with the localities and the principal residents in my new abode. But as the ensuing week commenced, I thought of the information which M. Krempe had given me concerning the lectures. And although I could not consent to go and hear that little conceited fellow deliver sentences out of a pulpit, I recollected what he had said of M. Waldman, whom I had never seen, as he had hitherto been out of town. + +Partly from curiosity and partly from idleness, I went into the lecturing room, which M. Waldman entered shortly after. This professor was very unlike his colleague. He appeared about fifty years of age, but with an aspect expressive of the greatest benevolence; a few grey hairs covered his temples, but those at the back of his head were nearly black. His person was short but remarkably erect and his voice the sweetest I had ever heard. He began his lecture by a recapitulation of the history of chemistry and the various improvements made by different men of learning, pronouncing with fervour the names of the most distinguished discoverers. He then took a cursory view of the present state of the science and explained many of its elementary terms. After having made a few preparatory experiments, he concluded with a panegyric upon modern chemistry, the terms of which I shall never forget: + +“The ancient teachers of this science,” said he, “promised impossibilities and performed nothing. The modern masters promise very little; they know that metals cannot be transmuted and that the elixir of life is a chimera but these philosophers, whose hands seem only made to dabble in dirt, and their eyes to pore over the microscope or crucible, have indeed performed miracles. They penetrate into the recesses of nature and show how she works in her hiding-places. They ascend into the heavens; they have discovered how the blood circulates, and the nature of the air we breathe. They have acquired new and almost unlimited powers; they can command the thunders of heaven, mimic the earthquake, and even mock the invisible world with its own shadows.” + +Such were the professor’s words—rather let me say such the words of the fate—enounced to destroy me. As he went on I felt as if my soul were grappling with a palpable enemy; one by one the various keys were touched which formed the mechanism of my being; chord after chord was sounded, and soon my mind was filled with one thought, one conception, one purpose. So much has been done, exclaimed the soul of Frankenstein—more, far more, will I achieve; treading in the steps already marked, I will pioneer a new way, explore unknown powers, and unfold to the world the deepest mysteries of creation. + +I closed not my eyes that night. My internal being was in a state of insurrection and turmoil; I felt that order would thence arise, but I had no power to produce it. By degrees, after the morning’s dawn, sleep came. I awoke, and my yesternight’s thoughts were as a dream. There only remained a resolution to return to my ancient studies and to devote myself to a science for which I believed myself to possess a natural talent. On the same day I paid M. Waldman a visit. His manners in private were even more mild and attractive than in public, for there was a certain dignity in his mien during his lecture which in his own house was replaced by the greatest affability and kindness. I gave him pretty nearly the same account of my former pursuits as I had given to his fellow professor. He heard with attention the little narration concerning my studies and smiled at the names of Cornelius Agrippa and Paracelsus, but without the contempt that M. Krempe had exhibited. He said that “These were men to whose indefatigable zeal modern philosophers were indebted for most of the foundations of their knowledge. They had left to us, as an easier task, to give new names and arrange in connected classifications the facts which they in a great degree had been the instruments of bringing to light. The labours of men of genius, however erroneously directed, scarcely ever fail in ultimately turning to the solid advantage of mankind.” I listened to his statement, which was delivered without any presumption or affectation, and then added that his lecture had removed my prejudices against modern chemists; I expressed myself in measured terms, with the modesty and deference due from a youth to his instructor, without letting escape (inexperience in life would have made me ashamed) any of the enthusiasm which stimulated my intended labours. I requested his advice concerning the books I ought to procure. + +“I am happy,” said M. Waldman, “to have gained a disciple; and if your application equals your ability, I have no doubt of your success. Chemistry is that branch of natural philosophy in which the greatest improvements have been and may be made; it is on that account that I have made it my peculiar study; but at the same time, I have not neglected the other branches of science. A man would make but a very sorry chemist if he attended to that department of human knowledge alone. If your wish is to become really a man of science and not merely a petty experimentalist, I should advise you to apply to every branch of natural philosophy, including mathematics.” + +He then took me into his laboratory and explained to me the uses of his various machines, instructing me as to what I ought to procure and promising me the use of his own when I should have advanced far enough in the science not to derange their mechanism. He also gave me the list of books which I had requested, and I took my leave. + +Thus ended a day memorable to me; it decided my future destiny. +Chapter 4 + +From this day natural philosophy, and particularly chemistry, in the most comprehensive sense of the term, became nearly my sole occupation. I read with ardour those works, so full of genius and discrimination, which modern inquirers have written on these subjects. I attended the lectures and cultivated the acquaintance of the men of science of the university, and I found even in M. Krempe a great deal of sound sense and real information, combined, it is true, with a repulsive physiognomy and manners, but not on that account the less valuable. In M. Waldman I found a true friend. His gentleness was never tinged by dogmatism, and his instructions were given with an air of frankness and good nature that banished every idea of pedantry. In a thousand ways he smoothed for me the path of knowledge and made the most abstruse inquiries clear and facile to my apprehension. My application was at first fluctuating and uncertain; it gained strength as I proceeded and soon became so ardent and eager that the stars often disappeared in the light of morning whilst I was yet engaged in my laboratory. + +As I applied so closely, it may be easily conceived that my progress was rapid. My ardour was indeed the astonishment of the students, and my proficiency that of the masters. Professor Krempe often asked me, with a sly smile, how Cornelius Agrippa went on, whilst M. Waldman expressed the most heartfelt exultation in my progress. Two years passed in this manner, during which I paid no visit to Geneva, but was engaged, heart and soul, in the pursuit of some discoveries which I hoped to make. None but those who have experienced them can conceive of the enticements of science. In other studies you go as far as others have gone before you, and there is nothing more to know; but in a scientific pursuit there is continual food for discovery and wonder. A mind of moderate capacity which closely pursues one study must infallibly arrive at great proficiency in that study; and I, who continually sought the attainment of one object of pursuit and was solely wrapped up in this, improved so rapidly that at the end of two years I made some discoveries in the improvement of some chemical instruments, which procured me great esteem and admiration at the university. When I had arrived at this point and had become as well acquainted with the theory and practice of natural philosophy as depended on the lessons of any of the professors at Ingolstadt, my residence there being no longer conducive to my improvements, I thought of returning to my friends and my native town, when an incident happened that protracted my stay. + +One of the phenomena which had peculiarly attracted my attention was the structure of the human frame, and, indeed, any animal endued with life. Whence, I often asked myself, did the principle of life proceed? It was a bold question, and one which has ever been considered as a mystery; yet with how many things are we upon the brink of becoming acquainted, if cowardice or carelessness did not restrain our inquiries. I revolved these circumstances in my mind and determined thenceforth to apply myself more particularly to those branches of natural philosophy which relate to physiology. Unless I had been animated by an almost supernatural enthusiasm, my application to this study would have been irksome and almost intolerable. To examine the causes of life, we must first have recourse to death. I became acquainted with the science of anatomy, but this was not sufficient; I must also observe the natural decay and corruption of the human body. In my education my father had taken the greatest precautions that my mind should be impressed with no supernatural horrors. I do not ever remember to have trembled at a tale of superstition or to have feared the apparition of a spirit. Darkness had no effect upon my fancy, and a churchyard was to me merely the receptacle of bodies deprived of life, which, from being the seat of beauty and strength, had become food for the worm. Now I was led to examine the cause and progress of this decay and forced to spend days and nights in vaults and charnel-houses. My attention was fixed upon every object the most insupportable to the delicacy of the human feelings. I saw how the fine form of man was degraded and wasted; I beheld the corruption of death succeed to the blooming cheek of life; I saw how the worm inherited the wonders of the eye and brain. I paused, examining and analysing all the minutiae of causation, as exemplified in the change from life to death, and death to life, until from the midst of this darkness a sudden light broke in upon me—a light so brilliant and wondrous, yet so simple, that while I became dizzy with the immensity of the prospect which it illustrated, I was surprised that among so many men of genius who had directed their inquiries towards the same science, that I alone should be reserved to discover so astonishing a secret. + +Remember, I am not recording the vision of a madman. The sun does not more certainly shine in the heavens than that which I now affirm is true. Some miracle might have produced it, yet the stages of the discovery were distinct and probable. After days and nights of incredible labour and fatigue, I succeeded in discovering the cause of generation and life; nay, more, I became myself capable of bestowing animation upon lifeless matter. + +The astonishment which I had at first experienced on this discovery soon gave place to delight and rapture. After so much time spent in painful labour, to arrive at once at the summit of my desires was the most gratifying consummation of my toils. But this discovery was so great and overwhelming that all the steps by which I had been progressively led to it were obliterated, and I beheld only the result. What had been the study and desire of the wisest men since the creation of the world was now within my grasp. Not that, like a magic scene, it all opened upon me at once: the information I had obtained was of a nature rather to direct my endeavours so soon as I should point them towards the object of my search than to exhibit that object already accomplished. I was like the Arabian who had been buried with the dead and found a passage to life, aided only by one glimmering and seemingly ineffectual light. + +I see by your eagerness and the wonder and hope which your eyes express, my friend, that you expect to be informed of the secret with which I am acquainted; that cannot be; listen patiently until the end of my story, and you will easily perceive why I am reserved upon that subject. I will not lead you on, unguarded and ardent as I then was, to your destruction and infallible misery. Learn from me, if not by my precepts, at least by my example, how dangerous is the acquirement of knowledge and how much happier that man is who believes his native town to be the world, than he who aspires to become greater than his nature will allow. + +When I found so astonishing a power placed within my hands, I hesitated a long time concerning the manner in which I should employ it. Although I possessed the capacity of bestowing animation, yet to prepare a frame for the reception of it, with all its intricacies of fibres, muscles, and veins, still remained a work of inconceivable difficulty and labour. I doubted at first whether I should attempt the creation of a being like myself, or one of simpler organization; but my imagination was too much exalted by my first success to permit me to doubt of my ability to give life to an animal as complex and wonderful as man. The materials at present within my command hardly appeared adequate to so arduous an undertaking, but I doubted not that I should ultimately succeed. I prepared myself for a multitude of reverses; my operations might be incessantly baffled, and at last my work be imperfect, yet when I considered the improvement which every day takes place in science and mechanics, I was encouraged to hope my present attempts would at least lay the foundations of future success. Nor could I consider the magnitude and complexity of my plan as any argument of its impracticability. It was with these feelings that I began the creation of a human being. As the minuteness of the parts formed a great hindrance to my speed, I resolved, contrary to my first intention, to make the being of a gigantic stature, that is to say, about eight feet in height, and proportionably large. After having formed this determination and having spent some months in successfully collecting and arranging my materials, I began. + +No one can conceive the variety of feelings which bore me onwards, like a hurricane, in the first enthusiasm of success. Life and death appeared to me ideal bounds, which I should first break through, and pour a torrent of light into our dark world. A new species would bless me as its creator and source; many happy and excellent natures would owe their being to me. No father could claim the gratitude of his child so completely as I should deserve theirs. Pursuing these reflections, I thought that if I could bestow animation upon lifeless matter, I might in process of time (although I now found it impossible) renew life where death had apparently devoted the body to corruption. + +These thoughts supported my spirits, while I pursued my undertaking with unremitting ardour. My cheek had grown pale with study, and my person had become emaciated with confinement. Sometimes, on the very brink of certainty, I failed; yet still I clung to the hope which the next day or the next hour might realise. One secret which I alone possessed was the hope to which I had dedicated myself; and the moon gazed on my midnight labours, while, with unrelaxed and breathless eagerness, I pursued nature to her hiding-places. Who shall conceive the horrors of my secret toil as I dabbled among the unhallowed damps of the grave or tortured the living animal to animate the lifeless clay? My limbs now tremble, and my eyes swim with the remembrance; but then a resistless and almost frantic impulse urged me forward; I seemed to have lost all soul or sensation but for this one pursuit. It was indeed but a passing trance, that only made me feel with renewed acuteness so soon as, the unnatural stimulus ceasing to operate, I had returned to my old habits. I collected bones from charnel-houses and disturbed, with profane fingers, the tremendous secrets of the human frame. In a solitary chamber, or rather cell, at the top of the house, and separated from all the other apartments by a gallery and staircase, I kept my workshop of filthy creation; my eyeballs were starting from their sockets in attending to the details of my employment. The dissecting room and the slaughter-house furnished many of my materials; and often did my human nature turn with loathing from my occupation, whilst, still urged on by an eagerness which perpetually increased, I brought my work near to a conclusion. + +The summer months passed while I was thus engaged, heart and soul, in one pursuit. It was a most beautiful season; never did the fields bestow a more plentiful harvest or the vines yield a more luxuriant vintage, but my eyes were insensible to the charms of nature. And the same feelings which made me neglect the scenes around me caused me also to forget those friends who were so many miles absent, and whom I had not seen for so long a time. I knew my silence disquieted them, and I well remembered the words of my father: “I know that while you are pleased with yourself you will think of us with affection, and we shall hear regularly from you. You must pardon me if I regard any interruption in your correspondence as a proof that your other duties are equally neglected.” + +I knew well therefore what would be my father’s feelings, but I could not tear my thoughts from my employment, loathsome in itself, but which had taken an irresistible hold of my imagination. I wished, as it were, to procrastinate all that related to my feelings of affection until the great object, which swallowed up every habit of my nature, should be completed. + +I then thought that my father would be unjust if he ascribed my neglect to vice or faultiness on my part, but I am now convinced that he was justified in conceiving that I should not be altogether free from blame. A human being in perfection ought always to preserve a calm and peaceful mind and never to allow passion or a transitory desire to disturb his tranquillity. I do not think that the pursuit of knowledge is an exception to this rule. If the study to which you apply yourself has a tendency to weaken your affections and to destroy your taste for those simple pleasures in which no alloy can possibly mix, then that study is certainly unlawful, that is to say, not befitting the human mind. If this rule were always observed; if no man allowed any pursuit whatsoever to interfere with the tranquillity of his domestic affections, Greece had not been enslaved, Cæsar would have spared his country, America would have been discovered more gradually, and the empires of Mexico and Peru had not been destroyed. + +But I forget that I am moralizing in the most interesting part of my tale, and your looks remind me to proceed. + +My father made no reproach in his letters and only took notice of my silence by inquiring into my occupations more particularly than before. Winter, spring, and summer passed away during my labours; but I did not watch the blossom or the expanding leaves—sights which before always yielded me supreme delight—so deeply was I engrossed in my occupation. The leaves of that year had withered before my work drew near to a close, and now every day showed me more plainly how well I had succeeded. But my enthusiasm was checked by my anxiety, and I appeared rather like one doomed by slavery to toil in the mines, or any other unwholesome trade than an artist occupied by his favourite employment. Every night I was oppressed by a slow fever, and I became nervous to a most painful degree; the fall of a leaf startled me, and I shunned my fellow creatures as if I had been guilty of a crime. Sometimes I grew alarmed at the wreck I perceived that I had become; the energy of my purpose alone sustained me: my labours would soon end, and I believed that exercise and amusement would then drive away incipient disease; and I promised myself both of these when my creation should be complete. +Chapter 5 + +It was on a dreary night of November that I beheld the accomplishment of my toils. With an anxiety that almost amounted to agony, I collected the instruments of life around me, that I might infuse a spark of being into the lifeless thing that lay at my feet. It was already one in the morning; the rain pattered dismally against the panes, and my candle was nearly burnt out, when, by the glimmer of the half-extinguished light, I saw the dull yellow eye of the creature open; it breathed hard, and a convulsive motion agitated its limbs. + +How can I describe my emotions at this catastrophe, or how delineate the wretch whom with such infinite pains and care I had endeavoured to form? His limbs were in proportion, and I had selected his features as beautiful. Beautiful! Great God! His yellow skin scarcely covered the work of muscles and arteries beneath; his hair was of a lustrous black, and flowing; his teeth of a pearly whiteness; but these luxuriances only formed a more horrid contrast with his watery eyes, that seemed almost of the same colour as the dun-white sockets in which they were set, his shrivelled complexion and straight black lips. + +The different accidents of life are not so changeable as the feelings of human nature. I had worked hard for nearly two years, for the sole purpose of infusing life into an inanimate body. For this I had deprived myself of rest and health. I had desired it with an ardour that far exceeded moderation; but now that I had finished, the beauty of the dream vanished, and breathless horror and disgust filled my heart. Unable to endure the aspect of the being I had created, I rushed out of the room and continued a long time traversing my bed-chamber, unable to compose my mind to sleep. At length lassitude succeeded to the tumult I had before endured, and I threw myself on the bed in my clothes, endeavouring to seek a few moments of forgetfulness. But it was in vain; I slept, indeed, but I was disturbed by the wildest dreams. I thought I saw Elizabeth, in the bloom of health, walking in the streets of Ingolstadt. Delighted and surprised, I embraced her, but as I imprinted the first kiss on her lips, they became livid with the hue of death; her features appeared to change, and I thought that I held the corpse of my dead mother in my arms; a shroud enveloped her form, and I saw the grave-worms crawling in the folds of the flannel. I started from my sleep with horror; a cold dew covered my forehead, my teeth chattered, and every limb became convulsed; when, by the dim and yellow light of the moon, as it forced its way through the window shutters, I beheld the wretch—the miserable monster whom I had created. He held up the curtain of the bed; and his eyes, if eyes they may be called, were fixed on me. His jaws opened, and he muttered some inarticulate sounds, while a grin wrinkled his cheeks. He might have spoken, but I did not hear; one hand was stretched out, seemingly to detain me, but I escaped and rushed downstairs. I took refuge in the courtyard belonging to the house which I inhabited, where I remained during the rest of the night, walking up and down in the greatest agitation, listening attentively, catching and fearing each sound as if it were to announce the approach of the demoniacal corpse to which I had so miserably given life. + +Oh! No mortal could support the horror of that countenance. A mummy again endued with animation could not be so hideous as that wretch. I had gazed on him while unfinished; he was ugly then, but when those muscles and joints were rendered capable of motion, it became a thing such as even Dante could not have conceived. + +I passed the night wretchedly. Sometimes my pulse beat so quickly and hardly that I felt the palpitation of every artery; at others, I nearly sank to the ground through languor and extreme weakness. Mingled with this horror, I felt the bitterness of disappointment; dreams that had been my food and pleasant rest for so long a space were now become a hell to me; and the change was so rapid, the overthrow so complete! + +Morning, dismal and wet, at length dawned and discovered to my sleepless and aching eyes the church of Ingolstadt, its white steeple and clock, which indicated the sixth hour. The porter opened the gates of the court, which had that night been my asylum, and I issued into the streets, pacing them with quick steps, as if I sought to avoid the wretch whom I feared every turning of the street would present to my view. I did not dare return to the apartment which I inhabited, but felt impelled to hurry on, although drenched by the rain which poured from a black and comfortless sky. + +I continued walking in this manner for some time, endeavouring by bodily exercise to ease the load that weighed upon my mind. I traversed the streets without any clear conception of where I was or what I was doing. My heart palpitated in the sickness of fear, and I hurried on with irregular steps, not daring to look about me: + +Like one who, on a lonely road, +Doth walk in fear and dread, +And, having once turned round, walks on, +And turns no more his head; +Because he knows a frightful fiend +Doth close behind him tread. + +[Coleridge’s “Ancient Mariner.”] + +Continuing thus, I came at length opposite to the inn at which the various diligences and carriages usually stopped. Here I paused, I knew not why; but I remained some minutes with my eyes fixed on a coach that was coming towards me from the other end of the street. As it drew nearer I observed that it was the Swiss diligence; it stopped just where I was standing, and on the door being opened, I perceived Henry Clerval, who, on seeing me, instantly sprung out. “My dear Frankenstein,” exclaimed he, “how glad I am to see you! How fortunate that you should be here at the very moment of my alighting!” + +Nothing could equal my delight on seeing Clerval; his presence brought back to my thoughts my father, Elizabeth, and all those scenes of home so dear to my recollection. I grasped his hand, and in a moment forgot my horror and misfortune; I felt suddenly, and for the first time during many months, calm and serene joy. I welcomed my friend, therefore, in the most cordial manner, and we walked towards my college. Clerval continued talking for some time about our mutual friends and his own good fortune in being permitted to come to Ingolstadt. “You may easily believe,” said he, “how great was the difficulty to persuade my father that all necessary knowledge was not comprised in the noble art of book-keeping; and, indeed, I believe I left him incredulous to the last, for his constant answer to my unwearied entreaties was the same as that of the Dutch schoolmaster in The Vicar of Wakefield: ‘I have ten thousand florins a year without Greek, I eat heartily without Greek.’ But his affection for me at length overcame his dislike of learning, and he has permitted me to undertake a voyage of discovery to the land of knowledge.” + +“It gives me the greatest delight to see you; but tell me how you left my father, brothers, and Elizabeth.” + +“Very well, and very happy, only a little uneasy that they hear from you so seldom. By the by, I mean to lecture you a little upon their account myself. But, my dear Frankenstein,” continued he, stopping short and gazing full in my face, “I did not before remark how very ill you appear; so thin and pale; you look as if you had been watching for several nights.” + +“You have guessed right; I have lately been so deeply engaged in one occupation that I have not allowed myself sufficient rest, as you see; but I hope, I sincerely hope, that all these employments are now at an end and that I am at length free.” + +I trembled excessively; I could not endure to think of, and far less to allude to, the occurrences of the preceding night. I walked with a quick pace, and we soon arrived at my college. I then reflected, and the thought made me shiver, that the creature whom I had left in my apartment might still be there, alive and walking about. I dreaded to behold this monster, but I feared still more that Henry should see him. Entreating him, therefore, to remain a few minutes at the bottom of the stairs, I darted up towards my own room. My hand was already on the lock of the door before I recollected myself. I then paused, and a cold shivering came over me. I threw the door forcibly open, as children are accustomed to do when they expect a spectre to stand in waiting for them on the other side; but nothing appeared. I stepped fearfully in: the apartment was empty, and my bedroom was also freed from its hideous guest. I could hardly believe that so great a good fortune could have befallen me, but when I became assured that my enemy had indeed fled, I clapped my hands for joy and ran down to Clerval. + +We ascended into my room, and the servant presently brought breakfast; but I was unable to contain myself. It was not joy only that possessed me; I felt my flesh tingle with excess of sensitiveness, and my pulse beat rapidly. I was unable to remain for a single instant in the same place; I jumped over the chairs, clapped my hands, and laughed aloud. Clerval at first attributed my unusual spirits to joy on his arrival, but when he observed me more attentively, he saw a wildness in my eyes for which he could not account, and my loud, unrestrained, heartless laughter frightened and astonished him. + +“My dear Victor,” cried he, “what, for God’s sake, is the matter? Do not laugh in that manner. How ill you are! What is the cause of all this?” + +“Do not ask me,” cried I, putting my hands before my eyes, for I thought I saw the dreaded spectre glide into the room; “he can tell. Oh, save me! Save me!” I imagined that the monster seized me; I struggled furiously and fell down in a fit. + +Poor Clerval! What must have been his feelings? A meeting, which he anticipated with such joy, so strangely turned to bitterness. But I was not the witness of his grief, for I was lifeless and did not recover my senses for a long, long time. + +This was the commencement of a nervous fever which confined me for several months. During all that time Henry was my only nurse. I afterwards learned that, knowing my father’s advanced age and unfitness for so long a journey, and how wretched my sickness would make Elizabeth, he spared them this grief by concealing the extent of my disorder. He knew that I could not have a more kind and attentive nurse than himself; and, firm in the hope he felt of my recovery, he did not doubt that, instead of doing harm, he performed the kindest action that he could towards them. + +But I was in reality very ill, and surely nothing but the unbounded and unremitting attentions of my friend could have restored me to life. The form of the monster on whom I had bestowed existence was for ever before my eyes, and I raved incessantly concerning him. Doubtless my words surprised Henry; he at first believed them to be the wanderings of my disturbed imagination, but the pertinacity with which I continually recurred to the same subject persuaded him that my disorder indeed owed its origin to some uncommon and terrible event. + +By very slow degrees, and with frequent relapses that alarmed and grieved my friend, I recovered. I remember the first time I became capable of observing outward objects with any kind of pleasure, I perceived that the fallen leaves had disappeared and that the young buds were shooting forth from the trees that shaded my window. It was a divine spring, and the season contributed greatly to my convalescence. I felt also sentiments of joy and affection revive in my bosom; my gloom disappeared, and in a short time I became as cheerful as before I was attacked by the fatal passion. + +“Dearest Clerval,” exclaimed I, “how kind, how very good you are to me. This whole winter, instead of being spent in study, as you promised yourself, has been consumed in my sick room. How shall I ever repay you? I feel the greatest remorse for the disappointment of which I have been the occasion, but you will forgive me.” + +“You will repay me entirely if you do not discompose yourself, but get well as fast as you can; and since you appear in such good spirits, I may speak to you on one subject, may I not?” + +I trembled. One subject! What could it be? Could he allude to an object on whom I dared not even think? + +“Compose yourself,” said Clerval, who observed my change of colour, “I will not mention it if it agitates you; but your father and cousin would be very happy if they received a letter from you in your own handwriting. They hardly know how ill you have been and are uneasy at your long silence.” + +“Is that all, my dear Henry? How could you suppose that my first thought would not fly towards those dear, dear friends whom I love and who are so deserving of my love?” + +“If this is your present temper, my friend, you will perhaps be glad to see a letter that has been lying here some days for you; it is from your cousin, I believe.” +Chapter 6 + +Clerval then put the following letter into my hands. It was from my own Elizabeth: + +“My dearest Cousin, + +“You have been ill, very ill, and even the constant letters of dear kind Henry are not sufficient to reassure me on your account. You are forbidden to write—to hold a pen; yet one word from you, dear Victor, is necessary to calm our apprehensions. For a long time I have thought that each post would bring this line, and my persuasions have restrained my uncle from undertaking a journey to Ingolstadt. I have prevented his encountering the inconveniences and perhaps dangers of so long a journey, yet how often have I regretted not being able to perform it myself! I figure to myself that the task of attending on your sickbed has devolved on some mercenary old nurse, who could never guess your wishes nor minister to them with the care and affection of your poor cousin. Yet that is over now: Clerval writes that indeed you are getting better. I eagerly hope that you will confirm this intelligence soon in your own handwriting. + +“Get well—and return to us. You will find a happy, cheerful home and friends who love you dearly. Your father’s health is vigorous, and he asks but to see you, but to be assured that you are well; and not a care will ever cloud his benevolent countenance. How pleased you would be to remark the improvement of our Ernest! He is now sixteen and full of activity and spirit. He is desirous to be a true Swiss and to enter into foreign service, but we cannot part with him, at least until his elder brother returns to us. My uncle is not pleased with the idea of a military career in a distant country, but Ernest never had your powers of application. He looks upon study as an odious fetter; his time is spent in the open air, climbing the hills or rowing on the lake. I fear that he will become an idler unless we yield the point and permit him to enter on the profession which he has selected. + +“Little alteration, except the growth of our dear children, has taken place since you left us. The blue lake and snow-clad mountains—they never change; and I think our placid home and our contented hearts are regulated by the same immutable laws. My trifling occupations take up my time and amuse me, and I am rewarded for any exertions by seeing none but happy, kind faces around me. Since you left us, but one change has taken place in our little household. Do you remember on what occasion Justine Moritz entered our family? Probably you do not; I will relate her history, therefore in a few words. Madame Moritz, her mother, was a widow with four children, of whom Justine was the third. This girl had always been the favourite of her father, but through a strange perversity, her mother could not endure her, and after the death of M. Moritz, treated her very ill. My aunt observed this, and when Justine was twelve years of age, prevailed on her mother to allow her to live at our house. The republican institutions of our country have produced simpler and happier manners than those which prevail in the great monarchies that surround it. Hence there is less distinction between the several classes of its inhabitants; and the lower orders, being neither so poor nor so despised, their manners are more refined and moral. A servant in Geneva does not mean the same thing as a servant in France and England. Justine, thus received in our family, learned the duties of a servant, a condition which, in our fortunate country, does not include the idea of ignorance and a sacrifice of the dignity of a human being. + +“Justine, you may remember, was a great favourite of yours; and I recollect you once remarked that if you were in an ill humour, one glance from Justine could dissipate it, for the same reason that Ariosto gives concerning the beauty of Angelica—she looked so frank-hearted and happy. My aunt conceived a great attachment for her, by which she was induced to give her an education superior to that which she had at first intended. This benefit was fully repaid; Justine was the most grateful little creature in the world: I do not mean that she made any professions I never heard one pass her lips, but you could see by her eyes that she almost adored her protectress. Although her disposition was gay and in many respects inconsiderate, yet she paid the greatest attention to every gesture of my aunt. She thought her the model of all excellence and endeavoured to imitate her phraseology and manners, so that even now she often reminds me of her. + +“When my dearest aunt died every one was too much occupied in their own grief to notice poor Justine, who had attended her during her illness with the most anxious affection. Poor Justine was very ill; but other trials were reserved for her. + +“One by one, her brothers and sister died; and her mother, with the exception of her neglected daughter, was left childless. The conscience of the woman was troubled; she began to think that the deaths of her favourites was a judgement from heaven to chastise her partiality. She was a Roman Catholic; and I believe her confessor confirmed the idea which she had conceived. Accordingly, a few months after your departure for Ingolstadt, Justine was called home by her repentant mother. Poor girl! She wept when she quitted our house; she was much altered since the death of my aunt; grief had given softness and a winning mildness to her manners, which had before been remarkable for vivacity. Nor was her residence at her mother’s house of a nature to restore her gaiety. The poor woman was very vacillating in her repentance. She sometimes begged Justine to forgive her unkindness, but much oftener accused her of having caused the deaths of her brothers and sister. Perpetual fretting at length threw Madame Moritz into a decline, which at first increased her irritability, but she is now at peace for ever. She died on the first approach of cold weather, at the beginning of this last winter. Justine has just returned to us; and I assure you I love her tenderly. She is very clever and gentle, and extremely pretty; as I mentioned before, her mien and her expression continually remind me of my dear aunt. + +“I must say also a few words to you, my dear cousin, of little darling William. I wish you could see him; he is very tall of his age, with sweet laughing blue eyes, dark eyelashes, and curling hair. When he smiles, two little dimples appear on each cheek, which are rosy with health. He has already had one or two little wives, but Louisa Biron is his favourite, a pretty little girl of five years of age. + +“Now, dear Victor, I dare say you wish to be indulged in a little gossip concerning the good people of Geneva. The pretty Miss Mansfield has already received the congratulatory visits on her approaching marriage with a young Englishman, John Melbourne, Esq. Her ugly sister, Manon, married M. Duvillard, the rich banker, last autumn. Your favourite schoolfellow, Louis Manoir, has suffered several misfortunes since the departure of Clerval from Geneva. But he has already recovered his spirits, and is reported to be on the point of marrying a lively pretty Frenchwoman, Madame Tavernier. She is a widow, and much older than Manoir; but she is very much admired, and a favourite with everybody. + +“I have written myself into better spirits, dear cousin; but my anxiety returns upon me as I conclude. Write, dearest Victor,—one line—one word will be a blessing to us. Ten thousand thanks to Henry for his kindness, his affection, and his many letters; we are sincerely grateful. Adieu! my cousin; take care of yourself; and, I entreat you, write! + +“Elizabeth Lavenza. + +“Geneva, March 18th, 17—.” + +“Dear, dear Elizabeth!” I exclaimed, when I had read her letter: “I will write instantly and relieve them from the anxiety they must feel.” I wrote, and this exertion greatly fatigued me; but my convalescence had commenced, and proceeded regularly. In another fortnight I was able to leave my chamber. + +One of my first duties on my recovery was to introduce Clerval to the several professors of the university. In doing this, I underwent a kind of rough usage, ill befitting the wounds that my mind had sustained. Ever since the fatal night, the end of my labours, and the beginning of my misfortunes, I had conceived a violent antipathy even to the name of natural philosophy. When I was otherwise quite restored to health, the sight of a chemical instrument would renew all the agony of my nervous symptoms. Henry saw this, and had removed all my apparatus from my view. He had also changed my apartment; for he perceived that I had acquired a dislike for the room which had previously been my laboratory. But these cares of Clerval were made of no avail when I visited the professors. M. Waldman inflicted torture when he praised, with kindness and warmth, the astonishing progress I had made in the sciences. He soon perceived that I disliked the subject; but not guessing the real cause, he attributed my feelings to modesty, and changed the subject from my improvement, to the science itself, with a desire, as I evidently saw, of drawing me out. What could I do? He meant to please, and he tormented me. I felt as if he had placed carefully, one by one, in my view those instruments which were to be afterwards used in putting me to a slow and cruel death. I writhed under his words, yet dared not exhibit the pain I felt. Clerval, whose eyes and feelings were always quick in discerning the sensations of others, declined the subject, alleging, in excuse, his total ignorance; and the conversation took a more general turn. I thanked my friend from my heart, but I did not speak. I saw plainly that he was surprised, but he never attempted to draw my secret from me; and although I loved him with a mixture of affection and reverence that knew no bounds, yet I could never persuade myself to confide in him that event which was so often present to my recollection, but which I feared the detail to another would only impress more deeply. + +M. Krempe was not equally docile; and in my condition at that time, of almost insupportable sensitiveness, his harsh blunt encomiums gave me even more pain than the benevolent approbation of M. Waldman. “D—n the fellow!” cried he; “why, M. Clerval, I assure you he has outstript us all. Ay, stare if you please; but it is nevertheless true. A youngster who, but a few years ago, believed in Cornelius Agrippa as firmly as in the gospel, has now set himself at the head of the university; and if he is not soon pulled down, we shall all be out of countenance.—Ay, ay,” continued he, observing my face expressive of suffering, “M. Frankenstein is modest; an excellent quality in a young man. Young men should be diffident of themselves, you know, M. Clerval: I was myself when young; but that wears out in a very short time.” + +M. Krempe had now commenced an eulogy on himself, which happily turned the conversation from a subject that was so annoying to me. + +Clerval had never sympathised in my tastes for natural science; and his literary pursuits differed wholly from those which had occupied me. He came to the university with the design of making himself complete master of the oriental languages, and thus he should open a field for the plan of life he had marked out for himself. Resolved to pursue no inglorious career, he turned his eyes toward the East, as affording scope for his spirit of enterprise. The Persian, Arabic, and Sanskrit languages engaged his attention, and I was easily induced to enter on the same studies. Idleness had ever been irksome to me, and now that I wished to fly from reflection, and hated my former studies, I felt great relief in being the fellow-pupil with my friend, and found not only instruction but consolation in the works of the orientalists. I did not, like him, attempt a critical knowledge of their dialects, for I did not contemplate making any other use of them than temporary amusement. I read merely to understand their meaning, and they well repaid my labours. Their melancholy is soothing, and their joy elevating, to a degree I never experienced in studying the authors of any other country. When you read their writings, life appears to consist in a warm sun and a garden of roses,—in the smiles and frowns of a fair enemy, and the fire that consumes your own heart. How different from the manly and heroical poetry of Greece and Rome! + +Summer passed away in these occupations, and my return to Geneva was fixed for the latter end of autumn; but being delayed by several accidents, winter and snow arrived, the roads were deemed impassable, and my journey was retarded until the ensuing spring. I felt this delay very bitterly; for I longed to see my native town and my beloved friends. My return had only been delayed so long, from an unwillingness to leave Clerval in a strange place, before he had become acquainted with any of its inhabitants. The winter, however, was spent cheerfully; and although the spring was uncommonly late, when it came its beauty compensated for its dilatoriness. + +The month of May had already commenced, and I expected the letter daily which was to fix the date of my departure, when Henry proposed a pedestrian tour in the environs of Ingolstadt, that I might bid a personal farewell to the country I had so long inhabited. I acceded with pleasure to this proposition: I was fond of exercise, and Clerval had always been my favourite companion in the ramble of this nature that I had taken among the scenes of my native country. + +We passed a fortnight in these perambulations: my health and spirits had long been restored, and they gained additional strength from the salubrious air I breathed, the natural incidents of our progress, and the conversation of my friend. Study had before secluded me from the intercourse of my fellow-creatures, and rendered me unsocial; but Clerval called forth the better feelings of my heart; he again taught me to love the aspect of nature, and the cheerful faces of children. Excellent friend! how sincerely you did love me, and endeavour to elevate my mind until it was on a level with your own. A selfish pursuit had cramped and narrowed me, until your gentleness and affection warmed and opened my senses; I became the same happy creature who, a few years ago, loved and beloved by all, had no sorrow or care. When happy, inanimate nature had the power of bestowing on me the most delightful sensations. A serene sky and verdant fields filled me with ecstasy. The present season was indeed divine; the flowers of spring bloomed in the hedges, while those of summer were already in bud. I was undisturbed by thoughts which during the preceding year had pressed upon me, notwithstanding my endeavours to throw them off, with an invincible burden. + +Henry rejoiced in my gaiety, and sincerely sympathised in my feelings: he exerted himself to amuse me, while he expressed the sensations that filled his soul. The resources of his mind on this occasion were truly astonishing: his conversation was full of imagination; and very often, in imitation of the Persian and Arabic writers, he invented tales of wonderful fancy and passion. At other times he repeated my favourite poems, or drew me out into arguments, which he supported with great ingenuity. + +We returned to our college on a Sunday afternoon: the peasants were dancing, and every one we met appeared gay and happy. My own spirits were high, and I bounded along with feelings of unbridled joy and hilarity. +Chapter 7 + +On my return, I found the following letter from my father:— + +“My dear Victor, + +“You have probably waited impatiently for a letter to fix the date of your return to us; and I was at first tempted to write only a few lines, merely mentioning the day on which I should expect you. But that would be a cruel kindness, and I dare not do it. What would be your surprise, my son, when you expected a happy and glad welcome, to behold, on the contrary, tears and wretchedness? And how, Victor, can I relate our misfortune? Absence cannot have rendered you callous to our joys and griefs; and how shall I inflict pain on my long absent son? I wish to prepare you for the woeful news, but I know it is impossible; even now your eye skims over the page to seek the words which are to convey to you the horrible tidings. + +“William is dead!—that sweet child, whose smiles delighted and warmed my heart, who was so gentle, yet so gay! Victor, he is murdered! + +“I will not attempt to console you; but will simply relate the circumstances of the transaction. + +“Last Thursday (May 7th), I, my niece, and your two brothers, went to walk in Plainpalais. The evening was warm and serene, and we prolonged our walk farther than usual. It was already dusk before we thought of returning; and then we discovered that William and Ernest, who had gone on before, were not to be found. We accordingly rested on a seat until they should return. Presently Ernest came, and enquired if we had seen his brother; he said, that he had been playing with him, that William had run away to hide himself, and that he vainly sought for him, and afterwards waited for a long time, but that he did not return. + +“This account rather alarmed us, and we continued to search for him until night fell, when Elizabeth conjectured that he might have returned to the house. He was not there. We returned again, with torches; for I could not rest, when I thought that my sweet boy had lost himself, and was exposed to all the damps and dews of night; Elizabeth also suffered extreme anguish. About five in the morning I discovered my lovely boy, whom the night before I had seen blooming and active in health, stretched on the grass livid and motionless; the print of the murder’s finger was on his neck. + +“He was conveyed home, and the anguish that was visible in my countenance betrayed the secret to Elizabeth. She was very earnest to see the corpse. At first I attempted to prevent her but she persisted, and entering the room where it lay, hastily examined the neck of the victim, and clasping her hands exclaimed, ‘O God! I have murdered my darling child!’ + +“She fainted, and was restored with extreme difficulty. When she again lived, it was only to weep and sigh. She told me, that that same evening William had teased her to let him wear a very valuable miniature that she possessed of your mother. This picture is gone, and was doubtless the temptation which urged the murderer to the deed. We have no trace of him at present, although our exertions to discover him are unremitted; but they will not restore my beloved William! + +“Come, dearest Victor; you alone can console Elizabeth. She weeps continually, and accuses herself unjustly as the cause of his death; her words pierce my heart. We are all unhappy; but will not that be an additional motive for you, my son, to return and be our comforter? Your dear mother! Alas, Victor! I now say, Thank God she did not live to witness the cruel, miserable death of her youngest darling! + +“Come, Victor; not brooding thoughts of vengeance against the assassin, but with feelings of peace and gentleness, that will heal, instead of festering, the wounds of our minds. Enter the house of mourning, my friend, but with kindness and affection for those who love you, and not with hatred for your enemies. + +“Your affectionate and afflicted father, +“Alphonse Frankenstein. + +“Geneva, May 12th, 17—.” + +Clerval, who had watched my countenance as I read this letter, was surprised to observe the despair that succeeded the joy I at first expressed on receiving new from my friends. I threw the letter on the table, and covered my face with my hands. + +“My dear Frankenstein,” exclaimed Henry, when he perceived me weep with bitterness, “are you always to be unhappy? My dear friend, what has happened?” + +I motioned him to take up the letter, while I walked up and down the room in the extremest agitation. Tears also gushed from the eyes of Clerval, as he read the account of my misfortune. + +“I can offer you no consolation, my friend,” said he; “your disaster is irreparable. What do you intend to do?” + +“To go instantly to Geneva: come with me, Henry, to order the horses.” + +During our walk, Clerval endeavoured to say a few words of consolation; he could only express his heartfelt sympathy. “Poor William!” said he, “dear lovely child, he now sleeps with his angel mother! Who that had seen him bright and joyous in his young beauty, but must weep over his untimely loss! To die so miserably; to feel the murderer’s grasp! How much more a murdered that could destroy radiant innocence! Poor little fellow! one only consolation have we; his friends mourn and weep, but he is at rest. The pang is over, his sufferings are at an end for ever. A sod covers his gentle form, and he knows no pain. He can no longer be a subject for pity; we must reserve that for his miserable survivors.” + +Clerval spoke thus as we hurried through the streets; the words impressed themselves on my mind and I remembered them afterwards in solitude. But now, as soon as the horses arrived, I hurried into a cabriolet, and bade farewell to my friend. + +My journey was very melancholy. At first I wished to hurry on, for I longed to console and sympathise with my loved and sorrowing friends; but when I drew near my native town, I slackened my progress. I could hardly sustain the multitude of feelings that crowded into my mind. I passed through scenes familiar to my youth, but which I had not seen for nearly six years. How altered every thing might be during that time! One sudden and desolating change had taken place; but a thousand little circumstances might have by degrees worked other alterations, which, although they were done more tranquilly, might not be the less decisive. Fear overcame me; I dared no advance, dreading a thousand nameless evils that made me tremble, although I was unable to define them. + +I remained two days at Lausanne, in this painful state of mind. I contemplated the lake: the waters were placid; all around was calm; and the snowy mountains, “the palaces of nature,” were not changed. By degrees the calm and heavenly scene restored me, and I continued my journey towards Geneva. + +The road ran by the side of the lake, which became narrower as I approached my native town. I discovered more distinctly the black sides of Jura, and the bright summit of Mont Blanc. I wept like a child. “Dear mountains! my own beautiful lake! how do you welcome your wanderer? Your summits are clear; the sky and lake are blue and placid. Is this to prognosticate peace, or to mock at my unhappiness?” + +I fear, my friend, that I shall render myself tedious by dwelling on these preliminary circumstances; but they were days of comparative happiness, and I think of them with pleasure. My country, my beloved country! who but a native can tell the delight I took in again beholding thy streams, thy mountains, and, more than all, thy lovely lake! + +Yet, as I drew nearer home, grief and fear again overcame me. Night also closed around; and when I could hardly see the dark mountains, I felt still more gloomily. The picture appeared a vast and dim scene of evil, and I foresaw obscurely that I was destined to become the most wretched of human beings. Alas! I prophesied truly, and failed only in one single circumstance, that in all the misery I imagined and dreaded, I did not conceive the hundredth part of the anguish I was destined to endure. + +It was completely dark when I arrived in the environs of Geneva; the gates of the town were already shut; and I was obliged to pass the night at Secheron, a village at the distance of half a league from the city. The sky was serene; and, as I was unable to rest, I resolved to visit the spot where my poor William had been murdered. As I could not pass through the town, I was obliged to cross the lake in a boat to arrive at Plainpalais. During this short voyage I saw the lightning playing on the summit of Mont Blanc in the most beautiful figures. The storm appeared to approach rapidly, and, on landing, I ascended a low hill, that I might observe its progress. It advanced; the heavens were clouded, and I soon felt the rain coming slowly in large drops, but its violence quickly increased. + +I quitted my seat, and walked on, although the darkness and storm increased every minute, and the thunder burst with a terrific crash over my head. It was echoed from Salêve, the Juras, and the Alps of Savoy; vivid flashes of lightning dazzled my eyes, illuminating the lake, making it appear like a vast sheet of fire; then for an instant every thing seemed of a pitchy darkness, until the eye recovered itself from the preceding flash. The storm, as is often the case in Switzerland, appeared at once in various parts of the heavens. The most violent storm hung exactly north of the town, over the part of the lake which lies between the promontory of Belrive and the village of Copêt. Another storm enlightened Jura with faint flashes; and another darkened and sometimes disclosed the Môle, a peaked mountain to the east of the lake. + +While I watched the tempest, so beautiful yet terrific, I wandered on with a hasty step. This noble war in the sky elevated my spirits; I clasped my hands, and exclaimed aloud, “William, dear angel! this is thy funeral, this thy dirge!” As I said these words, I perceived in the gloom a figure which stole from behind a clump of trees near me; I stood fixed, gazing intently: I could not be mistaken. A flash of lightning illuminated the object, and discovered its shape plainly to me; its gigantic stature, and the deformity of its aspect more hideous than belongs to humanity, instantly informed me that it was the wretch, the filthy dæmon, to whom I had given life. What did he there? Could he be (I shuddered at the conception) the murderer of my brother? No sooner did that idea cross my imagination, than I became convinced of its truth; my teeth chattered, and I was forced to lean against a tree for support. The figure passed me quickly, and I lost it in the gloom. Nothing in human shape could have destroyed the fair child. He was the murderer! I could not doubt it. The mere presence of the idea was an irresistible proof of the fact. I thought of pursuing the devil; but it would have been in vain, for another flash discovered him to me hanging among the rocks of the nearly perpendicular ascent of Mont Salêve, a hill that bounds Plainpalais on the south. He soon reached the summit, and disappeared. + +I remained motionless. The thunder ceased; but the rain still continued, and the scene was enveloped in an impenetrable darkness. I revolved in my mind the events which I had until now sought to forget: the whole train of my progress toward the creation; the appearance of the works of my own hands at my bedside; its departure. Two years had now nearly elapsed since the night on which he first received life; and was this his first crime? Alas! I had turned loose into the world a depraved wretch, whose delight was in carnage and misery; had he not murdered my brother? + +No one can conceive the anguish I suffered during the remainder of the night, which I spent, cold and wet, in the open air. But I did not feel the inconvenience of the weather; my imagination was busy in scenes of evil and despair. I considered the being whom I had cast among mankind, and endowed with the will and power to effect purposes of horror, such as the deed which he had now done, nearly in the light of my own vampire, my own spirit let loose from the grave, and forced to destroy all that was dear to me. + +Day dawned; and I directed my steps towards the town. The gates were open, and I hastened to my father’s house. My first thought was to discover what I knew of the murderer, and cause instant pursuit to be made. But I paused when I reflected on the story that I had to tell. A being whom I myself had formed, and endued with life, had met me at midnight among the precipices of an inaccessible mountain. I remembered also the nervous fever with which I had been seized just at the time that I dated my creation, and which would give an air of delirium to a tale otherwise so utterly improbable. I well knew that if any other had communicated such a relation to me, I should have looked upon it as the ravings of insanity. Besides, the strange nature of the animal would elude all pursuit, even if I were so far credited as to persuade my relatives to commence it. And then of what use would be pursuit? Who could arrest a creature capable of scaling the overhanging sides of Mont Salêve? These reflections determined me, and I resolved to remain silent. + +It was about five in the morning when I entered my father’s house. I told the servants not to disturb the family, and went into the library to attend their usual hour of rising. + +Six years had elapsed, passed in a dream but for one indelible trace, and I stood in the same place where I had last embraced my father before my departure for Ingolstadt. Beloved and venerable parent! He still remained to me. I gazed on the picture of my mother, which stood over the mantel-piece. It was an historical subject, painted at my father’s desire, and represented Caroline Beaufort in an agony of despair, kneeling by the coffin of her dead father. Her garb was rustic, and her cheek pale; but there was an air of dignity and beauty, that hardly permitted the sentiment of pity. Below this picture was a miniature of William; and my tears flowed when I looked upon it. While I was thus engaged, Ernest entered: he had heard me arrive, and hastened to welcome me: “Welcome, my dearest Victor,” said he. “Ah! I wish you had come three months ago, and then you would have found us all joyous and delighted. You come to us now to share a misery which nothing can alleviate; yet your presence will, I hope, revive our father, who seems sinking under his misfortune; and your persuasions will induce poor Elizabeth to cease her vain and tormenting self-accusations.—Poor William! he was our darling and our pride!” + +Tears, unrestrained, fell from my brother’s eyes; a sense of mortal agony crept over my frame. Before, I had only imagined the wretchedness of my desolated home; the reality came on me as a new, and a not less terrible, disaster. I tried to calm Ernest; I enquired more minutely concerning my father, and here I named my cousin. + +“She most of all,” said Ernest, “requires consolation; she accused herself of having caused the death of my brother, and that made her very wretched. But since the murderer has been discovered—” + +“The murderer discovered! Good God! how can that be? who could attempt to pursue him? It is impossible; one might as well try to overtake the winds, or confine a mountain-stream with a straw. I saw him too; he was free last night!” + +“I do not know what you mean,” replied my brother, in accents of wonder, “but to us the discovery we have made completes our misery. No one would believe it at first; and even now Elizabeth will not be convinced, notwithstanding all the evidence. Indeed, who would credit that Justine Moritz, who was so amiable, and fond of all the family, could suddenly become so capable of so frightful, so appalling a crime?” + +“Justine Moritz! Poor, poor girl, is she the accused? But it is wrongfully; every one knows that; no one believes it, surely, Ernest?” + +“No one did at first; but several circumstances came out, that have almost forced conviction upon us; and her own behaviour has been so confused, as to add to the evidence of facts a weight that, I fear, leaves no hope for doubt. But she will be tried today, and you will then hear all.” + +He then related that, the morning on which the murder of poor William had been discovered, Justine had been taken ill, and confined to her bed for several days. During this interval, one of the servants, happening to examine the apparel she had worn on the night of the murder, had discovered in her pocket the picture of my mother, which had been judged to be the temptation of the murderer. The servant instantly showed it to one of the others, who, without saying a word to any of the family, went to a magistrate; and, upon their deposition, Justine was apprehended. On being charged with the fact, the poor girl confirmed the suspicion in a great measure by her extreme confusion of manner. + +This was a strange tale, but it did not shake my faith; and I replied earnestly, “You are all mistaken; I know the murderer. Justine, poor, good Justine, is innocent.” + +At that instant my father entered. I saw unhappiness deeply impressed on his countenance, but he endeavoured to welcome me cheerfully; and, after we had exchanged our mournful greeting, would have introduced some other topic than that of our disaster, had not Ernest exclaimed, “Good God, papa! Victor says that he knows who was the murderer of poor William.” + +“We do also, unfortunately,” replied my father, “for indeed I had rather have been for ever ignorant than have discovered so much depravity and ungratitude in one I valued so highly.” + +“My dear father, you are mistaken; Justine is innocent.” + +“If she is, God forbid that she should suffer as guilty. She is to be tried today, and I hope, I sincerely hope, that she will be acquitted.” + +This speech calmed me. I was firmly convinced in my own mind that Justine, and indeed every human being, was guiltless of this murder. I had no fear, therefore, that any circumstantial evidence could be brought forward strong enough to convict her. My tale was not one to announce publicly; its astounding horror would be looked upon as madness by the vulgar. Did any one indeed exist, except I, the creator, who would believe, unless his senses convinced him, in the existence of the living monument of presumption and rash ignorance which I had let loose upon the world? + +We were soon joined by Elizabeth. Time had altered her since I last beheld her; it had endowed her with loveliness surpassing the beauty of her childish years. There was the same candour, the same vivacity, but it was allied to an expression more full of sensibility and intellect. She welcomed me with the greatest affection. “Your arrival, my dear cousin,” said she, “fills me with hope. You perhaps will find some means to justify my poor guiltless Justine. Alas! who is safe, if she be convicted of crime? I rely on her innocence as certainly as I do upon my own. Our misfortune is doubly hard to us; we have not only lost that lovely darling boy, but this poor girl, whom I sincerely love, is to be torn away by even a worse fate. If she is condemned, I never shall know joy more. But she will not, I am sure she will not; and then I shall be happy again, even after the sad death of my little William.” + +“She is innocent, my Elizabeth,” said I, “and that shall be proved; fear nothing, but let your spirits be cheered by the assurance of her acquittal.” + +“How kind and generous you are! every one else believes in her guilt, and that made me wretched, for I knew that it was impossible: and to see every one else prejudiced in so deadly a manner rendered me hopeless and despairing.” She wept. + +“Dearest niece,” said my father, “dry your tears. If she is, as you believe, innocent, rely on the justice of our laws, and the activity with which I shall prevent the slightest shadow of partiality.” +Chapter 8 + +We passed a few sad hours until eleven o’clock, when the trial was to commence. My father and the rest of the family being obliged to attend as witnesses, I accompanied them to the court. During the whole of this wretched mockery of justice I suffered living torture. It was to be decided whether the result of my curiosity and lawless devices would cause the death of two of my fellow beings: one a smiling babe full of innocence and joy, the other far more dreadfully murdered, with every aggravation of infamy that could make the murder memorable in horror. Justine also was a girl of merit and possessed qualities which promised to render her life happy; now all was to be obliterated in an ignominious grave, and I the cause! A thousand times rather would I have confessed myself guilty of the crime ascribed to Justine, but I was absent when it was committed, and such a declaration would have been considered as the ravings of a madman and would not have exculpated her who suffered through me. + +The appearance of Justine was calm. She was dressed in mourning, and her countenance, always engaging, was rendered, by the solemnity of her feelings, exquisitely beautiful. Yet she appeared confident in innocence and did not tremble, although gazed on and execrated by thousands, for all the kindness which her beauty might otherwise have excited was obliterated in the minds of the spectators by the imagination of the enormity she was supposed to have committed. She was tranquil, yet her tranquillity was evidently constrained; and as her confusion had before been adduced as a proof of her guilt, she worked up her mind to an appearance of courage. When she entered the court she threw her eyes round it and quickly discovered where we were seated. A tear seemed to dim her eye when she saw us, but she quickly recovered herself, and a look of sorrowful affection seemed to attest her utter guiltlessness. + +The trial began, and after the advocate against her had stated the charge, several witnesses were called. Several strange facts combined against her, which might have staggered anyone who had not such proof of her innocence as I had. She had been out the whole of the night on which the murder had been committed and towards morning had been perceived by a market-woman not far from the spot where the body of the murdered child had been afterwards found. The woman asked her what she did there, but she looked very strangely and only returned a confused and unintelligible answer. She returned to the house about eight o’clock, and when one inquired where she had passed the night, she replied that she had been looking for the child and demanded earnestly if anything had been heard concerning him. When shown the body, she fell into violent hysterics and kept her bed for several days. The picture was then produced which the servant had found in her pocket; and when Elizabeth, in a faltering voice, proved that it was the same which, an hour before the child had been missed, she had placed round his neck, a murmur of horror and indignation filled the court. + +Justine was called on for her defence. As the trial had proceeded, her countenance had altered. Surprise, horror, and misery were strongly expressed. Sometimes she struggled with her tears, but when she was desired to plead, she collected her powers and spoke in an audible although variable voice. + +“God knows,” she said, “how entirely I am innocent. But I do not pretend that my protestations should acquit me; I rest my innocence on a plain and simple explanation of the facts which have been adduced against me, and I hope the character I have always borne will incline my judges to a favourable interpretation where any circumstance appears doubtful or suspicious.” + +She then related that, by the permission of Elizabeth, she had passed the evening of the night on which the murder had been committed at the house of an aunt at Chêne, a village situated at about a league from Geneva. On her return, at about nine o’clock, she met a man who asked her if she had seen anything of the child who was lost. She was alarmed by this account and passed several hours in looking for him, when the gates of Geneva were shut, and she was forced to remain several hours of the night in a barn belonging to a cottage, being unwilling to call up the inhabitants, to whom she was well known. Most of the night she spent here watching; towards morning she believed that she slept for a few minutes; some steps disturbed her, and she awoke. It was dawn, and she quitted her asylum, that she might again endeavour to find my brother. If she had gone near the spot where his body lay, it was without her knowledge. That she had been bewildered when questioned by the market-woman was not surprising, since she had passed a sleepless night and the fate of poor William was yet uncertain. Concerning the picture she could give no account. + +“I know,” continued the unhappy victim, “how heavily and fatally this one circumstance weighs against me, but I have no power of explaining it; and when I have expressed my utter ignorance, I am only left to conjecture concerning the probabilities by which it might have been placed in my pocket. But here also I am checked. I believe that I have no enemy on earth, and none surely would have been so wicked as to destroy me wantonly. Did the murderer place it there? I know of no opportunity afforded him for so doing; or, if I had, why should he have stolen the jewel, to part with it again so soon? + +“I commit my cause to the justice of my judges, yet I see no room for hope. I beg permission to have a few witnesses examined concerning my character, and if their testimony shall not overweigh my supposed guilt, I must be condemned, although I would pledge my salvation on my innocence.” + +Several witnesses were called who had known her for many years, and they spoke well of her; but fear and hatred of the crime of which they supposed her guilty rendered them timorous and unwilling to come forward. Elizabeth saw even this last resource, her excellent dispositions and irreproachable conduct, about to fail the accused, when, although violently agitated, she desired permission to address the court. + +“I am,” said she, “the cousin of the unhappy child who was murdered, or rather his sister, for I was educated by and have lived with his parents ever since and even long before his birth. It may therefore be judged indecent in me to come forward on this occasion, but when I see a fellow creature about to perish through the cowardice of her pretended friends, I wish to be allowed to speak, that I may say what I know of her character. I am well acquainted with the accused. I have lived in the same house with her, at one time for five and at another for nearly two years. During all that period she appeared to me the most amiable and benevolent of human creatures. She nursed Madame Frankenstein, my aunt, in her last illness, with the greatest affection and care and afterwards attended her own mother during a tedious illness, in a manner that excited the admiration of all who knew her, after which she again lived in my uncle’s house, where she was beloved by all the family. She was warmly attached to the child who is now dead and acted towards him like a most affectionate mother. For my own part, I do not hesitate to say that, notwithstanding all the evidence produced against her, I believe and rely on her perfect innocence. She had no temptation for such an action; as to the bauble on which the chief proof rests, if she had earnestly desired it, I should have willingly given it to her, so much do I esteem and value her.” + +A murmur of approbation followed Elizabeth’s simple and powerful appeal, but it was excited by her generous interference, and not in favour of poor Justine, on whom the public indignation was turned with renewed violence, charging her with the blackest ingratitude. She herself wept as Elizabeth spoke, but she did not answer. My own agitation and anguish was extreme during the whole trial. I believed in her innocence; I knew it. Could the dæmon who had (I did not for a minute doubt) murdered my brother also in his hellish sport have betrayed the innocent to death and ignominy? I could not sustain the horror of my situation, and when I perceived that the popular voice and the countenances of the judges had already condemned my unhappy victim, I rushed out of the court in agony. The tortures of the accused did not equal mine; she was sustained by innocence, but the fangs of remorse tore my bosom and would not forgo their hold. + +I passed a night of unmingled wretchedness. In the morning I went to the court; my lips and throat were parched. I dared not ask the fatal question, but I was known, and the officer guessed the cause of my visit. The ballots had been thrown; they were all black, and Justine was condemned. + +I cannot pretend to describe what I then felt. I had before experienced sensations of horror, and I have endeavoured to bestow upon them adequate expressions, but words cannot convey an idea of the heart-sickening despair that I then endured. The person to whom I addressed myself added that Justine had already confessed her guilt. “That evidence,” he observed, “was hardly required in so glaring a case, but I am glad of it, and, indeed, none of our judges like to condemn a criminal upon circumstantial evidence, be it ever so decisive.” + +This was strange and unexpected intelligence; what could it mean? Had my eyes deceived me? And was I really as mad as the whole world would believe me to be if I disclosed the object of my suspicions? I hastened to return home, and Elizabeth eagerly demanded the result. + +“My cousin,” replied I, “it is decided as you may have expected; all judges had rather that ten innocent should suffer than that one guilty should escape. But she has confessed.” + +This was a dire blow to poor Elizabeth, who had relied with firmness upon Justine’s innocence. “Alas!” said she. “How shall I ever again believe in human goodness? Justine, whom I loved and esteemed as my sister, how could she put on those smiles of innocence only to betray? Her mild eyes seemed incapable of any severity or guile, and yet she has committed a murder.” + +Soon after we heard that the poor victim had expressed a desire to see my cousin. My father wished her not to go but said that he left it to her own judgment and feelings to decide. “Yes,” said Elizabeth, “I will go, although she is guilty; and you, Victor, shall accompany me; I cannot go alone.” The idea of this visit was torture to me, yet I could not refuse. + +We entered the gloomy prison chamber and beheld Justine sitting on some straw at the farther end; her hands were manacled, and her head rested on her knees. She rose on seeing us enter, and when we were left alone with her, she threw herself at the feet of Elizabeth, weeping bitterly. My cousin wept also. + +“Oh, Justine!” said she. “Why did you rob me of my last consolation? I relied on your innocence, and although I was then very wretched, I was not so miserable as I am now.” + +“And do you also believe that I am so very, very wicked? Do you also join with my enemies to crush me, to condemn me as a murderer?” Her voice was suffocated with sobs. + +“Rise, my poor girl,” said Elizabeth; “why do you kneel, if you are innocent? I am not one of your enemies, I believed you guiltless, notwithstanding every evidence, until I heard that you had yourself declared your guilt. That report, you say, is false; and be assured, dear Justine, that nothing can shake my confidence in you for a moment, but your own confession.” + +“I did confess, but I confessed a lie. I confessed, that I might obtain absolution; but now that falsehood lies heavier at my heart than all my other sins. The God of heaven forgive me! Ever since I was condemned, my confessor has besieged me; he threatened and menaced, until I almost began to think that I was the monster that he said I was. He threatened excommunication and hell fire in my last moments if I continued obdurate. Dear lady, I had none to support me; all looked on me as a wretch doomed to ignominy and perdition. What could I do? In an evil hour I subscribed to a lie; and now only am I truly miserable.” + +She paused, weeping, and then continued, “I thought with horror, my sweet lady, that you should believe your Justine, whom your blessed aunt had so highly honoured, and whom you loved, was a creature capable of a crime which none but the devil himself could have perpetrated. Dear William! dearest blessed child! I soon shall see you again in heaven, where we shall all be happy; and that consoles me, going as I am to suffer ignominy and death.” + +“Oh, Justine! Forgive me for having for one moment distrusted you. Why did you confess? But do not mourn, dear girl. Do not fear. I will proclaim, I will prove your innocence. I will melt the stony hearts of your enemies by my tears and prayers. You shall not die! You, my playfellow, my companion, my sister, perish on the scaffold! No! No! I never could survive so horrible a misfortune.” + +Justine shook her head mournfully. “I do not fear to die,” she said; “that pang is past. God raises my weakness and gives me courage to endure the worst. I leave a sad and bitter world; and if you remember me and think of me as of one unjustly condemned, I am resigned to the fate awaiting me. Learn from me, dear lady, to submit in patience to the will of heaven!” + +During this conversation I had retired to a corner of the prison room, where I could conceal the horrid anguish that possessed me. Despair! Who dared talk of that? The poor victim, who on the morrow was to pass the awful boundary between life and death, felt not, as I did, such deep and bitter agony. I gnashed my teeth and ground them together, uttering a groan that came from my inmost soul. Justine started. When she saw who it was, she approached me and said, “Dear sir, you are very kind to visit me; you, I hope, do not believe that I am guilty?” + +I could not answer. “No, Justine,” said Elizabeth; “he is more convinced of your innocence than I was, for even when he heard that you had confessed, he did not credit it.” + +“I truly thank him. In these last moments I feel the sincerest gratitude towards those who think of me with kindness. How sweet is the affection of others to such a wretch as I am! It removes more than half my misfortune, and I feel as if I could die in peace now that my innocence is acknowledged by you, dear lady, and your cousin.” + +Thus the poor sufferer tried to comfort others and herself. She indeed gained the resignation she desired. But I, the true murderer, felt the never-dying worm alive in my bosom, which allowed of no hope or consolation. Elizabeth also wept and was unhappy, but hers also was the misery of innocence, which, like a cloud that passes over the fair moon, for a while hides but cannot tarnish its brightness. Anguish and despair had penetrated into the core of my heart; I bore a hell within me which nothing could extinguish. We stayed several hours with Justine, and it was with great difficulty that Elizabeth could tear herself away. “I wish,” cried she, “that I were to die with you; I cannot live in this world of misery.” + +Justine assumed an air of cheerfulness, while she with difficulty repressed her bitter tears. She embraced Elizabeth and said in a voice of half-suppressed emotion, “Farewell, sweet lady, dearest Elizabeth, my beloved and only friend; may heaven, in its bounty, bless and preserve you; may this be the last misfortune that you will ever suffer! Live, and be happy, and make others so.” + +And on the morrow Justine died. Elizabeth’s heart-rending eloquence failed to move the judges from their settled conviction in the criminality of the saintly sufferer. My passionate and indignant appeals were lost upon them. And when I received their cold answers and heard the harsh, unfeeling reasoning of these men, my purposed avowal died away on my lips. Thus I might proclaim myself a madman, but not revoke the sentence passed upon my wretched victim. She perished on the scaffold as a murderess! + +From the tortures of my own heart, I turned to contemplate the deep and voiceless grief of my Elizabeth. This also was my doing! And my father’s woe, and the desolation of that late so smiling home all was the work of my thrice-accursed hands! Ye weep, unhappy ones, but these are not your last tears! Again shall you raise the funeral wail, and the sound of your lamentations shall again and again be heard! Frankenstein, your son, your kinsman, your early, much-loved friend; he who would spend each vital drop of blood for your sakes, who has no thought nor sense of joy except as it is mirrored also in your dear countenances, who would fill the air with blessings and spend his life in serving you—he bids you weep, to shed countless tears; happy beyond his hopes, if thus inexorable fate be satisfied, and if the destruction pause before the peace of the grave have succeeded to your sad torments! + +Thus spoke my prophetic soul, as, torn by remorse, horror, and despair, I beheld those I loved spend vain sorrow upon the graves of William and Justine, the first hapless victims to my unhallowed arts. +Chapter 9 + +Nothing is more painful to the human mind than, after the feelings have been worked up by a quick succession of events, the dead calmness of inaction and certainty which follows and deprives the soul both of hope and fear. Justine died, she rested, and I was alive. The blood flowed freely in my veins, but a weight of despair and remorse pressed on my heart which nothing could remove. Sleep fled from my eyes; I wandered like an evil spirit, for I had committed deeds of mischief beyond description horrible, and more, much more (I persuaded myself) was yet behind. Yet my heart overflowed with kindness and the love of virtue. I had begun life with benevolent intentions and thirsted for the moment when I should put them in practice and make myself useful to my fellow beings. Now all was blasted; instead of that serenity of conscience which allowed me to look back upon the past with self-satisfaction, and from thence to gather promise of new hopes, I was seized by remorse and the sense of guilt, which hurried me away to a hell of intense tortures such as no language can describe. + +This state of mind preyed upon my health, which had perhaps never entirely recovered from the first shock it had sustained. I shunned the face of man; all sound of joy or complacency was torture to me; solitude was my only consolation—deep, dark, deathlike solitude. + +My father observed with pain the alteration perceptible in my disposition and habits and endeavoured by arguments deduced from the feelings of his serene conscience and guiltless life to inspire me with fortitude and awaken in me the courage to dispel the dark cloud which brooded over me. “Do you think, Victor,” said he, “that I do not suffer also? No one could love a child more than I loved your brother”—tears came into his eyes as he spoke—“but is it not a duty to the survivors that we should refrain from augmenting their unhappiness by an appearance of immoderate grief? It is also a duty owed to yourself, for excessive sorrow prevents improvement or enjoyment, or even the discharge of daily usefulness, without which no man is fit for society.” + +This advice, although good, was totally inapplicable to my case; I should have been the first to hide my grief and console my friends if remorse had not mingled its bitterness, and terror its alarm, with my other sensations. Now I could only answer my father with a look of despair and endeavour to hide myself from his view. + +About this time we retired to our house at Belrive. This change was particularly agreeable to me. The shutting of the gates regularly at ten o’clock and the impossibility of remaining on the lake after that hour had rendered our residence within the walls of Geneva very irksome to me. I was now free. Often, after the rest of the family had retired for the night, I took the boat and passed many hours upon the water. Sometimes, with my sails set, I was carried by the wind; and sometimes, after rowing into the middle of the lake, I left the boat to pursue its own course and gave way to my own miserable reflections. I was often tempted, when all was at peace around me, and I the only unquiet thing that wandered restless in a scene so beautiful and heavenly—if I except some bat, or the frogs, whose harsh and interrupted croaking was heard only when I approached the shore—often, I say, I was tempted to plunge into the silent lake, that the waters might close over me and my calamities for ever. But I was restrained, when I thought of the heroic and suffering Elizabeth, whom I tenderly loved, and whose existence was bound up in mine. I thought also of my father and surviving brother; should I by my base desertion leave them exposed and unprotected to the malice of the fiend whom I had let loose among them? + +At these moments I wept bitterly and wished that peace would revisit my mind only that I might afford them consolation and happiness. But that could not be. Remorse extinguished every hope. I had been the author of unalterable evils, and I lived in daily fear lest the monster whom I had created should perpetrate some new wickedness. I had an obscure feeling that all was not over and that he would still commit some signal crime, which by its enormity should almost efface the recollection of the past. There was always scope for fear so long as anything I loved remained behind. My abhorrence of this fiend cannot be conceived. When I thought of him I gnashed my teeth, my eyes became inflamed, and I ardently wished to extinguish that life which I had so thoughtlessly bestowed. When I reflected on his crimes and malice, my hatred and revenge burst all bounds of moderation. I would have made a pilgrimage to the highest peak of the Andes, could I, when there, have precipitated him to their base. I wished to see him again, that I might wreak the utmost extent of abhorrence on his head and avenge the deaths of William and Justine. + +Our house was the house of mourning. My father’s health was deeply shaken by the horror of the recent events. Elizabeth was sad and desponding; she no longer took delight in her ordinary occupations; all pleasure seemed to her sacrilege toward the dead; eternal woe and tears she then thought was the just tribute she should pay to innocence so blasted and destroyed. She was no longer that happy creature who in earlier youth wandered with me on the banks of the lake and talked with ecstasy of our future prospects. The first of those sorrows which are sent to wean us from the earth had visited her, and its dimming influence quenched her dearest smiles. + +“When I reflect, my dear cousin,” said she, “on the miserable death of Justine Moritz, I no longer see the world and its works as they before appeared to me. Before, I looked upon the accounts of vice and injustice that I read in books or heard from others as tales of ancient days or imaginary evils; at least they were remote and more familiar to reason than to the imagination; but now misery has come home, and men appear to me as monsters thirsting for each other’s blood. Yet I am certainly unjust. Everybody believed that poor girl to be guilty; and if she could have committed the crime for which she suffered, assuredly she would have been the most depraved of human creatures. For the sake of a few jewels, to have murdered the son of her benefactor and friend, a child whom she had nursed from its birth, and appeared to love as if it had been her own! I could not consent to the death of any human being, but certainly I should have thought such a creature unfit to remain in the society of men. But she was innocent. I know, I feel she was innocent; you are of the same opinion, and that confirms me. Alas! Victor, when falsehood can look so like the truth, who can assure themselves of certain happiness? I feel as if I were walking on the edge of a precipice, towards which thousands are crowding and endeavouring to plunge me into the abyss. William and Justine were assassinated, and the murderer escapes; he walks about the world free, and perhaps respected. But even if I were condemned to suffer on the scaffold for the same crimes, I would not change places with such a wretch.” + +I listened to this discourse with the extremest agony. I, not in deed, but in effect, was the true murderer. Elizabeth read my anguish in my countenance, and kindly taking my hand, said, “My dearest friend, you must calm yourself. These events have affected me, God knows how deeply; but I am not so wretched as you are. There is an expression of despair, and sometimes of revenge, in your countenance that makes me tremble. Dear Victor, banish these dark passions. Remember the friends around you, who centre all their hopes in you. Have we lost the power of rendering you happy? Ah! While we love, while we are true to each other, here in this land of peace and beauty, your native country, we may reap every tranquil blessing—what can disturb our peace?” + +And could not such words from her whom I fondly prized before every other gift of fortune suffice to chase away the fiend that lurked in my heart? Even as she spoke I drew near to her, as if in terror, lest at that very moment the destroyer had been near to rob me of her. + +Thus not the tenderness of friendship, nor the beauty of earth, nor of heaven, could redeem my soul from woe; the very accents of love were ineffectual. I was encompassed by a cloud which no beneficial influence could penetrate. The wounded deer dragging its fainting limbs to some untrodden brake, there to gaze upon the arrow which had pierced it, and to die, was but a type of me. + +Sometimes I could cope with the sullen despair that overwhelmed me, but sometimes the whirlwind passions of my soul drove me to seek, by bodily exercise and by change of place, some relief from my intolerable sensations. It was during an access of this kind that I suddenly left my home, and bending my steps towards the near Alpine valleys, sought in the magnificence, the eternity of such scenes, to forget myself and my ephemeral, because human, sorrows. My wanderings were directed towards the valley of Chamounix. I had visited it frequently during my boyhood. Six years had passed since then: I was a wreck, but nought had changed in those savage and enduring scenes. + +I performed the first part of my journey on horseback. I afterwards hired a mule, as the more sure-footed and least liable to receive injury on these rugged roads. The weather was fine; it was about the middle of the month of August, nearly two months after the death of Justine, that miserable epoch from which I dated all my woe. The weight upon my spirit was sensibly lightened as I plunged yet deeper in the ravine of Arve. The immense mountains and precipices that overhung me on every side, the sound of the river raging among the rocks, and the dashing of the waterfalls around spoke of a power mighty as Omnipotence—and I ceased to fear or to bend before any being less almighty than that which had created and ruled the elements, here displayed in their most terrific guise. Still, as I ascended higher, the valley assumed a more magnificent and astonishing character. Ruined castles hanging on the precipices of piny mountains, the impetuous Arve, and cottages every here and there peeping forth from among the trees formed a scene of singular beauty. But it was augmented and rendered sublime by the mighty Alps, whose white and shining pyramids and domes towered above all, as belonging to another earth, the habitations of another race of beings. + +I passed the bridge of Pélissier, where the ravine, which the river forms, opened before me, and I began to ascend the mountain that overhangs it. Soon after, I entered the valley of Chamounix. This valley is more wonderful and sublime, but not so beautiful and picturesque as that of Servox, through which I had just passed. The high and snowy mountains were its immediate boundaries, but I saw no more ruined castles and fertile fields. Immense glaciers approached the road; I heard the rumbling thunder of the falling avalanche and marked the smoke of its passage. Mont Blanc, the supreme and magnificent Mont Blanc, raised itself from the surrounding aiguilles, and its tremendous dôme overlooked the valley. + +A tingling long-lost sense of pleasure often came across me during this journey. Some turn in the road, some new object suddenly perceived and recognised, reminded me of days gone by, and were associated with the lighthearted gaiety of boyhood. The very winds whispered in soothing accents, and maternal Nature bade me weep no more. Then again the kindly influence ceased to act—I found myself fettered again to grief and indulging in all the misery of reflection. Then I spurred on my animal, striving so to forget the world, my fears, and more than all, myself—or, in a more desperate fashion, I alighted and threw myself on the grass, weighed down by horror and despair. + +At length I arrived at the village of Chamounix. Exhaustion succeeded to the extreme fatigue both of body and of mind which I had endured. For a short space of time I remained at the window watching the pallid lightnings that played above Mont Blanc and listening to the rushing of the Arve, which pursued its noisy way beneath. The same lulling sounds acted as a lullaby to my too keen sensations; when I placed my head upon my pillow, sleep crept over me; I felt it as it came and blessed the giver of oblivion. +Chapter 10 + +I spent the following day roaming through the valley. I stood beside the sources of the Arveiron, which take their rise in a glacier, that with slow pace is advancing down from the summit of the hills to barricade the valley. The abrupt sides of vast mountains were before me; the icy wall of the glacier overhung me; a few shattered pines were scattered around; and the solemn silence of this glorious presence-chamber of imperial Nature was broken only by the brawling waves or the fall of some vast fragment, the thunder sound of the avalanche or the cracking, reverberated along the mountains, of the accumulated ice, which, through the silent working of immutable laws, was ever and anon rent and torn, as if it had been but a plaything in their hands. These sublime and magnificent scenes afforded me the greatest consolation that I was capable of receiving. They elevated me from all littleness of feeling, and although they did not remove my grief, they subdued and tranquillised it. In some degree, also, they diverted my mind from the thoughts over which it had brooded for the last month. I retired to rest at night; my slumbers, as it were, waited on and ministered to by the assemblance of grand shapes which I had contemplated during the day. They congregated round me; the unstained snowy mountain-top, the glittering pinnacle, the pine woods, and ragged bare ravine, the eagle, soaring amidst the clouds—they all gathered round me and bade me be at peace. + +Where had they fled when the next morning I awoke? All of soul-inspiriting fled with sleep, and dark melancholy clouded every thought. The rain was pouring in torrents, and thick mists hid the summits of the mountains, so that I even saw not the faces of those mighty friends. Still I would penetrate their misty veil and seek them in their cloudy retreats. What were rain and storm to me? My mule was brought to the door, and I resolved to ascend to the summit of Montanvert. I remembered the effect that the view of the tremendous and ever-moving glacier had produced upon my mind when I first saw it. It had then filled me with a sublime ecstasy that gave wings to the soul and allowed it to soar from the obscure world to light and joy. The sight of the awful and majestic in nature had indeed always the effect of solemnising my mind and causing me to forget the passing cares of life. I determined to go without a guide, for I was well acquainted with the path, and the presence of another would destroy the solitary grandeur of the scene. + +The ascent is precipitous, but the path is cut into continual and short windings, which enable you to surmount the perpendicularity of the mountain. It is a scene terrifically desolate. In a thousand spots the traces of the winter avalanche may be perceived, where trees lie broken and strewed on the ground, some entirely destroyed, others bent, leaning upon the jutting rocks of the mountain or transversely upon other trees. The path, as you ascend higher, is intersected by ravines of snow, down which stones continually roll from above; one of them is particularly dangerous, as the slightest sound, such as even speaking in a loud voice, produces a concussion of air sufficient to draw destruction upon the head of the speaker. The pines are not tall or luxuriant, but they are sombre and add an air of severity to the scene. I looked on the valley beneath; vast mists were rising from the rivers which ran through it and curling in thick wreaths around the opposite mountains, whose summits were hid in the uniform clouds, while rain poured from the dark sky and added to the melancholy impression I received from the objects around me. Alas! Why does man boast of sensibilities superior to those apparent in the brute; it only renders them more necessary beings. If our impulses were confined to hunger, thirst, and desire, we might be nearly free; but now we are moved by every wind that blows and a chance word or scene that that word may convey to us. + +We rest; a dream has power to poison sleep. + We rise; one wand’ring thought pollutes the day. +We feel, conceive, or reason; laugh or weep, + Embrace fond woe, or cast our cares away; +It is the same: for, be it joy or sorrow, + The path of its departure still is free. +Man’s yesterday may ne’er be like his morrow; + Nought may endure but mutability! + +It was nearly noon when I arrived at the top of the ascent. For some time I sat upon the rock that overlooks the sea of ice. A mist covered both that and the surrounding mountains. Presently a breeze dissipated the cloud, and I descended upon the glacier. The surface is very uneven, rising like the waves of a troubled sea, descending low, and interspersed by rifts that sink deep. The field of ice is almost a league in width, but I spent nearly two hours in crossing it. The opposite mountain is a bare perpendicular rock. From the side where I now stood Montanvert was exactly opposite, at the distance of a league; and above it rose Mont Blanc, in awful majesty. I remained in a recess of the rock, gazing on this wonderful and stupendous scene. The sea, or rather the vast river of ice, wound among its dependent mountains, whose aerial summits hung over its recesses. Their icy and glittering peaks shone in the sunlight over the clouds. My heart, which was before sorrowful, now swelled with something like joy; I exclaimed, “Wandering spirits, if indeed ye wander, and do not rest in your narrow beds, allow me this faint happiness, or take me, as your companion, away from the joys of life.” + +As I said this I suddenly beheld the figure of a man, at some distance, advancing towards me with superhuman speed. He bounded over the crevices in the ice, among which I had walked with caution; his stature, also, as he approached, seemed to exceed that of man. I was troubled; a mist came over my eyes, and I felt a faintness seize me, but I was quickly restored by the cold gale of the mountains. I perceived, as the shape came nearer (sight tremendous and abhorred!) that it was the wretch whom I had created. I trembled with rage and horror, resolving to wait his approach and then close with him in mortal combat. He approached; his countenance bespoke bitter anguish, combined with disdain and malignity, while its unearthly ugliness rendered it almost too horrible for human eyes. But I scarcely observed this; rage and hatred had at first deprived me of utterance, and I recovered only to overwhelm him with words expressive of furious detestation and contempt. + +“Devil,” I exclaimed, “do you dare approach me? And do not you fear the fierce vengeance of my arm wreaked on your miserable head? Begone, vile insect! Or rather, stay, that I may trample you to dust! And, oh! That I could, with the extinction of your miserable existence, restore those victims whom you have so diabolically murdered!” + +“I expected this reception,” said the dæmon. “All men hate the wretched; how, then, must I be hated, who am miserable beyond all living things! Yet you, my creator, detest and spurn me, thy creature, to whom thou art bound by ties only dissoluble by the annihilation of one of us. You purpose to kill me. How dare you sport thus with life? Do your duty towards me, and I will do mine towards you and the rest of mankind. If you will comply with my conditions, I will leave them and you at peace; but if you refuse, I will glut the maw of death, until it be satiated with the blood of your remaining friends.” + +“Abhorred monster! Fiend that thou art! The tortures of hell are too mild a vengeance for thy crimes. Wretched devil! You reproach me with your creation, come on, then, that I may extinguish the spark which I so negligently bestowed.” + +My rage was without bounds; I sprang on him, impelled by all the feelings which can arm one being against the existence of another. + +He easily eluded me and said, + +“Be calm! I entreat you to hear me before you give vent to your hatred on my devoted head. Have I not suffered enough, that you seek to increase my misery? Life, although it may only be an accumulation of anguish, is dear to me, and I will defend it. Remember, thou hast made me more powerful than thyself; my height is superior to thine, my joints more supple. But I will not be tempted to set myself in opposition to thee. I am thy creature, and I will be even mild and docile to my natural lord and king if thou wilt also perform thy part, the which thou owest me. Oh, Frankenstein, be not equitable to every other and trample upon me alone, to whom thy justice, and even thy clemency and affection, is most due. Remember that I am thy creature; I ought to be thy Adam, but I am rather the fallen angel, whom thou drivest from joy for no misdeed. Everywhere I see bliss, from which I alone am irrevocably excluded. I was benevolent and good; misery made me a fiend. Make me happy, and I shall again be virtuous.” + +“Begone! I will not hear you. There can be no community between you and me; we are enemies. Begone, or let us try our strength in a fight, in which one must fall.” + +“How can I move thee? Will no entreaties cause thee to turn a favourable eye upon thy creature, who implores thy goodness and compassion? Believe me, Frankenstein, I was benevolent; my soul glowed with love and humanity; but am I not alone, miserably alone? You, my creator, abhor me; what hope can I gather from your fellow creatures, who owe me nothing? They spurn and hate me. The desert mountains and dreary glaciers are my refuge. I have wandered here many days; the caves of ice, which I only do not fear, are a dwelling to me, and the only one which man does not grudge. These bleak skies I hail, for they are kinder to me than your fellow beings. If the multitude of mankind knew of my existence, they would do as you do, and arm themselves for my destruction. Shall I not then hate them who abhor me? I will keep no terms with my enemies. I am miserable, and they shall share my wretchedness. Yet it is in your power to recompense me, and deliver them from an evil which it only remains for you to make so great, that not only you and your family, but thousands of others, shall be swallowed up in the whirlwinds of its rage. Let your compassion be moved, and do not disdain me. Listen to my tale; when you have heard that, abandon or commiserate me, as you shall judge that I deserve. But hear me. The guilty are allowed, by human laws, bloody as they are, to speak in their own defence before they are condemned. Listen to me, Frankenstein. You accuse me of murder, and yet you would, with a satisfied conscience, destroy your own creature. Oh, praise the eternal justice of man! Yet I ask you not to spare me; listen to me, and then, if you can, and if you will, destroy the work of your hands.” + +“Why do you call to my remembrance,” I rejoined, “circumstances of which I shudder to reflect, that I have been the miserable origin and author? Cursed be the day, abhorred devil, in which you first saw light! Cursed (although I curse myself) be the hands that formed you! You have made me wretched beyond expression. You have left me no power to consider whether I am just to you or not. Begone! Relieve me from the sight of your detested form.” + +“Thus I relieve thee, my creator,” he said, and placed his hated hands before my eyes, which I flung from me with violence; “thus I take from thee a sight which you abhor. Still thou canst listen to me and grant me thy compassion. By the virtues that I once possessed, I demand this from you. Hear my tale; it is long and strange, and the temperature of this place is not fitting to your fine sensations; come to the hut upon the mountain. The sun is yet high in the heavens; before it descends to hide itself behind your snowy precipices and illuminate another world, you will have heard my story and can decide. On you it rests, whether I quit for ever the neighbourhood of man and lead a harmless life, or become the scourge of your fellow creatures and the author of your own speedy ruin.” + +As he said this he led the way across the ice; I followed. My heart was full, and I did not answer him, but as I proceeded, I weighed the various arguments that he had used and determined at least to listen to his tale. I was partly urged by curiosity, and compassion confirmed my resolution. I had hitherto supposed him to be the murderer of my brother, and I eagerly sought a confirmation or denial of this opinion. For the first time, also, I felt what the duties of a creator towards his creature were, and that I ought to render him happy before I complained of his wickedness. These motives urged me to comply with his demand. We crossed the ice, therefore, and ascended the opposite rock. The air was cold, and the rain again began to descend; we entered the hut, the fiend with an air of exultation, I with a heavy heart and depressed spirits. But I consented to listen, and seating myself by the fire which my odious companion had lighted, he thus began his tale. +Chapter 11 + +“It is with considerable difficulty that I remember the original era of my being; all the events of that period appear confused and indistinct. A strange multiplicity of sensations seized me, and I saw, felt, heard, and smelt at the same time; and it was, indeed, a long time before I learned to distinguish between the operations of my various senses. By degrees, I remember, a stronger light pressed upon my nerves, so that I was obliged to shut my eyes. Darkness then came over me and troubled me, but hardly had I felt this when, by opening my eyes, as I now suppose, the light poured in upon me again. I walked and, I believe, descended, but I presently found a great alteration in my sensations. Before, dark and opaque bodies had surrounded me, impervious to my touch or sight; but I now found that I could wander on at liberty, with no obstacles which I could not either surmount or avoid. The light became more and more oppressive to me, and the heat wearying me as I walked, I sought a place where I could receive shade. This was the forest near Ingolstadt; and here I lay by the side of a brook resting from my fatigue, until I felt tormented by hunger and thirst. This roused me from my nearly dormant state, and I ate some berries which I found hanging on the trees or lying on the ground. I slaked my thirst at the brook, and then lying down, was overcome by sleep. + +“It was dark when I awoke; I felt cold also, and half frightened, as it were, instinctively, finding myself so desolate. Before I had quitted your apartment, on a sensation of cold, I had covered myself with some clothes, but these were insufficient to secure me from the dews of night. I was a poor, helpless, miserable wretch; I knew, and could distinguish, nothing; but feeling pain invade me on all sides, I sat down and wept. + +“Soon a gentle light stole over the heavens and gave me a sensation of pleasure. I started up and beheld a radiant form rise from among the trees. [The moon] I gazed with a kind of wonder. It moved slowly, but it enlightened my path, and I again went out in search of berries. I was still cold when under one of the trees I found a huge cloak, with which I covered myself, and sat down upon the ground. No distinct ideas occupied my mind; all was confused. I felt light, and hunger, and thirst, and darkness; innumerable sounds rang in my ears, and on all sides various scents saluted me; the only object that I could distinguish was the bright moon, and I fixed my eyes on that with pleasure. + +“Several changes of day and night passed, and the orb of night had greatly lessened, when I began to distinguish my sensations from each other. I gradually saw plainly the clear stream that supplied me with drink and the trees that shaded me with their foliage. I was delighted when I first discovered that a pleasant sound, which often saluted my ears, proceeded from the throats of the little winged animals who had often intercepted the light from my eyes. I began also to observe, with greater accuracy, the forms that surrounded me and to perceive the boundaries of the radiant roof of light which canopied me. Sometimes I tried to imitate the pleasant songs of the birds but was unable. Sometimes I wished to express my sensations in my own mode, but the uncouth and inarticulate sounds which broke from me frightened me into silence again. + +“The moon had disappeared from the night, and again, with a lessened form, showed itself, while I still remained in the forest. My sensations had by this time become distinct, and my mind received every day additional ideas. My eyes became accustomed to the light and to perceive objects in their right forms; I distinguished the insect from the herb, and by degrees, one herb from another. I found that the sparrow uttered none but harsh notes, whilst those of the blackbird and thrush were sweet and enticing. + +“One day, when I was oppressed by cold, I found a fire which had been left by some wandering beggars, and was overcome with delight at the warmth I experienced from it. In my joy I thrust my hand into the live embers, but quickly drew it out again with a cry of pain. How strange, I thought, that the same cause should produce such opposite effects! I examined the materials of the fire, and to my joy found it to be composed of wood. I quickly collected some branches, but they were wet and would not burn. I was pained at this and sat still watching the operation of the fire. The wet wood which I had placed near the heat dried and itself became inflamed. I reflected on this, and by touching the various branches, I discovered the cause and busied myself in collecting a great quantity of wood, that I might dry it and have a plentiful supply of fire. When night came on and brought sleep with it, I was in the greatest fear lest my fire should be extinguished. I covered it carefully with dry wood and leaves and placed wet branches upon it; and then, spreading my cloak, I lay on the ground and sank into sleep. + +“It was morning when I awoke, and my first care was to visit the fire. I uncovered it, and a gentle breeze quickly fanned it into a flame. I observed this also and contrived a fan of branches, which roused the embers when they were nearly extinguished. When night came again I found, with pleasure, that the fire gave light as well as heat and that the discovery of this element was useful to me in my food, for I found some of the offals that the travellers had left had been roasted, and tasted much more savoury than the berries I gathered from the trees. I tried, therefore, to dress my food in the same manner, placing it on the live embers. I found that the berries were spoiled by this operation, and the nuts and roots much improved. + +“Food, however, became scarce, and I often spent the whole day searching in vain for a few acorns to assuage the pangs of hunger. When I found this, I resolved to quit the place that I had hitherto inhabited, to seek for one where the few wants I experienced would be more easily satisfied. In this emigration I exceedingly lamented the loss of the fire which I had obtained through accident and knew not how to reproduce it. I gave several hours to the serious consideration of this difficulty, but I was obliged to relinquish all attempt to supply it, and wrapping myself up in my cloak, I struck across the wood towards the setting sun. I passed three days in these rambles and at length discovered the open country. A great fall of snow had taken place the night before, and the fields were of one uniform white; the appearance was disconsolate, and I found my feet chilled by the cold damp substance that covered the ground. + +“It was about seven in the morning, and I longed to obtain food and shelter; at length I perceived a small hut, on a rising ground, which had doubtless been built for the convenience of some shepherd. This was a new sight to me, and I examined the structure with great curiosity. Finding the door open, I entered. An old man sat in it, near a fire, over which he was preparing his breakfast. He turned on hearing a noise, and perceiving me, shrieked loudly, and quitting the hut, ran across the fields with a speed of which his debilitated form hardly appeared capable. His appearance, different from any I had ever before seen, and his flight somewhat surprised me. But I was enchanted by the appearance of the hut; here the snow and rain could not penetrate; the ground was dry; and it presented to me then as exquisite and divine a retreat as Pandæmonium appeared to the dæmons of hell after their sufferings in the lake of fire. I greedily devoured the remnants of the shepherd’s breakfast, which consisted of bread, cheese, milk, and wine; the latter, however, I did not like. Then, overcome by fatigue, I lay down among some straw and fell asleep. + +“It was noon when I awoke, and allured by the warmth of the sun, which shone brightly on the white ground, I determined to recommence my travels; and, depositing the remains of the peasant’s breakfast in a wallet I found, I proceeded across the fields for several hours, until at sunset I arrived at a village. How miraculous did this appear! The huts, the neater cottages, and stately houses engaged my admiration by turns. The vegetables in the gardens, the milk and cheese that I saw placed at the windows of some of the cottages, allured my appetite. One of the best of these I entered, but I had hardly placed my foot within the door before the children shrieked, and one of the women fainted. The whole village was roused; some fled, some attacked me, until, grievously bruised by stones and many other kinds of missile weapons, I escaped to the open country and fearfully took refuge in a low hovel, quite bare, and making a wretched appearance after the palaces I had beheld in the village. This hovel however, joined a cottage of a neat and pleasant appearance, but after my late dearly bought experience, I dared not enter it. My place of refuge was constructed of wood, but so low that I could with difficulty sit upright in it. No wood, however, was placed on the earth, which formed the floor, but it was dry; and although the wind entered it by innumerable chinks, I found it an agreeable asylum from the snow and rain. + +“Here, then, I retreated and lay down happy to have found a shelter, however miserable, from the inclemency of the season, and still more from the barbarity of man. As soon as morning dawned I crept from my kennel, that I might view the adjacent cottage and discover if I could remain in the habitation I had found. It was situated against the back of the cottage and surrounded on the sides which were exposed by a pig sty and a clear pool of water. One part was open, and by that I had crept in; but now I covered every crevice by which I might be perceived with stones and wood, yet in such a manner that I might move them on occasion to pass out; all the light I enjoyed came through the sty, and that was sufficient for me. + +“Having thus arranged my dwelling and carpeted it with clean straw, I retired, for I saw the figure of a man at a distance, and I remembered too well my treatment the night before to trust myself in his power. I had first, however, provided for my sustenance for that day by a loaf of coarse bread, which I purloined, and a cup with which I could drink more conveniently than from my hand of the pure water which flowed by my retreat. The floor was a little raised, so that it was kept perfectly dry, and by its vicinity to the chimney of the cottage it was tolerably warm. + +“Being thus provided, I resolved to reside in this hovel until something should occur which might alter my determination. It was indeed a paradise compared to the bleak forest, my former residence, the rain-dropping branches, and dank earth. I ate my breakfast with pleasure and was about to remove a plank to procure myself a little water when I heard a step, and looking through a small chink, I beheld a young creature, with a pail on her head, passing before my hovel. The girl was young and of gentle demeanour, unlike what I have since found cottagers and farmhouse servants to be. Yet she was meanly dressed, a coarse blue petticoat and a linen jacket being her only garb; her fair hair was plaited but not adorned: she looked patient yet sad. I lost sight of her, and in about a quarter of an hour she returned bearing the pail, which was now partly filled with milk. As she walked along, seemingly incommoded by the burden, a young man met her, whose countenance expressed a deeper despondence. Uttering a few sounds with an air of melancholy, he took the pail from her head and bore it to the cottage himself. She followed, and they disappeared. Presently I saw the young man again, with some tools in his hand, cross the field behind the cottage; and the girl was also busied, sometimes in the house and sometimes in the yard. + +“On examining my dwelling, I found that one of the windows of the cottage had formerly occupied a part of it, but the panes had been filled up with wood. In one of these was a small and almost imperceptible chink through which the eye could just penetrate. Through this crevice a small room was visible, whitewashed and clean but very bare of furniture. In one corner, near a small fire, sat an old man, leaning his head on his hands in a disconsolate attitude. The young girl was occupied in arranging the cottage; but presently she took something out of a drawer, which employed her hands, and she sat down beside the old man, who, taking up an instrument, began to play and to produce sounds sweeter than the voice of the thrush or the nightingale. It was a lovely sight, even to me, poor wretch who had never beheld aught beautiful before. The silver hair and benevolent countenance of the aged cottager won my reverence, while the gentle manners of the girl enticed my love. He played a sweet mournful air which I perceived drew tears from the eyes of his amiable companion, of which the old man took no notice, until she sobbed audibly; he then pronounced a few sounds, and the fair creature, leaving her work, knelt at his feet. He raised her and smiled with such kindness and affection that I felt sensations of a peculiar and overpowering nature; they were a mixture of pain and pleasure, such as I had never before experienced, either from hunger or cold, warmth or food; and I withdrew from the window, unable to bear these emotions. + +“Soon after this the young man returned, bearing on his shoulders a load of wood. The girl met him at the door, helped to relieve him of his burden, and taking some of the fuel into the cottage, placed it on the fire; then she and the youth went apart into a nook of the cottage, and he showed her a large loaf and a piece of cheese. She seemed pleased and went into the garden for some roots and plants, which she placed in water, and then upon the fire. She afterwards continued her work, whilst the young man went into the garden and appeared busily employed in digging and pulling up roots. After he had been employed thus about an hour, the young woman joined him and they entered the cottage together. + +“The old man had, in the meantime, been pensive, but on the appearance of his companions he assumed a more cheerful air, and they sat down to eat. The meal was quickly dispatched. The young woman was again occupied in arranging the cottage, the old man walked before the cottage in the sun for a few minutes, leaning on the arm of the youth. Nothing could exceed in beauty the contrast between these two excellent creatures. One was old, with silver hairs and a countenance beaming with benevolence and love; the younger was slight and graceful in his figure, and his features were moulded with the finest symmetry, yet his eyes and attitude expressed the utmost sadness and despondency. The old man returned to the cottage, and the youth, with tools different from those he had used in the morning, directed his steps across the fields. + +“Night quickly shut in, but to my extreme wonder, I found that the cottagers had a means of prolonging light by the use of tapers, and was delighted to find that the setting of the sun did not put an end to the pleasure I experienced in watching my human neighbours. In the evening the young girl and her companion were employed in various occupations which I did not understand; and the old man again took up the instrument which produced the divine sounds that had enchanted me in the morning. So soon as he had finished, the youth began, not to play, but to utter sounds that were monotonous, and neither resembling the harmony of the old man’s instrument nor the songs of the birds; I since found that he read aloud, but at that time I knew nothing of the science of words or letters. + +“The family, after having been thus occupied for a short time, extinguished their lights and retired, as I conjectured, to rest.” +Chapter 12 + +“I lay on my straw, but I could not sleep. I thought of the occurrences of the day. What chiefly struck me was the gentle manners of these people, and I longed to join them, but dared not. I remembered too well the treatment I had suffered the night before from the barbarous villagers, and resolved, whatever course of conduct I might hereafter think it right to pursue, that for the present I would remain quietly in my hovel, watching and endeavouring to discover the motives which influenced their actions. + +“The cottagers arose the next morning before the sun. The young woman arranged the cottage and prepared the food, and the youth departed after the first meal. + +“This day was passed in the same routine as that which preceded it. The young man was constantly employed out of doors, and the girl in various laborious occupations within. The old man, whom I soon perceived to be blind, employed his leisure hours on his instrument or in contemplation. Nothing could exceed the love and respect which the younger cottagers exhibited towards their venerable companion. They performed towards him every little office of affection and duty with gentleness, and he rewarded them by his benevolent smiles. + +“They were not entirely happy. The young man and his companion often went apart and appeared to weep. I saw no cause for their unhappiness, but I was deeply affected by it. If such lovely creatures were miserable, it was less strange that I, an imperfect and solitary being, should be wretched. Yet why were these gentle beings unhappy? They possessed a delightful house (for such it was in my eyes) and every luxury; they had a fire to warm them when chill and delicious viands when hungry; they were dressed in excellent clothes; and, still more, they enjoyed one another’s company and speech, interchanging each day looks of affection and kindness. What did their tears imply? Did they really express pain? I was at first unable to solve these questions, but perpetual attention and time explained to me many appearances which were at first enigmatic. + +“A considerable period elapsed before I discovered one of the causes of the uneasiness of this amiable family: it was poverty, and they suffered that evil in a very distressing degree. Their nourishment consisted entirely of the vegetables of their garden and the milk of one cow, which gave very little during the winter, when its masters could scarcely procure food to support it. They often, I believe, suffered the pangs of hunger very poignantly, especially the two younger cottagers, for several times they placed food before the old man when they reserved none for themselves. + +“This trait of kindness moved me sensibly. I had been accustomed, during the night, to steal a part of their store for my own consumption, but when I found that in doing this I inflicted pain on the cottagers, I abstained and satisfied myself with berries, nuts, and roots which I gathered from a neighbouring wood. + +“I discovered also another means through which I was enabled to assist their labours. I found that the youth spent a great part of each day in collecting wood for the family fire, and during the night I often took his tools, the use of which I quickly discovered, and brought home firing sufficient for the consumption of several days. + +“I remember, the first time that I did this, the young woman, when she opened the door in the morning, appeared greatly astonished on seeing a great pile of wood on the outside. She uttered some words in a loud voice, and the youth joined her, who also expressed surprise. I observed, with pleasure, that he did not go to the forest that day, but spent it in repairing the cottage and cultivating the garden. + +“By degrees I made a discovery of still greater moment. I found that these people possessed a method of communicating their experience and feelings to one another by articulate sounds. I perceived that the words they spoke sometimes produced pleasure or pain, smiles or sadness, in the minds and countenances of the hearers. This was indeed a godlike science, and I ardently desired to become acquainted with it. But I was baffled in every attempt I made for this purpose. Their pronunciation was quick, and the words they uttered, not having any apparent connection with visible objects, I was unable to discover any clue by which I could unravel the mystery of their reference. By great application, however, and after having remained during the space of several revolutions of the moon in my hovel, I discovered the names that were given to some of the most familiar objects of discourse; I learned and applied the words, fire, milk, bread, and wood. I learned also the names of the cottagers themselves. The youth and his companion had each of them several names, but the old man had only one, which was father. The girl was called sister or Agatha, and the youth Felix, brother, or son. I cannot describe the delight I felt when I learned the ideas appropriated to each of these sounds and was able to pronounce them. I distinguished several other words without being able as yet to understand or apply them, such as good, dearest, unhappy. + +“I spent the winter in this manner. The gentle manners and beauty of the cottagers greatly endeared them to me; when they were unhappy, I felt depressed; when they rejoiced, I sympathised in their joys. I saw few human beings besides them, and if any other happened to enter the cottage, their harsh manners and rude gait only enhanced to me the superior accomplishments of my friends. The old man, I could perceive, often endeavoured to encourage his children, as sometimes I found that he called them, to cast off their melancholy. He would talk in a cheerful accent, with an expression of goodness that bestowed pleasure even upon me. Agatha listened with respect, her eyes sometimes filled with tears, which she endeavoured to wipe away unperceived; but I generally found that her countenance and tone were more cheerful after having listened to the exhortations of her father. It was not thus with Felix. He was always the saddest of the group, and even to my unpractised senses, he appeared to have suffered more deeply than his friends. But if his countenance was more sorrowful, his voice was more cheerful than that of his sister, especially when he addressed the old man. + +“I could mention innumerable instances which, although slight, marked the dispositions of these amiable cottagers. In the midst of poverty and want, Felix carried with pleasure to his sister the first little white flower that peeped out from beneath the snowy ground. Early in the morning, before she had risen, he cleared away the snow that obstructed her path to the milk-house, drew water from the well, and brought the wood from the outhouse, where, to his perpetual astonishment, he found his store always replenished by an invisible hand. In the day, I believe, he worked sometimes for a neighbouring farmer, because he often went forth and did not return until dinner, yet brought no wood with him. At other times he worked in the garden, but as there was little to do in the frosty season, he read to the old man and Agatha. + +“This reading had puzzled me extremely at first, but by degrees I discovered that he uttered many of the same sounds when he read as when he talked. I conjectured, therefore, that he found on the paper signs for speech which he understood, and I ardently longed to comprehend these also; but how was that possible when I did not even understand the sounds for which they stood as signs? I improved, however, sensibly in this science, but not sufficiently to follow up any kind of conversation, although I applied my whole mind to the endeavour, for I easily perceived that, although I eagerly longed to discover myself to the cottagers, I ought not to make the attempt until I had first become master of their language, which knowledge might enable me to make them overlook the deformity of my figure, for with this also the contrast perpetually presented to my eyes had made me acquainted. + +“I had admired the perfect forms of my cottagers—their grace, beauty, and delicate complexions; but how was I terrified when I viewed myself in a transparent pool! At first I started back, unable to believe that it was indeed I who was reflected in the mirror; and when I became fully convinced that I was in reality the monster that I am, I was filled with the bitterest sensations of despondence and mortification. Alas! I did not yet entirely know the fatal effects of this miserable deformity. + +“As the sun became warmer and the light of day longer, the snow vanished, and I beheld the bare trees and the black earth. From this time Felix was more employed, and the heart-moving indications of impending famine disappeared. Their food, as I afterwards found, was coarse, but it was wholesome; and they procured a sufficiency of it. Several new kinds of plants sprang up in the garden, which they dressed; and these signs of comfort increased daily as the season advanced. + +“The old man, leaning on his son, walked each day at noon, when it did not rain, as I found it was called when the heavens poured forth its waters. This frequently took place, but a high wind quickly dried the earth, and the season became far more pleasant than it had been. + +“My mode of life in my hovel was uniform. During the morning I attended the motions of the cottagers, and when they were dispersed in various occupations, I slept; the remainder of the day was spent in observing my friends. When they had retired to rest, if there was any moon or the night was star-light, I went into the woods and collected my own food and fuel for the cottage. When I returned, as often as it was necessary, I cleared their path from the snow and performed those offices that I had seen done by Felix. I afterwards found that these labours, performed by an invisible hand, greatly astonished them; and once or twice I heard them, on these occasions, utter the words good spirit, wonderful; but I did not then understand the signification of these terms. + +“My thoughts now became more active, and I longed to discover the motives and feelings of these lovely creatures; I was inquisitive to know why Felix appeared so miserable and Agatha so sad. I thought (foolish wretch!) that it might be in my power to restore happiness to these deserving people. When I slept or was absent, the forms of the venerable blind father, the gentle Agatha, and the excellent Felix flitted before me. I looked upon them as superior beings who would be the arbiters of my future destiny. I formed in my imagination a thousand pictures of presenting myself to them, and their reception of me. I imagined that they would be disgusted, until, by my gentle demeanour and conciliating words, I should first win their favour and afterwards their love. + +“These thoughts exhilarated me and led me to apply with fresh ardour to the acquiring the art of language. My organs were indeed harsh, but supple; and although my voice was very unlike the soft music of their tones, yet I pronounced such words as I understood with tolerable ease. It was as the ass and the lap-dog; yet surely the gentle ass whose intentions were affectionate, although his manners were rude, deserved better treatment than blows and execration. + +“The pleasant showers and genial warmth of spring greatly altered the aspect of the earth. Men who before this change seemed to have been hid in caves dispersed themselves and were employed in various arts of cultivation. The birds sang in more cheerful notes, and the leaves began to bud forth on the trees. Happy, happy earth! Fit habitation for gods, which, so short a time before, was bleak, damp, and unwholesome. My spirits were elevated by the enchanting appearance of nature; the past was blotted from my memory, the present was tranquil, and the future gilded by bright rays of hope and anticipations of joy.” +Chapter 13 + +“I now hasten to the more moving part of my story. I shall relate events that impressed me with feelings which, from what I had been, have made me what I am. + +“Spring advanced rapidly; the weather became fine and the skies cloudless. It surprised me that what before was desert and gloomy should now bloom with the most beautiful flowers and verdure. My senses were gratified and refreshed by a thousand scents of delight and a thousand sights of beauty. + +“It was on one of these days, when my cottagers periodically rested from labour—the old man played on his guitar, and the children listened to him—that I observed the countenance of Felix was melancholy beyond expression; he sighed frequently, and once his father paused in his music, and I conjectured by his manner that he inquired the cause of his son’s sorrow. Felix replied in a cheerful accent, and the old man was recommencing his music when someone tapped at the door. + +“It was a lady on horseback, accompanied by a country-man as a guide. The lady was dressed in a dark suit and covered with a thick black veil. Agatha asked a question, to which the stranger only replied by pronouncing, in a sweet accent, the name of Felix. Her voice was musical but unlike that of either of my friends. On hearing this word, Felix came up hastily to the lady, who, when she saw him, threw up her veil, and I beheld a countenance of angelic beauty and expression. Her hair of a shining raven black, and curiously braided; her eyes were dark, but gentle, although animated; her features of a regular proportion, and her complexion wondrously fair, each cheek tinged with a lovely pink. + +“Felix seemed ravished with delight when he saw her, every trait of sorrow vanished from his face, and it instantly expressed a degree of ecstatic joy, of which I could hardly have believed it capable; his eyes sparkled, as his cheek flushed with pleasure; and at that moment I thought him as beautiful as the stranger. She appeared affected by different feelings; wiping a few tears from her lovely eyes, she held out her hand to Felix, who kissed it rapturously and called her, as well as I could distinguish, his sweet Arabian. She did not appear to understand him, but smiled. He assisted her to dismount, and dismissing her guide, conducted her into the cottage. Some conversation took place between him and his father, and the young stranger knelt at the old man’s feet and would have kissed his hand, but he raised her and embraced her affectionately. + +“I soon perceived that although the stranger uttered articulate sounds and appeared to have a language of her own, she was neither understood by nor herself understood the cottagers. They made many signs which I did not comprehend, but I saw that her presence diffused gladness through the cottage, dispelling their sorrow as the sun dissipates the morning mists. Felix seemed peculiarly happy and with smiles of delight welcomed his Arabian. Agatha, the ever-gentle Agatha, kissed the hands of the lovely stranger, and pointing to her brother, made signs which appeared to me to mean that he had been sorrowful until she came. Some hours passed thus, while they, by their countenances, expressed joy, the cause of which I did not comprehend. Presently I found, by the frequent recurrence of some sound which the stranger repeated after them, that she was endeavouring to learn their language; and the idea instantly occurred to me that I should make use of the same instructions to the same end. The stranger learned about twenty words at the first lesson; most of them, indeed, were those which I had before understood, but I profited by the others. + +“As night came on, Agatha and the Arabian retired early. When they separated Felix kissed the hand of the stranger and said, ‘Good night sweet Safie.’ He sat up much longer, conversing with his father, and by the frequent repetition of her name I conjectured that their lovely guest was the subject of their conversation. I ardently desired to understand them, and bent every faculty towards that purpose, but found it utterly impossible. + +“The next morning Felix went out to his work, and after the usual occupations of Agatha were finished, the Arabian sat at the feet of the old man, and taking his guitar, played some airs so entrancingly beautiful that they at once drew tears of sorrow and delight from my eyes. She sang, and her voice flowed in a rich cadence, swelling or dying away like a nightingale of the woods. + +“When she had finished, she gave the guitar to Agatha, who at first declined it. She played a simple air, and her voice accompanied it in sweet accents, but unlike the wondrous strain of the stranger. The old man appeared enraptured and said some words which Agatha endeavoured to explain to Safie, and by which he appeared to wish to express that she bestowed on him the greatest delight by her music. + +“The days now passed as peaceably as before, with the sole alteration that joy had taken place of sadness in the countenances of my friends. Safie was always gay and happy; she and I improved rapidly in the knowledge of language, so that in two months I began to comprehend most of the words uttered by my protectors. + +“In the meanwhile also the black ground was covered with herbage, and the green banks interspersed with innumerable flowers, sweet to the scent and the eyes, stars of pale radiance among the moonlight woods; the sun became warmer, the nights clear and balmy; and my nocturnal rambles were an extreme pleasure to me, although they were considerably shortened by the late setting and early rising of the sun, for I never ventured abroad during daylight, fearful of meeting with the same treatment I had formerly endured in the first village which I entered. + +“My days were spent in close attention, that I might more speedily master the language; and I may boast that I improved more rapidly than the Arabian, who understood very little and conversed in broken accents, whilst I comprehended and could imitate almost every word that was spoken. + +“While I improved in speech, I also learned the science of letters as it was taught to the stranger, and this opened before me a wide field for wonder and delight. + +“The book from which Felix instructed Safie was Volney’s Ruins of Empires. I should not have understood the purport of this book had not Felix, in reading it, given very minute explanations. He had chosen this work, he said, because the declamatory style was framed in imitation of the Eastern authors. Through this work I obtained a cursory knowledge of history and a view of the several empires at present existing in the world; it gave me an insight into the manners, governments, and religions of the different nations of the earth. I heard of the slothful Asiatics, of the stupendous genius and mental activity of the Grecians, of the wars and wonderful virtue of the early Romans—of their subsequent degenerating—of the decline of that mighty empire, of chivalry, Christianity, and kings. I heard of the discovery of the American hemisphere and wept with Safie over the hapless fate of its original inhabitants. + +“These wonderful narrations inspired me with strange feelings. Was man, indeed, at once so powerful, so virtuous and magnificent, yet so vicious and base? He appeared at one time a mere scion of the evil principle and at another as all that can be conceived of noble and godlike. To be a great and virtuous man appeared the highest honour that can befall a sensitive being; to be base and vicious, as many on record have been, appeared the lowest degradation, a condition more abject than that of the blind mole or harmless worm. For a long time I could not conceive how one man could go forth to murder his fellow, or even why there were laws and governments; but when I heard details of vice and bloodshed, my wonder ceased and I turned away with disgust and loathing. + +“Every conversation of the cottagers now opened new wonders to me. While I listened to the instructions which Felix bestowed upon the Arabian, the strange system of human society was explained to me. I heard of the division of property, of immense wealth and squalid poverty, of rank, descent, and noble blood. + +“The words induced me to turn towards myself. I learned that the possessions most esteemed by your fellow creatures were high and unsullied descent united with riches. A man might be respected with only one of these advantages, but without either he was considered, except in very rare instances, as a vagabond and a slave, doomed to waste his powers for the profits of the chosen few! And what was I? Of my creation and creator I was absolutely ignorant, but I knew that I possessed no money, no friends, no kind of property. I was, besides, endued with a figure hideously deformed and loathsome; I was not even of the same nature as man. I was more agile than they and could subsist upon coarser diet; I bore the extremes of heat and cold with less injury to my frame; my stature far exceeded theirs. When I looked around I saw and heard of none like me. Was I, then, a monster, a blot upon the earth, from which all men fled and whom all men disowned? + +“I cannot describe to you the agony that these reflections inflicted upon me; I tried to dispel them, but sorrow only increased with knowledge. Oh, that I had for ever remained in my native wood, nor known nor felt beyond the sensations of hunger, thirst, and heat! + +“Of what a strange nature is knowledge! It clings to the mind when it has once seized on it like a lichen on the rock. I wished sometimes to shake off all thought and feeling, but I learned that there was but one means to overcome the sensation of pain, and that was death—a state which I feared yet did not understand. I admired virtue and good feelings and loved the gentle manners and amiable qualities of my cottagers, but I was shut out from intercourse with them, except through means which I obtained by stealth, when I was unseen and unknown, and which rather increased than satisfied the desire I had of becoming one among my fellows. The gentle words of Agatha and the animated smiles of the charming Arabian were not for me. The mild exhortations of the old man and the lively conversation of the loved Felix were not for me. Miserable, unhappy wretch! + +“Other lessons were impressed upon me even more deeply. I heard of the difference of sexes, and the birth and growth of children, how the father doted on the smiles of the infant, and the lively sallies of the older child, how all the life and cares of the mother were wrapped up in the precious charge, how the mind of youth expanded and gained knowledge, of brother, sister, and all the various relationships which bind one human being to another in mutual bonds. + +“But where were my friends and relations? No father had watched my infant days, no mother had blessed me with smiles and caresses; or if they had, all my past life was now a blot, a blind vacancy in which I distinguished nothing. From my earliest remembrance I had been as I then was in height and proportion. I had never yet seen a being resembling me or who claimed any intercourse with me. What was I? The question again recurred, to be answered only with groans. + +“I will soon explain to what these feelings tended, but allow me now to return to the cottagers, whose story excited in me such various feelings of indignation, delight, and wonder, but which all terminated in additional love and reverence for my protectors (for so I loved, in an innocent, half-painful self-deceit, to call them).” +Chapter 14 + +“Some time elapsed before I learned the history of my friends. It was one which could not fail to impress itself deeply on my mind, unfolding as it did a number of circumstances, each interesting and wonderful to one so utterly inexperienced as I was. + +“The name of the old man was De Lacey. He was descended from a good family in France, where he had lived for many years in affluence, respected by his superiors and beloved by his equals. His son was bred in the service of his country, and Agatha had ranked with ladies of the highest distinction. A few months before my arrival they had lived in a large and luxurious city called Paris, surrounded by friends and possessed of every enjoyment which virtue, refinement of intellect, or taste, accompanied by a moderate fortune, could afford. + +“The father of Safie had been the cause of their ruin. He was a Turkish merchant and had inhabited Paris for many years, when, for some reason which I could not learn, he became obnoxious to the government. He was seized and cast into prison the very day that Safie arrived from Constantinople to join him. He was tried and condemned to death. The injustice of his sentence was very flagrant; all Paris was indignant; and it was judged that his religion and wealth rather than the crime alleged against him had been the cause of his condemnation. + +“Felix had accidentally been present at the trial; his horror and indignation were uncontrollable when he heard the decision of the court. He made, at that moment, a solemn vow to deliver him and then looked around for the means. After many fruitless attempts to gain admittance to the prison, he found a strongly grated window in an unguarded part of the building, which lighted the dungeon of the unfortunate Muhammadan, who, loaded with chains, waited in despair the execution of the barbarous sentence. Felix visited the grate at night and made known to the prisoner his intentions in his favour. The Turk, amazed and delighted, endeavoured to kindle the zeal of his deliverer by promises of reward and wealth. Felix rejected his offers with contempt, yet when he saw the lovely Safie, who was allowed to visit her father and who by her gestures expressed her lively gratitude, the youth could not help owning to his own mind that the captive possessed a treasure which would fully reward his toil and hazard. + +“The Turk quickly perceived the impression that his daughter had made on the heart of Felix and endeavoured to secure him more entirely in his interests by the promise of her hand in marriage so soon as he should be conveyed to a place of safety. Felix was too delicate to accept this offer, yet he looked forward to the probability of the event as to the consummation of his happiness. + +“During the ensuing days, while the preparations were going forward for the escape of the merchant, the zeal of Felix was warmed by several letters that he received from this lovely girl, who found means to express her thoughts in the language of her lover by the aid of an old man, a servant of her father who understood French. She thanked him in the most ardent terms for his intended services towards her parent, and at the same time she gently deplored her own fate. + +“I have copies of these letters, for I found means, during my residence in the hovel, to procure the implements of writing; and the letters were often in the hands of Felix or Agatha. Before I depart I will give them to you; they will prove the truth of my tale; but at present, as the sun is already far declined, I shall only have time to repeat the substance of them to you. + +“Safie related that her mother was a Christian Arab, seized and made a slave by the Turks; recommended by her beauty, she had won the heart of the father of Safie, who married her. The young girl spoke in high and enthusiastic terms of her mother, who, born in freedom, spurned the bondage to which she was now reduced. She instructed her daughter in the tenets of her religion and taught her to aspire to higher powers of intellect and an independence of spirit forbidden to the female followers of Muhammad. This lady died, but her lessons were indelibly impressed on the mind of Safie, who sickened at the prospect of again returning to Asia and being immured within the walls of a harem, allowed only to occupy herself with infantile amusements, ill-suited to the temper of her soul, now accustomed to grand ideas and a noble emulation for virtue. The prospect of marrying a Christian and remaining in a country where women were allowed to take a rank in society was enchanting to her. + +“The day for the execution of the Turk was fixed, but on the night previous to it he quitted his prison and before morning was distant many leagues from Paris. Felix had procured passports in the name of his father, sister, and himself. He had previously communicated his plan to the former, who aided the deceit by quitting his house, under the pretence of a journey and concealed himself, with his daughter, in an obscure part of Paris. + +“Felix conducted the fugitives through France to Lyons and across Mont Cenis to Leghorn, where the merchant had decided to wait a favourable opportunity of passing into some part of the Turkish dominions. + +“Safie resolved to remain with her father until the moment of his departure, before which time the Turk renewed his promise that she should be united to his deliverer; and Felix remained with them in expectation of that event; and in the meantime he enjoyed the society of the Arabian, who exhibited towards him the simplest and tenderest affection. They conversed with one another through the means of an interpreter, and sometimes with the interpretation of looks; and Safie sang to him the divine airs of her native country. + +“The Turk allowed this intimacy to take place and encouraged the hopes of the youthful lovers, while in his heart he had formed far other plans. He loathed the idea that his daughter should be united to a Christian, but he feared the resentment of Felix if he should appear lukewarm, for he knew that he was still in the power of his deliverer if he should choose to betray him to the Italian state which they inhabited. He revolved a thousand plans by which he should be enabled to prolong the deceit until it might be no longer necessary, and secretly to take his daughter with him when he departed. His plans were facilitated by the news which arrived from Paris. + +“The government of France were greatly enraged at the escape of their victim and spared no pains to detect and punish his deliverer. The plot of Felix was quickly discovered, and De Lacey and Agatha were thrown into prison. The news reached Felix and roused him from his dream of pleasure. His blind and aged father and his gentle sister lay in a noisome dungeon while he enjoyed the free air and the society of her whom he loved. This idea was torture to him. He quickly arranged with the Turk that if the latter should find a favourable opportunity for escape before Felix could return to Italy, Safie should remain as a boarder at a convent at Leghorn; and then, quitting the lovely Arabian, he hastened to Paris and delivered himself up to the vengeance of the law, hoping to free De Lacey and Agatha by this proceeding. + +“He did not succeed. They remained confined for five months before the trial took place, the result of which deprived them of their fortune and condemned them to a perpetual exile from their native country. + +“They found a miserable asylum in the cottage in Germany, where I discovered them. Felix soon learned that the treacherous Turk, for whom he and his family endured such unheard-of oppression, on discovering that his deliverer was thus reduced to poverty and ruin, became a traitor to good feeling and honour and had quitted Italy with his daughter, insultingly sending Felix a pittance of money to aid him, as he said, in some plan of future maintenance. + +“Such were the events that preyed on the heart of Felix and rendered him, when I first saw him, the most miserable of his family. He could have endured poverty, and while this distress had been the meed of his virtue, he gloried in it; but the ingratitude of the Turk and the loss of his beloved Safie were misfortunes more bitter and irreparable. The arrival of the Arabian now infused new life into his soul. + +“When the news reached Leghorn that Felix was deprived of his wealth and rank, the merchant commanded his daughter to think no more of her lover, but to prepare to return to her native country. The generous nature of Safie was outraged by this command; she attempted to expostulate with her father, but he left her angrily, reiterating his tyrannical mandate. + +“A few days after, the Turk entered his daughter’s apartment and told her hastily that he had reason to believe that his residence at Leghorn had been divulged and that he should speedily be delivered up to the French government; he had consequently hired a vessel to convey him to Constantinople, for which city he should sail in a few hours. He intended to leave his daughter under the care of a confidential servant, to follow at her leisure with the greater part of his property, which had not yet arrived at Leghorn. + +“When alone, Safie resolved in her own mind the plan of conduct that it would become her to pursue in this emergency. A residence in Turkey was abhorrent to her; her religion and her feelings were alike averse to it. By some papers of her father which fell into her hands she heard of the exile of her lover and learnt the name of the spot where he then resided. She hesitated some time, but at length she formed her determination. Taking with her some jewels that belonged to her and a sum of money, she quitted Italy with an attendant, a native of Leghorn, but who understood the common language of Turkey, and departed for Germany. + +“She arrived in safety at a town about twenty leagues from the cottage of De Lacey, when her attendant fell dangerously ill. Safie nursed her with the most devoted affection, but the poor girl died, and the Arabian was left alone, unacquainted with the language of the country and utterly ignorant of the customs of the world. She fell, however, into good hands. The Italian had mentioned the name of the spot for which they were bound, and after her death the woman of the house in which they had lived took care that Safie should arrive in safety at the cottage of her lover.” +Chapter 15 + +“Such was the history of my beloved cottagers. It impressed me deeply. I learned, from the views of social life which it developed, to admire their virtues and to deprecate the vices of mankind. + +“As yet I looked upon crime as a distant evil, benevolence and generosity were ever present before me, inciting within me a desire to become an actor in the busy scene where so many admirable qualities were called forth and displayed. But in giving an account of the progress of my intellect, I must not omit a circumstance which occurred in the beginning of the month of August of the same year. + +“One night during my accustomed visit to the neighbouring wood where I collected my own food and brought home firing for my protectors, I found on the ground a leathern portmanteau containing several articles of dress and some books. I eagerly seized the prize and returned with it to my hovel. Fortunately the books were written in the language, the elements of which I had acquired at the cottage; they consisted of Paradise Lost, a volume of Plutarch’s Lives, and the Sorrows of Werter. The possession of these treasures gave me extreme delight; I now continually studied and exercised my mind upon these histories, whilst my friends were employed in their ordinary occupations. + +“I can hardly describe to you the effect of these books. They produced in me an infinity of new images and feelings, that sometimes raised me to ecstasy, but more frequently sunk me into the lowest dejection. In the Sorrows of Werter, besides the interest of its simple and affecting story, so many opinions are canvassed and so many lights thrown upon what had hitherto been to me obscure subjects that I found in it a never-ending source of speculation and astonishment. The gentle and domestic manners it described, combined with lofty sentiments and feelings, which had for their object something out of self, accorded well with my experience among my protectors and with the wants which were for ever alive in my own bosom. But I thought Werter himself a more divine being than I had ever beheld or imagined; his character contained no pretension, but it sank deep. The disquisitions upon death and suicide were calculated to fill me with wonder. I did not pretend to enter into the merits of the case, yet I inclined towards the opinions of the hero, whose extinction I wept, without precisely understanding it. + +“As I read, however, I applied much personally to my own feelings and condition. I found myself similar yet at the same time strangely unlike to the beings concerning whom I read and to whose conversation I was a listener. I sympathised with and partly understood them, but I was unformed in mind; I was dependent on none and related to none. ‘The path of my departure was free,’ and there was none to lament my annihilation. My person was hideous and my stature gigantic. What did this mean? Who was I? What was I? Whence did I come? What was my destination? These questions continually recurred, but I was unable to solve them. + +“The volume of Plutarch’s Lives which I possessed contained the histories of the first founders of the ancient republics. This book had a far different effect upon me from the Sorrows of Werter. I learned from Werter’s imaginations despondency and gloom, but Plutarch taught me high thoughts; he elevated me above the wretched sphere of my own reflections, to admire and love the heroes of past ages. Many things I read surpassed my understanding and experience. I had a very confused knowledge of kingdoms, wide extents of country, mighty rivers, and boundless seas. But I was perfectly unacquainted with towns and large assemblages of men. The cottage of my protectors had been the only school in which I had studied human nature, but this book developed new and mightier scenes of action. I read of men concerned in public affairs, governing or massacring their species. I felt the greatest ardour for virtue rise within me, and abhorrence for vice, as far as I understood the signification of those terms, relative as they were, as I applied them, to pleasure and pain alone. Induced by these feelings, I was of course led to admire peaceable lawgivers, Numa, Solon, and Lycurgus, in preference to Romulus and Theseus. The patriarchal lives of my protectors caused these impressions to take a firm hold on my mind; perhaps, if my first introduction to humanity had been made by a young soldier, burning for glory and slaughter, I should have been imbued with different sensations. + +“But Paradise Lost excited different and far deeper emotions. I read it, as I had read the other volumes which had fallen into my hands, as a true history. It moved every feeling of wonder and awe that the picture of an omnipotent God warring with his creatures was capable of exciting. I often referred the several situations, as their similarity struck me, to my own. Like Adam, I was apparently united by no link to any other being in existence; but his state was far different from mine in every other respect. He had come forth from the hands of God a perfect creature, happy and prosperous, guarded by the especial care of his Creator; he was allowed to converse with and acquire knowledge from beings of a superior nature, but I was wretched, helpless, and alone. Many times I considered Satan as the fitter emblem of my condition, for often, like him, when I viewed the bliss of my protectors, the bitter gall of envy rose within me. + +“Another circumstance strengthened and confirmed these feelings. Soon after my arrival in the hovel I discovered some papers in the pocket of the dress which I had taken from your laboratory. At first I had neglected them, but now that I was able to decipher the characters in which they were written, I began to study them with diligence. It was your journal of the four months that preceded my creation. You minutely described in these papers every step you took in the progress of your work; this history was mingled with accounts of domestic occurrences. You doubtless recollect these papers. Here they are. Everything is related in them which bears reference to my accursed origin; the whole detail of that series of disgusting circumstances which produced it is set in view; the minutest description of my odious and loathsome person is given, in language which painted your own horrors and rendered mine indelible. I sickened as I read. ‘Hateful day when I received life!’ I exclaimed in agony. ‘Accursed creator! Why did you form a monster so hideous that even you turned from me in disgust? God, in pity, made man beautiful and alluring, after his own image; but my form is a filthy type of yours, more horrid even from the very resemblance. Satan had his companions, fellow devils, to admire and encourage him, but I am solitary and abhorred.’ + +“These were the reflections of my hours of despondency and solitude; but when I contemplated the virtues of the cottagers, their amiable and benevolent dispositions, I persuaded myself that when they should become acquainted with my admiration of their virtues they would compassionate me and overlook my personal deformity. Could they turn from their door one, however monstrous, who solicited their compassion and friendship? I resolved, at least, not to despair, but in every way to fit myself for an interview with them which would decide my fate. I postponed this attempt for some months longer, for the importance attached to its success inspired me with a dread lest I should fail. Besides, I found that my understanding improved so much with every day’s experience that I was unwilling to commence this undertaking until a few more months should have added to my sagacity. + +“Several changes, in the meantime, took place in the cottage. The presence of Safie diffused happiness among its inhabitants, and I also found that a greater degree of plenty reigned there. Felix and Agatha spent more time in amusement and conversation, and were assisted in their labours by servants. They did not appear rich, but they were contented and happy; their feelings were serene and peaceful, while mine became every day more tumultuous. Increase of knowledge only discovered to me more clearly what a wretched outcast I was. I cherished hope, it is true, but it vanished when I beheld my person reflected in water or my shadow in the moonshine, even as that frail image and that inconstant shade. + +“I endeavoured to crush these fears and to fortify myself for the trial which in a few months I resolved to undergo; and sometimes I allowed my thoughts, unchecked by reason, to ramble in the fields of Paradise, and dared to fancy amiable and lovely creatures sympathising with my feelings and cheering my gloom; their angelic countenances breathed smiles of consolation. But it was all a dream; no Eve soothed my sorrows nor shared my thoughts; I was alone. I remembered Adam’s supplication to his Creator. But where was mine? He had abandoned me, and in the bitterness of my heart I cursed him. + +“Autumn passed thus. I saw, with surprise and grief, the leaves decay and fall, and nature again assume the barren and bleak appearance it had worn when I first beheld the woods and the lovely moon. Yet I did not heed the bleakness of the weather; I was better fitted by my conformation for the endurance of cold than heat. But my chief delights were the sight of the flowers, the birds, and all the gay apparel of summer; when those deserted me, I turned with more attention towards the cottagers. Their happiness was not decreased by the absence of summer. They loved and sympathised with one another; and their joys, depending on each other, were not interrupted by the casualties that took place around them. The more I saw of them, the greater became my desire to claim their protection and kindness; my heart yearned to be known and loved by these amiable creatures; to see their sweet looks directed towards me with affection was the utmost limit of my ambition. I dared not think that they would turn them from me with disdain and horror. The poor that stopped at their door were never driven away. I asked, it is true, for greater treasures than a little food or rest: I required kindness and sympathy; but I did not believe myself utterly unworthy of it. + +“The winter advanced, and an entire revolution of the seasons had taken place since I awoke into life. My attention at this time was solely directed towards my plan of introducing myself into the cottage of my protectors. I revolved many projects, but that on which I finally fixed was to enter the dwelling when the blind old man should be alone. I had sagacity enough to discover that the unnatural hideousness of my person was the chief object of horror with those who had formerly beheld me. My voice, although harsh, had nothing terrible in it; I thought, therefore, that if in the absence of his children I could gain the good will and mediation of the old De Lacey, I might by his means be tolerated by my younger protectors. + +“One day, when the sun shone on the red leaves that strewed the ground and diffused cheerfulness, although it denied warmth, Safie, Agatha, and Felix departed on a long country walk, and the old man, at his own desire, was left alone in the cottage. When his children had departed, he took up his guitar and played several mournful but sweet airs, more sweet and mournful than I had ever heard him play before. At first his countenance was illuminated with pleasure, but as he continued, thoughtfulness and sadness succeeded; at length, laying aside the instrument, he sat absorbed in reflection. + +“My heart beat quick; this was the hour and moment of trial, which would decide my hopes or realise my fears. The servants were gone to a neighbouring fair. All was silent in and around the cottage; it was an excellent opportunity; yet, when I proceeded to execute my plan, my limbs failed me and I sank to the ground. Again I rose, and exerting all the firmness of which I was master, removed the planks which I had placed before my hovel to conceal my retreat. The fresh air revived me, and with renewed determination I approached the door of their cottage. + +“I knocked. ‘Who is there?’ said the old man. ‘Come in.’ + +“I entered. ‘Pardon this intrusion,’ said I; ‘I am a traveller in want of a little rest; you would greatly oblige me if you would allow me to remain a few minutes before the fire.’ + +“‘Enter,’ said De Lacey, ‘and I will try in what manner I can to relieve your wants; but, unfortunately, my children are from home, and as I am blind, I am afraid I shall find it difficult to procure food for you.’ + +“‘Do not trouble yourself, my kind host; I have food; it is warmth and rest only that I need.’ + +“I sat down, and a silence ensued. I knew that every minute was precious to me, yet I remained irresolute in what manner to commence the interview, when the old man addressed me. + +‘By your language, stranger, I suppose you are my countryman; are you French?’ + +“‘No; but I was educated by a French family and understand that language only. I am now going to claim the protection of some friends, whom I sincerely love, and of whose favour I have some hopes.’ + +“‘Are they Germans?’ + +“‘No, they are French. But let us change the subject. I am an unfortunate and deserted creature, I look around and I have no relation or friend upon earth. These amiable people to whom I go have never seen me and know little of me. I am full of fears, for if I fail there, I am an outcast in the world for ever.’ + +“‘Do not despair. To be friendless is indeed to be unfortunate, but the hearts of men, when unprejudiced by any obvious self-interest, are full of brotherly love and charity. Rely, therefore, on your hopes; and if these friends are good and amiable, do not despair.’ + +“‘They are kind—they are the most excellent creatures in the world; but, unfortunately, they are prejudiced against me. I have good dispositions; my life has been hitherto harmless and in some degree beneficial; but a fatal prejudice clouds their eyes, and where they ought to see a feeling and kind friend, they behold only a detestable monster.’ + +“‘That is indeed unfortunate; but if you are really blameless, cannot you undeceive them?’ + +“‘I am about to undertake that task; and it is on that account that I feel so many overwhelming terrors. I tenderly love these friends; I have, unknown to them, been for many months in the habits of daily kindness towards them; but they believe that I wish to injure them, and it is that prejudice which I wish to overcome.’ + +“‘Where do these friends reside?’ + +“‘Near this spot.’ + +“The old man paused and then continued, ‘If you will unreservedly confide to me the particulars of your tale, I perhaps may be of use in undeceiving them. I am blind and cannot judge of your countenance, but there is something in your words which persuades me that you are sincere. I am poor and an exile, but it will afford me true pleasure to be in any way serviceable to a human creature.’ + +“‘Excellent man! I thank you and accept your generous offer. You raise me from the dust by this kindness; and I trust that, by your aid, I shall not be driven from the society and sympathy of your fellow creatures.’ + +“‘Heaven forbid! Even if you were really criminal, for that can only drive you to desperation, and not instigate you to virtue. I also am unfortunate; I and my family have been condemned, although innocent; judge, therefore, if I do not feel for your misfortunes.’ + +“‘How can I thank you, my best and only benefactor? From your lips first have I heard the voice of kindness directed towards me; I shall be for ever grateful; and your present humanity assures me of success with those friends whom I am on the point of meeting.’ + +“‘May I know the names and residence of those friends?’ + +“I paused. This, I thought, was the moment of decision, which was to rob me of or bestow happiness on me for ever. I struggled vainly for firmness sufficient to answer him, but the effort destroyed all my remaining strength; I sank on the chair and sobbed aloud. At that moment I heard the steps of my younger protectors. I had not a moment to lose, but seizing the hand of the old man, I cried, ‘Now is the time! Save and protect me! You and your family are the friends whom I seek. Do not you desert me in the hour of trial!’ + +“‘Great God!’ exclaimed the old man. ‘Who are you?’ + +“At that instant the cottage door was opened, and Felix, Safie, and Agatha entered. Who can describe their horror and consternation on beholding me? Agatha fainted, and Safie, unable to attend to her friend, rushed out of the cottage. Felix darted forward, and with supernatural force tore me from his father, to whose knees I clung, in a transport of fury, he dashed me to the ground and struck me violently with a stick. I could have torn him limb from limb, as the lion rends the antelope. But my heart sank within me as with bitter sickness, and I refrained. I saw him on the point of repeating his blow, when, overcome by pain and anguish, I quitted the cottage, and in the general tumult escaped unperceived to my hovel.” +Chapter 16 + +“Cursed, cursed creator! Why did I live? Why, in that instant, did I not extinguish the spark of existence which you had so wantonly bestowed? I know not; despair had not yet taken possession of me; my feelings were those of rage and revenge. I could with pleasure have destroyed the cottage and its inhabitants and have glutted myself with their shrieks and misery. + +“When night came I quitted my retreat and wandered in the wood; and now, no longer restrained by the fear of discovery, I gave vent to my anguish in fearful howlings. I was like a wild beast that had broken the toils, destroying the objects that obstructed me and ranging through the wood with a stag-like swiftness. Oh! What a miserable night I passed! The cold stars shone in mockery, and the bare trees waved their branches above me; now and then the sweet voice of a bird burst forth amidst the universal stillness. All, save I, were at rest or in enjoyment; I, like the arch-fiend, bore a hell within me, and finding myself unsympathised with, wished to tear up the trees, spread havoc and destruction around me, and then to have sat down and enjoyed the ruin. + +“But this was a luxury of sensation that could not endure; I became fatigued with excess of bodily exertion and sank on the damp grass in the sick impotence of despair. There was none among the myriads of men that existed who would pity or assist me; and should I feel kindness towards my enemies? No; from that moment I declared everlasting war against the species, and more than all, against him who had formed me and sent me forth to this insupportable misery. + +“The sun rose; I heard the voices of men and knew that it was impossible to return to my retreat during that day. Accordingly I hid myself in some thick underwood, determining to devote the ensuing hours to reflection on my situation. + +“The pleasant sunshine and the pure air of day restored me to some degree of tranquillity; and when I considered what had passed at the cottage, I could not help believing that I had been too hasty in my conclusions. I had certainly acted imprudently. It was apparent that my conversation had interested the father in my behalf, and I was a fool in having exposed my person to the horror of his children. I ought to have familiarised the old De Lacey to me, and by degrees to have discovered myself to the rest of his family, when they should have been prepared for my approach. But I did not believe my errors to be irretrievable, and after much consideration I resolved to return to the cottage, seek the old man, and by my representations win him to my party. + +“These thoughts calmed me, and in the afternoon I sank into a profound sleep; but the fever of my blood did not allow me to be visited by peaceful dreams. The horrible scene of the preceding day was for ever acting before my eyes; the females were flying and the enraged Felix tearing me from his father’s feet. I awoke exhausted, and finding that it was already night, I crept forth from my hiding-place, and went in search of food. + +“When my hunger was appeased, I directed my steps towards the well-known path that conducted to the cottage. All there was at peace. I crept into my hovel and remained in silent expectation of the accustomed hour when the family arose. That hour passed, the sun mounted high in the heavens, but the cottagers did not appear. I trembled violently, apprehending some dreadful misfortune. The inside of the cottage was dark, and I heard no motion; I cannot describe the agony of this suspense. + +“Presently two countrymen passed by, but pausing near the cottage, they entered into conversation, using violent gesticulations; but I did not understand what they said, as they spoke the language of the country, which differed from that of my protectors. Soon after, however, Felix approached with another man; I was surprised, as I knew that he had not quitted the cottage that morning, and waited anxiously to discover from his discourse the meaning of these unusual appearances. + +“‘Do you consider,’ said his companion to him, ‘that you will be obliged to pay three months’ rent and to lose the produce of your garden? I do not wish to take any unfair advantage, and I beg therefore that you will take some days to consider of your determination.’ + +“‘It is utterly useless,’ replied Felix; ‘we can never again inhabit your cottage. The life of my father is in the greatest danger, owing to the dreadful circumstance that I have related. My wife and my sister will never recover from their horror. I entreat you not to reason with me any more. Take possession of your tenement and let me fly from this place.’ + +“Felix trembled violently as he said this. He and his companion entered the cottage, in which they remained for a few minutes, and then departed. I never saw any of the family of De Lacey more. + +“I continued for the remainder of the day in my hovel in a state of utter and stupid despair. My protectors had departed and had broken the only link that held me to the world. For the first time the feelings of revenge and hatred filled my bosom, and I did not strive to control them, but allowing myself to be borne away by the stream, I bent my mind towards injury and death. When I thought of my friends, of the mild voice of De Lacey, the gentle eyes of Agatha, and the exquisite beauty of the Arabian, these thoughts vanished and a gush of tears somewhat soothed me. But again when I reflected that they had spurned and deserted me, anger returned, a rage of anger, and unable to injure anything human, I turned my fury towards inanimate objects. As night advanced, I placed a variety of combustibles around the cottage, and after having destroyed every vestige of cultivation in the garden, I waited with forced impatience until the moon had sunk to commence my operations. + +“As the night advanced, a fierce wind arose from the woods and quickly dispersed the clouds that had loitered in the heavens; the blast tore along like a mighty avalanche and produced a kind of insanity in my spirits that burst all bounds of reason and reflection. I lighted the dry branch of a tree and danced with fury around the devoted cottage, my eyes still fixed on the western horizon, the edge of which the moon nearly touched. A part of its orb was at length hid, and I waved my brand; it sank, and with a loud scream I fired the straw, and heath, and bushes, which I had collected. The wind fanned the fire, and the cottage was quickly enveloped by the flames, which clung to it and licked it with their forked and destroying tongues. + +“As soon as I was convinced that no assistance could save any part of the habitation, I quitted the scene and sought for refuge in the woods. + +“And now, with the world before me, whither should I bend my steps? I resolved to fly far from the scene of my misfortunes; but to me, hated and despised, every country must be equally horrible. At length the thought of you crossed my mind. I learned from your papers that you were my father, my creator; and to whom could I apply with more fitness than to him who had given me life? Among the lessons that Felix had bestowed upon Safie, geography had not been omitted; I had learned from these the relative situations of the different countries of the earth. You had mentioned Geneva as the name of your native town, and towards this place I resolved to proceed. + +“But how was I to direct myself? I knew that I must travel in a southwesterly direction to reach my destination, but the sun was my only guide. I did not know the names of the towns that I was to pass through, nor could I ask information from a single human being; but I did not despair. From you only could I hope for succour, although towards you I felt no sentiment but that of hatred. Unfeeling, heartless creator! You had endowed me with perceptions and passions and then cast me abroad an object for the scorn and horror of mankind. But on you only had I any claim for pity and redress, and from you I determined to seek that justice which I vainly attempted to gain from any other being that wore the human form. + +“My travels were long and the sufferings I endured intense. It was late in autumn when I quitted the district where I had so long resided. I travelled only at night, fearful of encountering the visage of a human being. Nature decayed around me, and the sun became heatless; rain and snow poured around me; mighty rivers were frozen; the surface of the earth was hard and chill, and bare, and I found no shelter. Oh, earth! How often did I imprecate curses on the cause of my being! The mildness of my nature had fled, and all within me was turned to gall and bitterness. The nearer I approached to your habitation, the more deeply did I feel the spirit of revenge enkindled in my heart. Snow fell, and the waters were hardened, but I rested not. A few incidents now and then directed me, and I possessed a map of the country; but I often wandered wide from my path. The agony of my feelings allowed me no respite; no incident occurred from which my rage and misery could not extract its food; but a circumstance that happened when I arrived on the confines of Switzerland, when the sun had recovered its warmth and the earth again began to look green, confirmed in an especial manner the bitterness and horror of my feelings. + +“I generally rested during the day and travelled only when I was secured by night from the view of man. One morning, however, finding that my path lay through a deep wood, I ventured to continue my journey after the sun had risen; the day, which was one of the first of spring, cheered even me by the loveliness of its sunshine and the balminess of the air. I felt emotions of gentleness and pleasure, that had long appeared dead, revive within me. Half surprised by the novelty of these sensations, I allowed myself to be borne away by them, and forgetting my solitude and deformity, dared to be happy. Soft tears again bedewed my cheeks, and I even raised my humid eyes with thankfulness towards the blessed sun, which bestowed such joy upon me. + +“I continued to wind among the paths of the wood, until I came to its boundary, which was skirted by a deep and rapid river, into which many of the trees bent their branches, now budding with the fresh spring. Here I paused, not exactly knowing what path to pursue, when I heard the sound of voices, that induced me to conceal myself under the shade of a cypress. I was scarcely hid when a young girl came running towards the spot where I was concealed, laughing, as if she ran from someone in sport. She continued her course along the precipitous sides of the river, when suddenly her foot slipped, and she fell into the rapid stream. I rushed from my hiding-place and with extreme labour, from the force of the current, saved her and dragged her to shore. She was senseless, and I endeavoured by every means in my power to restore animation, when I was suddenly interrupted by the approach of a rustic, who was probably the person from whom she had playfully fled. On seeing me, he darted towards me, and tearing the girl from my arms, hastened towards the deeper parts of the wood. I followed speedily, I hardly knew why; but when the man saw me draw near, he aimed a gun, which he carried, at my body and fired. I sank to the ground, and my injurer, with increased swiftness, escaped into the wood. + +“This was then the reward of my benevolence! I had saved a human being from destruction, and as a recompense I now writhed under the miserable pain of a wound which shattered the flesh and bone. The feelings of kindness and gentleness which I had entertained but a few moments before gave place to hellish rage and gnashing of teeth. Inflamed by pain, I vowed eternal hatred and vengeance to all mankind. But the agony of my wound overcame me; my pulses paused, and I fainted. + +“For some weeks I led a miserable life in the woods, endeavouring to cure the wound which I had received. The ball had entered my shoulder, and I knew not whether it had remained there or passed through; at any rate I had no means of extracting it. My sufferings were augmented also by the oppressive sense of the injustice and ingratitude of their infliction. My daily vows rose for revenge—a deep and deadly revenge, such as would alone compensate for the outrages and anguish I had endured. + +“After some weeks my wound healed, and I continued my journey. The labours I endured were no longer to be alleviated by the bright sun or gentle breezes of spring; all joy was but a mockery which insulted my desolate state and made me feel more painfully that I was not made for the enjoyment of pleasure. + +“But my toils now drew near a close, and in two months from this time I reached the environs of Geneva. + +“It was evening when I arrived, and I retired to a hiding-place among the fields that surround it to meditate in what manner I should apply to you. I was oppressed by fatigue and hunger and far too unhappy to enjoy the gentle breezes of evening or the prospect of the sun setting behind the stupendous mountains of Jura. + +“At this time a slight sleep relieved me from the pain of reflection, which was disturbed by the approach of a beautiful child, who came running into the recess I had chosen, with all the sportiveness of infancy. Suddenly, as I gazed on him, an idea seized me that this little creature was unprejudiced and had lived too short a time to have imbibed a horror of deformity. If, therefore, I could seize him and educate him as my companion and friend, I should not be so desolate in this peopled earth. + +“Urged by this impulse, I seized on the boy as he passed and drew him towards me. As soon as he beheld my form, he placed his hands before his eyes and uttered a shrill scream; I drew his hand forcibly from his face and said, ‘Child, what is the meaning of this? I do not intend to hurt you; listen to me.’ + +“He struggled violently. ‘Let me go,’ he cried; ‘monster! Ugly wretch! You wish to eat me and tear me to pieces. You are an ogre. Let me go, or I will tell my papa.’ + +“‘Boy, you will never see your father again; you must come with me.’ + +“‘Hideous monster! Let me go. My papa is a syndic—he is M. Frankenstein—he will punish you. You dare not keep me.’ + +“‘Frankenstein! you belong then to my enemy—to him towards whom I have sworn eternal revenge; you shall be my first victim.’ + +“The child still struggled and loaded me with epithets which carried despair to my heart; I grasped his throat to silence him, and in a moment he lay dead at my feet. + +“I gazed on my victim, and my heart swelled with exultation and hellish triumph; clapping my hands, I exclaimed, ‘I too can create desolation; my enemy is not invulnerable; this death will carry despair to him, and a thousand other miseries shall torment and destroy him.’ + +“As I fixed my eyes on the child, I saw something glittering on his breast. I took it; it was a portrait of a most lovely woman. In spite of my malignity, it softened and attracted me. For a few moments I gazed with delight on her dark eyes, fringed by deep lashes, and her lovely lips; but presently my rage returned; I remembered that I was for ever deprived of the delights that such beautiful creatures could bestow and that she whose resemblance I contemplated would, in regarding me, have changed that air of divine benignity to one expressive of disgust and affright. + +“Can you wonder that such thoughts transported me with rage? I only wonder that at that moment, instead of venting my sensations in exclamations and agony, I did not rush among mankind and perish in the attempt to destroy them. + +“While I was overcome by these feelings, I left the spot where I had committed the murder, and seeking a more secluded hiding-place, I entered a barn which had appeared to me to be empty. A woman was sleeping on some straw; she was young, not indeed so beautiful as her whose portrait I held, but of an agreeable aspect and blooming in the loveliness of youth and health. Here, I thought, is one of those whose joy-imparting smiles are bestowed on all but me. And then I bent over her and whispered, ‘Awake, fairest, thy lover is near—he who would give his life but to obtain one look of affection from thine eyes; my beloved, awake!’ + +“The sleeper stirred; a thrill of terror ran through me. Should she indeed awake, and see me, and curse me, and denounce the murderer? Thus would she assuredly act if her darkened eyes opened and she beheld me. The thought was madness; it stirred the fiend within me—not I, but she, shall suffer; the murder I have committed because I am for ever robbed of all that she could give me, she shall atone. The crime had its source in her; be hers the punishment! Thanks to the lessons of Felix and the sanguinary laws of man, I had learned now to work mischief. I bent over her and placed the portrait securely in one of the folds of her dress. She moved again, and I fled. + +“For some days I haunted the spot where these scenes had taken place, sometimes wishing to see you, sometimes resolved to quit the world and its miseries for ever. At length I wandered towards these mountains, and have ranged through their immense recesses, consumed by a burning passion which you alone can gratify. We may not part until you have promised to comply with my requisition. I am alone and miserable; man will not associate with me; but one as deformed and horrible as myself would not deny herself to me. My companion must be of the same species and have the same defects. This being you must create.” +Chapter 17 + +The being finished speaking and fixed his looks upon me in the expectation of a reply. But I was bewildered, perplexed, and unable to arrange my ideas sufficiently to understand the full extent of his proposition. He continued, + +“You must create a female for me with whom I can live in the interchange of those sympathies necessary for my being. This you alone can do, and I demand it of you as a right which you must not refuse to concede.” + +The latter part of his tale had kindled anew in me the anger that had died away while he narrated his peaceful life among the cottagers, and as he said this I could no longer suppress the rage that burned within me. + +“I do refuse it,” I replied; “and no torture shall ever extort a consent from me. You may render me the most miserable of men, but you shall never make me base in my own eyes. Shall I create another like yourself, whose joint wickedness might desolate the world. Begone! I have answered you; you may torture me, but I will never consent.” + +“You are in the wrong,” replied the fiend; “and instead of threatening, I am content to reason with you. I am malicious because I am miserable. Am I not shunned and hated by all mankind? You, my creator, would tear me to pieces and triumph; remember that, and tell me why I should pity man more than he pities me? You would not call it murder if you could precipitate me into one of those ice-rifts and destroy my frame, the work of your own hands. Shall I respect man when he condemns me? Let him live with me in the interchange of kindness, and instead of injury I would bestow every benefit upon him with tears of gratitude at his acceptance. But that cannot be; the human senses are insurmountable barriers to our union. Yet mine shall not be the submission of abject slavery. I will revenge my injuries; if I cannot inspire love, I will cause fear, and chiefly towards you my arch-enemy, because my creator, do I swear inextinguishable hatred. Have a care; I will work at your destruction, nor finish until I desolate your heart, so that you shall curse the hour of your birth.” + +A fiendish rage animated him as he said this; his face was wrinkled into contortions too horrible for human eyes to behold; but presently he calmed himself and proceeded— + +“I intended to reason. This passion is detrimental to me, for you do not reflect that you are the cause of its excess. If any being felt emotions of benevolence towards me, I should return them a hundred and a hundredfold; for that one creature’s sake I would make peace with the whole kind! But I now indulge in dreams of bliss that cannot be realised. What I ask of you is reasonable and moderate; I demand a creature of another sex, but as hideous as myself; the gratification is small, but it is all that I can receive, and it shall content me. It is true, we shall be monsters, cut off from all the world; but on that account we shall be more attached to one another. Our lives will not be happy, but they will be harmless and free from the misery I now feel. Oh! My creator, make me happy; let me feel gratitude towards you for one benefit! Let me see that I excite the sympathy of some existing thing; do not deny me my request!” + +I was moved. I shuddered when I thought of the possible consequences of my consent, but I felt that there was some justice in his argument. His tale and the feelings he now expressed proved him to be a creature of fine sensations, and did I not as his maker owe him all the portion of happiness that it was in my power to bestow? He saw my change of feeling and continued, + +“If you consent, neither you nor any other human being shall ever see us again; I will go to the vast wilds of South America. My food is not that of man; I do not destroy the lamb and the kid to glut my appetite; acorns and berries afford me sufficient nourishment. My companion will be of the same nature as myself and will be content with the same fare. We shall make our bed of dried leaves; the sun will shine on us as on man and will ripen our food. The picture I present to you is peaceful and human, and you must feel that you could deny it only in the wantonness of power and cruelty. Pitiless as you have been towards me, I now see compassion in your eyes; let me seize the favourable moment and persuade you to promise what I so ardently desire.” + +“You propose,” replied I, “to fly from the habitations of man, to dwell in those wilds where the beasts of the field will be your only companions. How can you, who long for the love and sympathy of man, persevere in this exile? You will return and again seek their kindness, and you will meet with their detestation; your evil passions will be renewed, and you will then have a companion to aid you in the task of destruction. This may not be; cease to argue the point, for I cannot consent.” + +“How inconstant are your feelings! But a moment ago you were moved by my representations, and why do you again harden yourself to my complaints? I swear to you, by the earth which I inhabit, and by you that made me, that with the companion you bestow, I will quit the neighbourhood of man and dwell, as it may chance, in the most savage of places. My evil passions will have fled, for I shall meet with sympathy! My life will flow quietly away, and in my dying moments I shall not curse my maker.” + +His words had a strange effect upon me. I compassionated him and sometimes felt a wish to console him, but when I looked upon him, when I saw the filthy mass that moved and talked, my heart sickened and my feelings were altered to those of horror and hatred. I tried to stifle these sensations; I thought that as I could not sympathise with him, I had no right to withhold from him the small portion of happiness which was yet in my power to bestow. + +“You swear,” I said, “to be harmless; but have you not already shown a degree of malice that should reasonably make me distrust you? May not even this be a feint that will increase your triumph by affording a wider scope for your revenge?” + +“How is this? I must not be trifled with, and I demand an answer. If I have no ties and no affections, hatred and vice must be my portion; the love of another will destroy the cause of my crimes, and I shall become a thing of whose existence everyone will be ignorant. My vices are the children of a forced solitude that I abhor, and my virtues will necessarily arise when I live in communion with an equal. I shall feel the affections of a sensitive being and become linked to the chain of existence and events from which I am now excluded.” + +I paused some time to reflect on all he had related and the various arguments which he had employed. I thought of the promise of virtues which he had displayed on the opening of his existence and the subsequent blight of all kindly feeling by the loathing and scorn which his protectors had manifested towards him. His power and threats were not omitted in my calculations; a creature who could exist in the ice-caves of the glaciers and hide himself from pursuit among the ridges of inaccessible precipices was a being possessing faculties it would be vain to cope with. After a long pause of reflection I concluded that the justice due both to him and my fellow creatures demanded of me that I should comply with his request. Turning to him, therefore, I said, + +“I consent to your demand, on your solemn oath to quit Europe for ever, and every other place in the neighbourhood of man, as soon as I shall deliver into your hands a female who will accompany you in your exile.” + +“I swear,” he cried, “by the sun, and by the blue sky of heaven, and by the fire of love that burns my heart, that if you grant my prayer, while they exist you shall never behold me again. Depart to your home and commence your labours; I shall watch their progress with unutterable anxiety; and fear not but that when you are ready I shall appear.” + +Saying this, he suddenly quitted me, fearful, perhaps, of any change in my sentiments. I saw him descend the mountain with greater speed than the flight of an eagle, and quickly lost among the undulations of the sea of ice. + +His tale had occupied the whole day, and the sun was upon the verge of the horizon when he departed. I knew that I ought to hasten my descent towards the valley, as I should soon be encompassed in darkness; but my heart was heavy, and my steps slow. The labour of winding among the little paths of the mountain and fixing my feet firmly as I advanced perplexed me, occupied as I was by the emotions which the occurrences of the day had produced. Night was far advanced when I came to the halfway resting-place and seated myself beside the fountain. The stars shone at intervals as the clouds passed from over them; the dark pines rose before me, and every here and there a broken tree lay on the ground; it was a scene of wonderful solemnity and stirred strange thoughts within me. I wept bitterly, and clasping my hands in agony, I exclaimed, “Oh! stars and clouds and winds, ye are all about to mock me; if ye really pity me, crush sensation and memory; let me become as nought; but if not, depart, depart, and leave me in darkness.” + +These were wild and miserable thoughts, but I cannot describe to you how the eternal twinkling of the stars weighed upon me and how I listened to every blast of wind as if it were a dull ugly siroc on its way to consume me. + +Morning dawned before I arrived at the village of Chamounix; I took no rest, but returned immediately to Geneva. Even in my own heart I could give no expression to my sensations—they weighed on me with a mountain’s weight and their excess destroyed my agony beneath them. Thus I returned home, and entering the house, presented myself to the family. My haggard and wild appearance awoke intense alarm, but I answered no question, scarcely did I speak. I felt as if I were placed under a ban—as if I had no right to claim their sympathies—as if never more might I enjoy companionship with them. Yet even thus I loved them to adoration; and to save them, I resolved to dedicate myself to my most abhorred task. The prospect of such an occupation made every other circumstance of existence pass before me like a dream, and that thought only had to me the reality of life. +Chapter 18 + +Day after day, week after week, passed away on my return to Geneva; and I could not collect the courage to recommence my work. I feared the vengeance of the disappointed fiend, yet I was unable to overcome my repugnance to the task which was enjoined me. I found that I could not compose a female without again devoting several months to profound study and laborious disquisition. I had heard of some discoveries having been made by an English philosopher, the knowledge of which was material to my success, and I sometimes thought of obtaining my father’s consent to visit England for this purpose; but I clung to every pretence of delay and shrank from taking the first step in an undertaking whose immediate necessity began to appear less absolute to me. A change indeed had taken place in me; my health, which had hitherto declined, was now much restored; and my spirits, when unchecked by the memory of my unhappy promise, rose proportionably. My father saw this change with pleasure, and he turned his thoughts towards the best method of eradicating the remains of my melancholy, which every now and then would return by fits, and with a devouring blackness overcast the approaching sunshine. At these moments I took refuge in the most perfect solitude. I passed whole days on the lake alone in a little boat, watching the clouds and listening to the rippling of the waves, silent and listless. But the fresh air and bright sun seldom failed to restore me to some degree of composure, and on my return I met the salutations of my friends with a readier smile and a more cheerful heart. + +It was after my return from one of these rambles that my father, calling me aside, thus addressed me, + +“I am happy to remark, my dear son, that you have resumed your former pleasures and seem to be returning to yourself. And yet you are still unhappy and still avoid our society. For some time I was lost in conjecture as to the cause of this, but yesterday an idea struck me, and if it is well founded, I conjure you to avow it. Reserve on such a point would be not only useless, but draw down treble misery on us all.” + +I trembled violently at his exordium, and my father continued— + +“I confess, my son, that I have always looked forward to your marriage with our dear Elizabeth as the tie of our domestic comfort and the stay of my declining years. You were attached to each other from your earliest infancy; you studied together, and appeared, in dispositions and tastes, entirely suited to one another. But so blind is the experience of man that what I conceived to be the best assistants to my plan may have entirely destroyed it. You, perhaps, regard her as your sister, without any wish that she might become your wife. Nay, you may have met with another whom you may love; and considering yourself as bound in honour to Elizabeth, this struggle may occasion the poignant misery which you appear to feel.” + +“My dear father, reassure yourself. I love my cousin tenderly and sincerely. I never saw any woman who excited, as Elizabeth does, my warmest admiration and affection. My future hopes and prospects are entirely bound up in the expectation of our union.” + +“The expression of your sentiments of this subject, my dear Victor, gives me more pleasure than I have for some time experienced. If you feel thus, we shall assuredly be happy, however present events may cast a gloom over us. But it is this gloom which appears to have taken so strong a hold of your mind that I wish to dissipate. Tell me, therefore, whether you object to an immediate solemnisation of the marriage. We have been unfortunate, and recent events have drawn us from that everyday tranquillity befitting my years and infirmities. You are younger; yet I do not suppose, possessed as you are of a competent fortune, that an early marriage would at all interfere with any future plans of honour and utility that you may have formed. Do not suppose, however, that I wish to dictate happiness to you or that a delay on your part would cause me any serious uneasiness. Interpret my words with candour and answer me, I conjure you, with confidence and sincerity.” + +I listened to my father in silence and remained for some time incapable of offering any reply. I revolved rapidly in my mind a multitude of thoughts and endeavoured to arrive at some conclusion. Alas! To me the idea of an immediate union with my Elizabeth was one of horror and dismay. I was bound by a solemn promise which I had not yet fulfilled and dared not break, or if I did, what manifold miseries might not impend over me and my devoted family! Could I enter into a festival with this deadly weight yet hanging round my neck and bowing me to the ground? I must perform my engagement and let the monster depart with his mate before I allowed myself to enjoy the delight of a union from which I expected peace. + +I remembered also the necessity imposed upon me of either journeying to England or entering into a long correspondence with those philosophers of that country whose knowledge and discoveries were of indispensable use to me in my present undertaking. The latter method of obtaining the desired intelligence was dilatory and unsatisfactory; besides, I had an insurmountable aversion to the idea of engaging myself in my loathsome task in my father’s house while in habits of familiar intercourse with those I loved. I knew that a thousand fearful accidents might occur, the slightest of which would disclose a tale to thrill all connected with me with horror. I was aware also that I should often lose all self-command, all capacity of hiding the harrowing sensations that would possess me during the progress of my unearthly occupation. I must absent myself from all I loved while thus employed. Once commenced, it would quickly be achieved, and I might be restored to my family in peace and happiness. My promise fulfilled, the monster would depart for ever. Or (so my fond fancy imaged) some accident might meanwhile occur to destroy him and put an end to my slavery for ever. + +These feelings dictated my answer to my father. I expressed a wish to visit England, but concealing the true reasons of this request, I clothed my desires under a guise which excited no suspicion, while I urged my desire with an earnestness that easily induced my father to comply. After so long a period of an absorbing melancholy that resembled madness in its intensity and effects, he was glad to find that I was capable of taking pleasure in the idea of such a journey, and he hoped that change of scene and varied amusement would, before my return, have restored me entirely to myself. + +The duration of my absence was left to my own choice; a few months, or at most a year, was the period contemplated. One paternal kind precaution he had taken to ensure my having a companion. Without previously communicating with me, he had, in concert with Elizabeth, arranged that Clerval should join me at Strasburgh. This interfered with the solitude I coveted for the prosecution of my task; yet at the commencement of my journey the presence of my friend could in no way be an impediment, and truly I rejoiced that thus I should be saved many hours of lonely, maddening reflection. Nay, Henry might stand between me and the intrusion of my foe. If I were alone, would he not at times force his abhorred presence on me to remind me of my task or to contemplate its progress? + +To England, therefore, I was bound, and it was understood that my union with Elizabeth should take place immediately on my return. My father’s age rendered him extremely averse to delay. For myself, there was one reward I promised myself from my detested toils—one consolation for my unparalleled sufferings; it was the prospect of that day when, enfranchised from my miserable slavery, I might claim Elizabeth and forget the past in my union with her. + +I now made arrangements for my journey, but one feeling haunted me which filled me with fear and agitation. During my absence I should leave my friends unconscious of the existence of their enemy and unprotected from his attacks, exasperated as he might be by my departure. But he had promised to follow me wherever I might go, and would he not accompany me to England? This imagination was dreadful in itself, but soothing inasmuch as it supposed the safety of my friends. I was agonised with the idea of the possibility that the reverse of this might happen. But through the whole period during which I was the slave of my creature I allowed myself to be governed by the impulses of the moment; and my present sensations strongly intimated that the fiend would follow me and exempt my family from the danger of his machinations. + +It was in the latter end of September that I again quitted my native country. My journey had been my own suggestion, and Elizabeth therefore acquiesced, but she was filled with disquiet at the idea of my suffering, away from her, the inroads of misery and grief. It had been her care which provided me a companion in Clerval—and yet a man is blind to a thousand minute circumstances which call forth a woman’s sedulous attention. She longed to bid me hasten my return; a thousand conflicting emotions rendered her mute as she bade me a tearful, silent farewell. + +I threw myself into the carriage that was to convey me away, hardly knowing whither I was going, and careless of what was passing around. I remembered only, and it was with a bitter anguish that I reflected on it, to order that my chemical instruments should be packed to go with me. Filled with dreary imaginations, I passed through many beautiful and majestic scenes, but my eyes were fixed and unobserving. I could only think of the bourne of my travels and the work which was to occupy me whilst they endured. + +After some days spent in listless indolence, during which I traversed many leagues, I arrived at Strasburgh, where I waited two days for Clerval. He came. Alas, how great was the contrast between us! He was alive to every new scene, joyful when he saw the beauties of the setting sun, and more happy when he beheld it rise and recommence a new day. He pointed out to me the shifting colours of the landscape and the appearances of the sky. “This is what it is to live,” he cried; “now I enjoy existence! But you, my dear Frankenstein, wherefore are you desponding and sorrowful!” In truth, I was occupied by gloomy thoughts and neither saw the descent of the evening star nor the golden sunrise reflected in the Rhine. And you, my friend, would be far more amused with the journal of Clerval, who observed the scenery with an eye of feeling and delight, than in listening to my reflections. I, a miserable wretch, haunted by a curse that shut up every avenue to enjoyment. + +We had agreed to descend the Rhine in a boat from Strasburgh to Rotterdam, whence we might take shipping for London. During this voyage we passed many willowy islands and saw several beautiful towns. We stayed a day at Mannheim, and on the fifth from our departure from Strasburgh, arrived at Mainz. The course of the Rhine below Mainz becomes much more picturesque. The river descends rapidly and winds between hills, not high, but steep, and of beautiful forms. We saw many ruined castles standing on the edges of precipices, surrounded by black woods, high and inaccessible. This part of the Rhine, indeed, presents a singularly variegated landscape. In one spot you view rugged hills, ruined castles overlooking tremendous precipices, with the dark Rhine rushing beneath; and on the sudden turn of a promontory, flourishing vineyards with green sloping banks and a meandering river and populous towns occupy the scene. + +We travelled at the time of the vintage and heard the song of the labourers as we glided down the stream. Even I, depressed in mind, and my spirits continually agitated by gloomy feelings, even I was pleased. I lay at the bottom of the boat, and as I gazed on the cloudless blue sky, I seemed to drink in a tranquillity to which I had long been a stranger. And if these were my sensations, who can describe those of Henry? He felt as if he had been transported to Fairy-land and enjoyed a happiness seldom tasted by man. “I have seen,” he said, “the most beautiful scenes of my own country; I have visited the lakes of Lucerne and Uri, where the snowy mountains descend almost perpendicularly to the water, casting black and impenetrable shades, which would cause a gloomy and mournful appearance were it not for the most verdant islands that relieve the eye by their gay appearance; I have seen this lake agitated by a tempest, when the wind tore up whirlwinds of water and gave you an idea of what the water-spout must be on the great ocean; and the waves dash with fury the base of the mountain, where the priest and his mistress were overwhelmed by an avalanche and where their dying voices are still said to be heard amid the pauses of the nightly wind; I have seen the mountains of La Valais, and the Pays de Vaud; but this country, Victor, pleases me more than all those wonders. The mountains of Switzerland are more majestic and strange, but there is a charm in the banks of this divine river that I never before saw equalled. Look at that castle which overhangs yon precipice; and that also on the island, almost concealed amongst the foliage of those lovely trees; and now that group of labourers coming from among their vines; and that village half hid in the recess of the mountain. Oh, surely the spirit that inhabits and guards this place has a soul more in harmony with man than those who pile the glacier or retire to the inaccessible peaks of the mountains of our own country.” + +Clerval! Beloved friend! Even now it delights me to record your words and to dwell on the praise of which you are so eminently deserving. He was a being formed in the “very poetry of nature.” His wild and enthusiastic imagination was chastened by the sensibility of his heart. His soul overflowed with ardent affections, and his friendship was of that devoted and wondrous nature that the worldly-minded teach us to look for only in the imagination. But even human sympathies were not sufficient to satisfy his eager mind. The scenery of external nature, which others regard only with admiration, he loved with ardour:— + +——The sounding cataract +Haunted him like a passion: the tall rock, +The mountain, and the deep and gloomy wood, +Their colours and their forms, were then to him +An appetite; a feeling, and a love, +That had no need of a remoter charm, +By thought supplied, or any interest +Unborrow’d from the eye. + +[Wordsworth’s “Tintern Abbey”.] + +And where does he now exist? Is this gentle and lovely being lost for ever? Has this mind, so replete with ideas, imaginations fanciful and magnificent, which formed a world, whose existence depended on the life of its creator;—has this mind perished? Does it now only exist in my memory? No, it is not thus; your form so divinely wrought, and beaming with beauty, has decayed, but your spirit still visits and consoles your unhappy friend. + +Pardon this gush of sorrow; these ineffectual words are but a slight tribute to the unexampled worth of Henry, but they soothe my heart, overflowing with the anguish which his remembrance creates. I will proceed with my tale. + +Beyond Cologne we descended to the plains of Holland; and we resolved to post the remainder of our way, for the wind was contrary and the stream of the river was too gentle to aid us. + +Our journey here lost the interest arising from beautiful scenery, but we arrived in a few days at Rotterdam, whence we proceeded by sea to England. It was on a clear morning, in the latter days of December, that I first saw the white cliffs of Britain. The banks of the Thames presented a new scene; they were flat but fertile, and almost every town was marked by the remembrance of some story. We saw Tilbury Fort and remembered the Spanish Armada, Gravesend, Woolwich, and Greenwich—places which I had heard of even in my country. + +At length we saw the numerous steeples of London, St. Paul’s towering above all, and the Tower famed in English history. +Chapter 19 + +London was our present point of rest; we determined to remain several months in this wonderful and celebrated city. Clerval desired the intercourse of the men of genius and talent who flourished at this time, but this was with me a secondary object; I was principally occupied with the means of obtaining the information necessary for the completion of my promise and quickly availed myself of the letters of introduction that I had brought with me, addressed to the most distinguished natural philosophers. + +If this journey had taken place during my days of study and happiness, it would have afforded me inexpressible pleasure. But a blight had come over my existence, and I only visited these people for the sake of the information they might give me on the subject in which my interest was so terribly profound. Company was irksome to me; when alone, I could fill my mind with the sights of heaven and earth; the voice of Henry soothed me, and I could thus cheat myself into a transitory peace. But busy, uninteresting, joyous faces brought back despair to my heart. I saw an insurmountable barrier placed between me and my fellow men; this barrier was sealed with the blood of William and Justine, and to reflect on the events connected with those names filled my soul with anguish. + +But in Clerval I saw the image of my former self; he was inquisitive and anxious to gain experience and instruction. The difference of manners which he observed was to him an inexhaustible source of instruction and amusement. He was also pursuing an object he had long had in view. His design was to visit India, in the belief that he had in his knowledge of its various languages, and in the views he had taken of its society, the means of materially assisting the progress of European colonization and trade. In Britain only could he further the execution of his plan. He was for ever busy, and the only check to his enjoyments was my sorrowful and dejected mind. I tried to conceal this as much as possible, that I might not debar him from the pleasures natural to one who was entering on a new scene of life, undisturbed by any care or bitter recollection. I often refused to accompany him, alleging another engagement, that I might remain alone. I now also began to collect the materials necessary for my new creation, and this was to me like the torture of single drops of water continually falling on the head. Every thought that was devoted to it was an extreme anguish, and every word that I spoke in allusion to it caused my lips to quiver, and my heart to palpitate. + +After passing some months in London, we received a letter from a person in Scotland who had formerly been our visitor at Geneva. He mentioned the beauties of his native country and asked us if those were not sufficient allurements to induce us to prolong our journey as far north as Perth, where he resided. Clerval eagerly desired to accept this invitation, and I, although I abhorred society, wished to view again mountains and streams and all the wondrous works with which Nature adorns her chosen dwelling-places. + +We had arrived in England at the beginning of October, and it was now February. We accordingly determined to commence our journey towards the north at the expiration of another month. In this expedition we did not intend to follow the great road to Edinburgh, but to visit Windsor, Oxford, Matlock, and the Cumberland lakes, resolving to arrive at the completion of this tour about the end of July. I packed up my chemical instruments and the materials I had collected, resolving to finish my labours in some obscure nook in the northern highlands of Scotland. + +We quitted London on the 27th of March and remained a few days at Windsor, rambling in its beautiful forest. This was a new scene to us mountaineers; the majestic oaks, the quantity of game, and the herds of stately deer were all novelties to us. + +From thence we proceeded to Oxford. As we entered this city, our minds were filled with the remembrance of the events that had been transacted there more than a century and a half before. It was here that Charles I. had collected his forces. This city had remained faithful to him, after the whole nation had forsaken his cause to join the standard of Parliament and liberty. The memory of that unfortunate king and his companions, the amiable Falkland, the insolent Goring, his queen, and son, gave a peculiar interest to every part of the city which they might be supposed to have inhabited. The spirit of elder days found a dwelling here, and we delighted to trace its footsteps. If these feelings had not found an imaginary gratification, the appearance of the city had yet in itself sufficient beauty to obtain our admiration. The colleges are ancient and picturesque; the streets are almost magnificent; and the lovely Isis, which flows beside it through meadows of exquisite verdure, is spread forth into a placid expanse of waters, which reflects its majestic assemblage of towers, and spires, and domes, embosomed among aged trees. + +I enjoyed this scene, and yet my enjoyment was embittered both by the memory of the past and the anticipation of the future. I was formed for peaceful happiness. During my youthful days discontent never visited my mind, and if I was ever overcome by ennui, the sight of what is beautiful in nature or the study of what is excellent and sublime in the productions of man could always interest my heart and communicate elasticity to my spirits. But I am a blasted tree; the bolt has entered my soul; and I felt then that I should survive to exhibit what I shall soon cease to be—a miserable spectacle of wrecked humanity, pitiable to others and intolerable to myself. + +We passed a considerable period at Oxford, rambling among its environs and endeavouring to identify every spot which might relate to the most animating epoch of English history. Our little voyages of discovery were often prolonged by the successive objects that presented themselves. We visited the tomb of the illustrious Hampden and the field on which that patriot fell. For a moment my soul was elevated from its debasing and miserable fears to contemplate the divine ideas of liberty and self-sacrifice of which these sights were the monuments and the remembrancers. For an instant I dared to shake off my chains and look around me with a free and lofty spirit, but the iron had eaten into my flesh, and I sank again, trembling and hopeless, into my miserable self. + +We left Oxford with regret and proceeded to Matlock, which was our next place of rest. The country in the neighbourhood of this village resembled, to a greater degree, the scenery of Switzerland; but everything is on a lower scale, and the green hills want the crown of distant white Alps which always attend on the piny mountains of my native country. We visited the wondrous cave and the little cabinets of natural history, where the curiosities are disposed in the same manner as in the collections at Servox and Chamounix. The latter name made me tremble when pronounced by Henry, and I hastened to quit Matlock, with which that terrible scene was thus associated. + +From Derby, still journeying northwards, we passed two months in Cumberland and Westmorland. I could now almost fancy myself among the Swiss mountains. The little patches of snow which yet lingered on the northern sides of the mountains, the lakes, and the dashing of the rocky streams were all familiar and dear sights to me. Here also we made some acquaintances, who almost contrived to cheat me into happiness. The delight of Clerval was proportionably greater than mine; his mind expanded in the company of men of talent, and he found in his own nature greater capacities and resources than he could have imagined himself to have possessed while he associated with his inferiors. “I could pass my life here,” said he to me; “and among these mountains I should scarcely regret Switzerland and the Rhine.” + +But he found that a traveller’s life is one that includes much pain amidst its enjoyments. His feelings are for ever on the stretch; and when he begins to sink into repose, he finds himself obliged to quit that on which he rests in pleasure for something new, which again engages his attention, and which also he forsakes for other novelties. + +We had scarcely visited the various lakes of Cumberland and Westmorland and conceived an affection for some of the inhabitants when the period of our appointment with our Scotch friend approached, and we left them to travel on. For my own part I was not sorry. I had now neglected my promise for some time, and I feared the effects of the dæmon’s disappointment. He might remain in Switzerland and wreak his vengeance on my relatives. This idea pursued me and tormented me at every moment from which I might otherwise have snatched repose and peace. I waited for my letters with feverish impatience; if they were delayed I was miserable and overcome by a thousand fears; and when they arrived and I saw the superscription of Elizabeth or my father, I hardly dared to read and ascertain my fate. Sometimes I thought that the fiend followed me and might expedite my remissness by murdering my companion. When these thoughts possessed me, I would not quit Henry for a moment, but followed him as his shadow, to protect him from the fancied rage of his destroyer. I felt as if I had committed some great crime, the consciousness of which haunted me. I was guiltless, but I had indeed drawn down a horrible curse upon my head, as mortal as that of crime. + +I visited Edinburgh with languid eyes and mind; and yet that city might have interested the most unfortunate being. Clerval did not like it so well as Oxford, for the antiquity of the latter city was more pleasing to him. But the beauty and regularity of the new town of Edinburgh, its romantic castle and its environs, the most delightful in the world, Arthur’s Seat, St. Bernard’s Well, and the Pentland Hills, compensated him for the change and filled him with cheerfulness and admiration. But I was impatient to arrive at the termination of my journey. + +We left Edinburgh in a week, passing through Coupar, St. Andrew’s, and along the banks of the Tay, to Perth, where our friend expected us. But I was in no mood to laugh and talk with strangers or enter into their feelings or plans with the good humour expected from a guest; and accordingly I told Clerval that I wished to make the tour of Scotland alone. “Do you,” said I, “enjoy yourself, and let this be our rendezvous. I may be absent a month or two; but do not interfere with my motions, I entreat you; leave me to peace and solitude for a short time; and when I return, I hope it will be with a lighter heart, more congenial to your own temper.” + +Henry wished to dissuade me, but seeing me bent on this plan, ceased to remonstrate. He entreated me to write often. “I had rather be with you,” he said, “in your solitary rambles, than with these Scotch people, whom I do not know; hasten, then, my dear friend, to return, that I may again feel myself somewhat at home, which I cannot do in your absence.” + +Having parted from my friend, I determined to visit some remote spot of Scotland and finish my work in solitude. I did not doubt but that the monster followed me and would discover himself to me when I should have finished, that he might receive his companion. + +With this resolution I traversed the northern highlands and fixed on one of the remotest of the Orkneys as the scene of my labours. It was a place fitted for such a work, being hardly more than a rock whose high sides were continually beaten upon by the waves. The soil was barren, scarcely affording pasture for a few miserable cows, and oatmeal for its inhabitants, which consisted of five persons, whose gaunt and scraggy limbs gave tokens of their miserable fare. Vegetables and bread, when they indulged in such luxuries, and even fresh water, was to be procured from the mainland, which was about five miles distant. + +On the whole island there were but three miserable huts, and one of these was vacant when I arrived. This I hired. It contained but two rooms, and these exhibited all the squalidness of the most miserable penury. The thatch had fallen in, the walls were unplastered, and the door was off its hinges. I ordered it to be repaired, bought some furniture, and took possession, an incident which would doubtless have occasioned some surprise had not all the senses of the cottagers been benumbed by want and squalid poverty. As it was, I lived ungazed at and unmolested, hardly thanked for the pittance of food and clothes which I gave, so much does suffering blunt even the coarsest sensations of men. + +In this retreat I devoted the morning to labour; but in the evening, when the weather permitted, I walked on the stony beach of the sea to listen to the waves as they roared and dashed at my feet. It was a monotonous yet ever-changing scene. I thought of Switzerland; it was far different from this desolate and appalling landscape. Its hills are covered with vines, and its cottages are scattered thickly in the plains. Its fair lakes reflect a blue and gentle sky, and when troubled by the winds, their tumult is but as the play of a lively infant when compared to the roarings of the giant ocean. + +In this manner I distributed my occupations when I first arrived, but as I proceeded in my labour, it became every day more horrible and irksome to me. Sometimes I could not prevail on myself to enter my laboratory for several days, and at other times I toiled day and night in order to complete my work. It was, indeed, a filthy process in which I was engaged. During my first experiment, a kind of enthusiastic frenzy had blinded me to the horror of my employment; my mind was intently fixed on the consummation of my labour, and my eyes were shut to the horror of my proceedings. But now I went to it in cold blood, and my heart often sickened at the work of my hands. + +Thus situated, employed in the most detestable occupation, immersed in a solitude where nothing could for an instant call my attention from the actual scene in which I was engaged, my spirits became unequal; I grew restless and nervous. Every moment I feared to meet my persecutor. Sometimes I sat with my eyes fixed on the ground, fearing to raise them lest they should encounter the object which I so much dreaded to behold. I feared to wander from the sight of my fellow creatures lest when alone he should come to claim his companion. + +In the mean time I worked on, and my labour was already considerably advanced. I looked towards its completion with a tremulous and eager hope, which I dared not trust myself to question but which was intermixed with obscure forebodings of evil that made my heart sicken in my bosom. +Chapter 20 + +I sat one evening in my laboratory; the sun had set, and the moon was just rising from the sea; I had not sufficient light for my employment, and I remained idle, in a pause of consideration of whether I should leave my labour for the night or hasten its conclusion by an unremitting attention to it. As I sat, a train of reflection occurred to me which led me to consider the effects of what I was now doing. Three years before, I was engaged in the same manner and had created a fiend whose unparalleled barbarity had desolated my heart and filled it for ever with the bitterest remorse. I was now about to form another being of whose dispositions I was alike ignorant; she might become ten thousand times more malignant than her mate and delight, for its own sake, in murder and wretchedness. He had sworn to quit the neighbourhood of man and hide himself in deserts, but she had not; and she, who in all probability was to become a thinking and reasoning animal, might refuse to comply with a compact made before her creation. They might even hate each other; the creature who already lived loathed his own deformity, and might he not conceive a greater abhorrence for it when it came before his eyes in the female form? She also might turn with disgust from him to the superior beauty of man; she might quit him, and he be again alone, exasperated by the fresh provocation of being deserted by one of his own species. + +Even if they were to leave Europe and inhabit the deserts of the new world, yet one of the first results of those sympathies for which the dæmon thirsted would be children, and a race of devils would be propagated upon the earth who might make the very existence of the species of man a condition precarious and full of terror. Had I right, for my own benefit, to inflict this curse upon everlasting generations? I had before been moved by the sophisms of the being I had created; I had been struck senseless by his fiendish threats; but now, for the first time, the wickedness of my promise burst upon me; I shuddered to think that future ages might curse me as their pest, whose selfishness had not hesitated to buy its own peace at the price, perhaps, of the existence of the whole human race. + +I trembled and my heart failed within me, when, on looking up, I saw by the light of the moon the dæmon at the casement. A ghastly grin wrinkled his lips as he gazed on me, where I sat fulfilling the task which he had allotted to me. Yes, he had followed me in my travels; he had loitered in forests, hid himself in caves, or taken refuge in wide and desert heaths; and he now came to mark my progress and claim the fulfilment of my promise. + +As I looked on him, his countenance expressed the utmost extent of malice and treachery. I thought with a sensation of madness on my promise of creating another like to him, and trembling with passion, tore to pieces the thing on which I was engaged. The wretch saw me destroy the creature on whose future existence he depended for happiness, and with a howl of devilish despair and revenge, withdrew. + +I left the room, and locking the door, made a solemn vow in my own heart never to resume my labours; and then, with trembling steps, I sought my own apartment. I was alone; none were near me to dissipate the gloom and relieve me from the sickening oppression of the most terrible reveries. + +Several hours passed, and I remained near my window gazing on the sea; it was almost motionless, for the winds were hushed, and all nature reposed under the eye of the quiet moon. A few fishing vessels alone specked the water, and now and then the gentle breeze wafted the sound of voices as the fishermen called to one another. I felt the silence, although I was hardly conscious of its extreme profundity, until my ear was suddenly arrested by the paddling of oars near the shore, and a person landed close to my house. + +In a few minutes after, I heard the creaking of my door, as if some one endeavoured to open it softly. I trembled from head to foot; I felt a presentiment of who it was and wished to rouse one of the peasants who dwelt in a cottage not far from mine; but I was overcome by the sensation of helplessness, so often felt in frightful dreams, when you in vain endeavour to fly from an impending danger, and was rooted to the spot. + +Presently I heard the sound of footsteps along the passage; the door opened, and the wretch whom I dreaded appeared. Shutting the door, he approached me and said in a smothered voice, + +“You have destroyed the work which you began; what is it that you intend? Do you dare to break your promise? I have endured toil and misery; I left Switzerland with you; I crept along the shores of the Rhine, among its willow islands and over the summits of its hills. I have dwelt many months in the heaths of England and among the deserts of Scotland. I have endured incalculable fatigue, and cold, and hunger; do you dare destroy my hopes?” + +“Begone! I do break my promise; never will I create another like yourself, equal in deformity and wickedness.” + +“Slave, I before reasoned with you, but you have proved yourself unworthy of my condescension. Remember that I have power; you believe yourself miserable, but I can make you so wretched that the light of day will be hateful to you. You are my creator, but I am your master; obey!” + +“The hour of my irresolution is past, and the period of your power is arrived. Your threats cannot move me to do an act of wickedness; but they confirm me in a determination of not creating you a companion in vice. Shall I, in cool blood, set loose upon the earth a dæmon whose delight is in death and wretchedness? Begone! I am firm, and your words will only exasperate my rage.” + +The monster saw my determination in my face and gnashed his teeth in the impotence of anger. “Shall each man,” cried he, “find a wife for his bosom, and each beast have his mate, and I be alone? I had feelings of affection, and they were requited by detestation and scorn. Man! You may hate, but beware! Your hours will pass in dread and misery, and soon the bolt will fall which must ravish from you your happiness for ever. Are you to be happy while I grovel in the intensity of my wretchedness? You can blast my other passions, but revenge remains—revenge, henceforth dearer than light or food! I may die, but first you, my tyrant and tormentor, shall curse the sun that gazes on your misery. Beware, for I am fearless and therefore powerful. I will watch with the wiliness of a snake, that I may sting with its venom. Man, you shall repent of the injuries you inflict.” + +“Devil, cease; and do not poison the air with these sounds of malice. I have declared my resolution to you, and I am no coward to bend beneath words. Leave me; I am inexorable.” + +“It is well. I go; but remember, I shall be with you on your wedding-night.” + +I started forward and exclaimed, “Villain! Before you sign my death-warrant, be sure that you are yourself safe.” + +I would have seized him, but he eluded me and quitted the house with precipitation. In a few moments I saw him in his boat, which shot across the waters with an arrowy swiftness and was soon lost amidst the waves. + +All was again silent, but his words rang in my ears. I burned with rage to pursue the murderer of my peace and precipitate him into the ocean. I walked up and down my room hastily and perturbed, while my imagination conjured up a thousand images to torment and sting me. Why had I not followed him and closed with him in mortal strife? But I had suffered him to depart, and he had directed his course towards the mainland. I shuddered to think who might be the next victim sacrificed to his insatiate revenge. And then I thought again of his words—“I will be with you on your wedding-night.” That, then, was the period fixed for the fulfilment of my destiny. In that hour I should die and at once satisfy and extinguish his malice. The prospect did not move me to fear; yet when I thought of my beloved Elizabeth, of her tears and endless sorrow, when she should find her lover so barbarously snatched from her, tears, the first I had shed for many months, streamed from my eyes, and I resolved not to fall before my enemy without a bitter struggle. + +The night passed away, and the sun rose from the ocean; my feelings became calmer, if it may be called calmness when the violence of rage sinks into the depths of despair. I left the house, the horrid scene of the last night’s contention, and walked on the beach of the sea, which I almost regarded as an insuperable barrier between me and my fellow creatures; nay, a wish that such should prove the fact stole across me. I desired that I might pass my life on that barren rock, wearily, it is true, but uninterrupted by any sudden shock of misery. If I returned, it was to be sacrificed or to see those whom I most loved die under the grasp of a dæmon whom I had myself created. + +I walked about the isle like a restless spectre, separated from all it loved and miserable in the separation. When it became noon, and the sun rose higher, I lay down on the grass and was overpowered by a deep sleep. I had been awake the whole of the preceding night, my nerves were agitated, and my eyes inflamed by watching and misery. The sleep into which I now sank refreshed me; and when I awoke, I again felt as if I belonged to a race of human beings like myself, and I began to reflect upon what had passed with greater composure; yet still the words of the fiend rang in my ears like a death-knell; they appeared like a dream, yet distinct and oppressive as a reality. + +The sun had far descended, and I still sat on the shore, satisfying my appetite, which had become ravenous, with an oaten cake, when I saw a fishing-boat land close to me, and one of the men brought me a packet; it contained letters from Geneva, and one from Clerval entreating me to join him. He said that he was wearing away his time fruitlessly where he was, that letters from the friends he had formed in London desired his return to complete the negotiation they had entered into for his Indian enterprise. He could not any longer delay his departure; but as his journey to London might be followed, even sooner than he now conjectured, by his longer voyage, he entreated me to bestow as much of my society on him as I could spare. He besought me, therefore, to leave my solitary isle and to meet him at Perth, that we might proceed southwards together. This letter in a degree recalled me to life, and I determined to quit my island at the expiration of two days. + +Yet, before I departed, there was a task to perform, on which I shuddered to reflect; I must pack up my chemical instruments, and for that purpose I must enter the room which had been the scene of my odious work, and I must handle those utensils the sight of which was sickening to me. The next morning, at daybreak, I summoned sufficient courage and unlocked the door of my laboratory. The remains of the half-finished creature, whom I had destroyed, lay scattered on the floor, and I almost felt as if I had mangled the living flesh of a human being. I paused to collect myself and then entered the chamber. With trembling hand I conveyed the instruments out of the room, but I reflected that I ought not to leave the relics of my work to excite the horror and suspicion of the peasants; and I accordingly put them into a basket, with a great quantity of stones, and laying them up, determined to throw them into the sea that very night; and in the meantime I sat upon the beach, employed in cleaning and arranging my chemical apparatus. + +Nothing could be more complete than the alteration that had taken place in my feelings since the night of the appearance of the dæmon. I had before regarded my promise with a gloomy despair as a thing that, with whatever consequences, must be fulfilled; but I now felt as if a film had been taken from before my eyes and that I for the first time saw clearly. The idea of renewing my labours did not for one instant occur to me; the threat I had heard weighed on my thoughts, but I did not reflect that a voluntary act of mine could avert it. I had resolved in my own mind that to create another like the fiend I had first made would be an act of the basest and most atrocious selfishness, and I banished from my mind every thought that could lead to a different conclusion. + +Between two and three in the morning the moon rose; and I then, putting my basket aboard a little skiff, sailed out about four miles from the shore. The scene was perfectly solitary; a few boats were returning towards land, but I sailed away from them. I felt as if I was about the commission of a dreadful crime and avoided with shuddering anxiety any encounter with my fellow creatures. At one time the moon, which had before been clear, was suddenly overspread by a thick cloud, and I took advantage of the moment of darkness and cast my basket into the sea; I listened to the gurgling sound as it sank and then sailed away from the spot. The sky became clouded, but the air was pure, although chilled by the northeast breeze that was then rising. But it refreshed me and filled me with such agreeable sensations that I resolved to prolong my stay on the water, and fixing the rudder in a direct position, stretched myself at the bottom of the boat. Clouds hid the moon, everything was obscure, and I heard only the sound of the boat as its keel cut through the waves; the murmur lulled me, and in a short time I slept soundly. + +I do not know how long I remained in this situation, but when I awoke I found that the sun had already mounted considerably. The wind was high, and the waves continually threatened the safety of my little skiff. I found that the wind was northeast and must have driven me far from the coast from which I had embarked. I endeavoured to change my course but quickly found that if I again made the attempt the boat would be instantly filled with water. Thus situated, my only resource was to drive before the wind. I confess that I felt a few sensations of terror. I had no compass with me and was so slenderly acquainted with the geography of this part of the world that the sun was of little benefit to me. I might be driven into the wide Atlantic and feel all the tortures of starvation or be swallowed up in the immeasurable waters that roared and buffeted around me. I had already been out many hours and felt the torment of a burning thirst, a prelude to my other sufferings. I looked on the heavens, which were covered by clouds that flew before the wind, only to be replaced by others; I looked upon the sea; it was to be my grave. “Fiend,” I exclaimed, “your task is already fulfilled!” I thought of Elizabeth, of my father, and of Clerval—all left behind, on whom the monster might satisfy his sanguinary and merciless passions. This idea plunged me into a reverie so despairing and frightful that even now, when the scene is on the point of closing before me for ever, I shudder to reflect on it. + +Some hours passed thus; but by degrees, as the sun declined towards the horizon, the wind died away into a gentle breeze and the sea became free from breakers. But these gave place to a heavy swell; I felt sick and hardly able to hold the rudder, when suddenly I saw a line of high land towards the south. + +Almost spent, as I was, by fatigue and the dreadful suspense I endured for several hours, this sudden certainty of life rushed like a flood of warm joy to my heart, and tears gushed from my eyes. + +How mutable are our feelings, and how strange is that clinging love we have of life even in the excess of misery! I constructed another sail with a part of my dress and eagerly steered my course towards the land. It had a wild and rocky appearance, but as I approached nearer I easily perceived the traces of cultivation. I saw vessels near the shore and found myself suddenly transported back to the neighbourhood of civilised man. I carefully traced the windings of the land and hailed a steeple which I at length saw issuing from behind a small promontory. As I was in a state of extreme debility, I resolved to sail directly towards the town, as a place where I could most easily procure nourishment. Fortunately I had money with me. As I turned the promontory I perceived a small neat town and a good harbour, which I entered, my heart bounding with joy at my unexpected escape. + +As I was occupied in fixing the boat and arranging the sails, several people crowded towards the spot. They seemed much surprised at my appearance, but instead of offering me any assistance, whispered together with gestures that at any other time might have produced in me a slight sensation of alarm. As it was, I merely remarked that they spoke English, and I therefore addressed them in that language. “My good friends,” said I, “will you be so kind as to tell me the name of this town and inform me where I am?” + +“You will know that soon enough,” replied a man with a hoarse voice. “Maybe you are come to a place that will not prove much to your taste, but you will not be consulted as to your quarters, I promise you.” + +I was exceedingly surprised on receiving so rude an answer from a stranger, and I was also disconcerted on perceiving the frowning and angry countenances of his companions. “Why do you answer me so roughly?” I replied. “Surely it is not the custom of Englishmen to receive strangers so inhospitably.” + +“I do not know,” said the man, “what the custom of the English may be, but it is the custom of the Irish to hate villains.” + +While this strange dialogue continued, I perceived the crowd rapidly increase. Their faces expressed a mixture of curiosity and anger, which annoyed and in some degree alarmed me. I inquired the way to the inn, but no one replied. I then moved forward, and a murmuring sound arose from the crowd as they followed and surrounded me, when an ill-looking man approaching tapped me on the shoulder and said, “Come, sir, you must follow me to Mr. Kirwin’s to give an account of yourself.” + +“Who is Mr. Kirwin? Why am I to give an account of myself? Is not this a free country?” + +“Ay, sir, free enough for honest folks. Mr. Kirwin is a magistrate, and you are to give an account of the death of a gentleman who was found murdered here last night.” + +This answer startled me, but I presently recovered myself. I was innocent; that could easily be proved; accordingly I followed my conductor in silence and was led to one of the best houses in the town. I was ready to sink from fatigue and hunger, but being surrounded by a crowd, I thought it politic to rouse all my strength, that no physical debility might be construed into apprehension or conscious guilt. Little did I then expect the calamity that was in a few moments to overwhelm me and extinguish in horror and despair all fear of ignominy or death. + +I must pause here, for it requires all my fortitude to recall the memory of the frightful events which I am about to relate, in proper detail, to my recollection. +Chapter 21 + +I was soon introduced into the presence of the magistrate, an old benevolent man with calm and mild manners. He looked upon me, however, with some degree of severity, and then, turning towards my conductors, he asked who appeared as witnesses on this occasion. + +About half a dozen men came forward; and, one being selected by the magistrate, he deposed that he had been out fishing the night before with his son and brother-in-law, Daniel Nugent, when, about ten o’clock, they observed a strong northerly blast rising, and they accordingly put in for port. It was a very dark night, as the moon had not yet risen; they did not land at the harbour, but, as they had been accustomed, at a creek about two miles below. He walked on first, carrying a part of the fishing tackle, and his companions followed him at some distance. As he was proceeding along the sands, he struck his foot against something and fell at his length on the ground. His companions came up to assist him, and by the light of their lantern they found that he had fallen on the body of a man, who was to all appearance dead. Their first supposition was that it was the corpse of some person who had been drowned and was thrown on shore by the waves, but on examination they found that the clothes were not wet and even that the body was not then cold. They instantly carried it to the cottage of an old woman near the spot and endeavoured, but in vain, to restore it to life. It appeared to be a handsome young man, about five and twenty years of age. He had apparently been strangled, for there was no sign of any violence except the black mark of fingers on his neck. + +The first part of this deposition did not in the least interest me, but when the mark of the fingers was mentioned I remembered the murder of my brother and felt myself extremely agitated; my limbs trembled, and a mist came over my eyes, which obliged me to lean on a chair for support. The magistrate observed me with a keen eye and of course drew an unfavourable augury from my manner. + +The son confirmed his father’s account, but when Daniel Nugent was called he swore positively that just before the fall of his companion, he saw a boat, with a single man in it, at a short distance from the shore; and as far as he could judge by the light of a few stars, it was the same boat in which I had just landed. + +A woman deposed that she lived near the beach and was standing at the door of her cottage, waiting for the return of the fishermen, about an hour before she heard of the discovery of the body, when she saw a boat with only one man in it push off from that part of the shore where the corpse was afterwards found. + +Another woman confirmed the account of the fishermen having brought the body into her house; it was not cold. They put it into a bed and rubbed it, and Daniel went to the town for an apothecary, but life was quite gone. + +Several other men were examined concerning my landing, and they agreed that, with the strong north wind that had arisen during the night, it was very probable that I had beaten about for many hours and had been obliged to return nearly to the same spot from which I had departed. Besides, they observed that it appeared that I had brought the body from another place, and it was likely that as I did not appear to know the shore, I might have put into the harbour ignorant of the distance of the town of —— from the place where I had deposited the corpse. + +Mr. Kirwin, on hearing this evidence, desired that I should be taken into the room where the body lay for interment, that it might be observed what effect the sight of it would produce upon me. This idea was probably suggested by the extreme agitation I had exhibited when the mode of the murder had been described. I was accordingly conducted, by the magistrate and several other persons, to the inn. I could not help being struck by the strange coincidences that had taken place during this eventful night; but, knowing that I had been conversing with several persons in the island I had inhabited about the time that the body had been found, I was perfectly tranquil as to the consequences of the affair. + +I entered the room where the corpse lay and was led up to the coffin. How can I describe my sensations on beholding it? I feel yet parched with horror, nor can I reflect on that terrible moment without shuddering and agony. The examination, the presence of the magistrate and witnesses, passed like a dream from my memory when I saw the lifeless form of Henry Clerval stretched before me. I gasped for breath, and throwing myself on the body, I exclaimed, “Have my murderous machinations deprived you also, my dearest Henry, of life? Two I have already destroyed; other victims await their destiny; but you, Clerval, my friend, my benefactor—” + +The human frame could no longer support the agonies that I endured, and I was carried out of the room in strong convulsions. + +A fever succeeded to this. I lay for two months on the point of death; my ravings, as I afterwards heard, were frightful; I called myself the murderer of William, of Justine, and of Clerval. Sometimes I entreated my attendants to assist me in the destruction of the fiend by whom I was tormented; and at others I felt the fingers of the monster already grasping my neck, and screamed aloud with agony and terror. Fortunately, as I spoke my native language, Mr. Kirwin alone understood me; but my gestures and bitter cries were sufficient to affright the other witnesses. + +Why did I not die? More miserable than man ever was before, why did I not sink into forgetfulness and rest? Death snatches away many blooming children, the only hopes of their doting parents; how many brides and youthful lovers have been one day in the bloom of health and hope, and the next a prey for worms and the decay of the tomb! Of what materials was I made that I could thus resist so many shocks, which, like the turning of the wheel, continually renewed the torture? + +But I was doomed to live and in two months found myself as awaking from a dream, in a prison, stretched on a wretched bed, surrounded by gaolers, turnkeys, bolts, and all the miserable apparatus of a dungeon. It was morning, I remember, when I thus awoke to understanding; I had forgotten the particulars of what had happened and only felt as if some great misfortune had suddenly overwhelmed me; but when I looked around and saw the barred windows and the squalidness of the room in which I was, all flashed across my memory and I groaned bitterly. + +This sound disturbed an old woman who was sleeping in a chair beside me. She was a hired nurse, the wife of one of the turnkeys, and her countenance expressed all those bad qualities which often characterise that class. The lines of her face were hard and rude, like that of persons accustomed to see without sympathising in sights of misery. Her tone expressed her entire indifference; she addressed me in English, and the voice struck me as one that I had heard during my sufferings. + +“Are you better now, sir?” said she. + +I replied in the same language, with a feeble voice, “I believe I am; but if it be all true, if indeed I did not dream, I am sorry that I am still alive to feel this misery and horror.” + +“For that matter,” replied the old woman, “if you mean about the gentleman you murdered, I believe that it were better for you if you were dead, for I fancy it will go hard with you! However, that’s none of my business; I am sent to nurse you and get you well; I do my duty with a safe conscience; it were well if everybody did the same.” + +I turned with loathing from the woman who could utter so unfeeling a speech to a person just saved, on the very edge of death; but I felt languid and unable to reflect on all that had passed. The whole series of my life appeared to me as a dream; I sometimes doubted if indeed it were all true, for it never presented itself to my mind with the force of reality. + +As the images that floated before me became more distinct, I grew feverish; a darkness pressed around me; no one was near me who soothed me with the gentle voice of love; no dear hand supported me. The physician came and prescribed medicines, and the old woman prepared them for me; but utter carelessness was visible in the first, and the expression of brutality was strongly marked in the visage of the second. Who could be interested in the fate of a murderer but the hangman who would gain his fee? + +These were my first reflections, but I soon learned that Mr. Kirwin had shown me extreme kindness. He had caused the best room in the prison to be prepared for me (wretched indeed was the best); and it was he who had provided a physician and a nurse. It is true, he seldom came to see me, for although he ardently desired to relieve the sufferings of every human creature, he did not wish to be present at the agonies and miserable ravings of a murderer. He came, therefore, sometimes to see that I was not neglected, but his visits were short and with long intervals. + +One day, while I was gradually recovering, I was seated in a chair, my eyes half open and my cheeks livid like those in death. I was overcome by gloom and misery and often reflected I had better seek death than desire to remain in a world which to me was replete with wretchedness. At one time I considered whether I should not declare myself guilty and suffer the penalty of the law, less innocent than poor Justine had been. Such were my thoughts when the door of my apartment was opened and Mr. Kirwin entered. His countenance expressed sympathy and compassion; he drew a chair close to mine and addressed me in French, + +“I fear that this place is very shocking to you; can I do anything to make you more comfortable?” + +“I thank you, but all that you mention is nothing to me; on the whole earth there is no comfort which I am capable of receiving.” + +“I know that the sympathy of a stranger can be but of little relief to one borne down as you are by so strange a misfortune. But you will, I hope, soon quit this melancholy abode, for doubtless evidence can easily be brought to free you from the criminal charge.” + +“That is my least concern; I am, by a course of strange events, become the most miserable of mortals. Persecuted and tortured as I am and have been, can death be any evil to me?” + +“Nothing indeed could be more unfortunate and agonising than the strange chances that have lately occurred. You were thrown, by some surprising accident, on this shore, renowned for its hospitality, seized immediately, and charged with murder. The first sight that was presented to your eyes was the body of your friend, murdered in so unaccountable a manner and placed, as it were, by some fiend across your path.” + +As Mr. Kirwin said this, notwithstanding the agitation I endured on this retrospect of my sufferings, I also felt considerable surprise at the knowledge he seemed to possess concerning me. I suppose some astonishment was exhibited in my countenance, for Mr. Kirwin hastened to say, + +“Immediately upon your being taken ill, all the papers that were on your person were brought me, and I examined them that I might discover some trace by which I could send to your relations an account of your misfortune and illness. I found several letters, and, among others, one which I discovered from its commencement to be from your father. I instantly wrote to Geneva; nearly two months have elapsed since the departure of my letter. But you are ill; even now you tremble; you are unfit for agitation of any kind.” + +“This suspense is a thousand times worse than the most horrible event; tell me what new scene of death has been acted, and whose murder I am now to lament?” + +“Your family is perfectly well,” said Mr. Kirwin with gentleness; “and someone, a friend, is come to visit you.” + +I know not by what chain of thought the idea presented itself, but it instantly darted into my mind that the murderer had come to mock at my misery and taunt me with the death of Clerval, as a new incitement for me to comply with his hellish desires. I put my hand before my eyes, and cried out in agony, + +“Oh! Take him away! I cannot see him; for God’s sake, do not let him enter!” + +Mr. Kirwin regarded me with a troubled countenance. He could not help regarding my exclamation as a presumption of my guilt and said in rather a severe tone, + +“I should have thought, young man, that the presence of your father would have been welcome instead of inspiring such violent repugnance.” + +“My father!” cried I, while every feature and every muscle was relaxed from anguish to pleasure. “Is my father indeed come? How kind, how very kind! But where is he, why does he not hasten to me?” + +My change of manner surprised and pleased the magistrate; perhaps he thought that my former exclamation was a momentary return of delirium, and now he instantly resumed his former benevolence. He rose and quitted the room with my nurse, and in a moment my father entered it. + +Nothing, at this moment, could have given me greater pleasure than the arrival of my father. I stretched out my hand to him and cried, + +“Are you then safe—and Elizabeth—and Ernest?” + +My father calmed me with assurances of their welfare and endeavoured, by dwelling on these subjects so interesting to my heart, to raise my desponding spirits; but he soon felt that a prison cannot be the abode of cheerfulness. “What a place is this that you inhabit, my son!” said he, looking mournfully at the barred windows and wretched appearance of the room. “You travelled to seek happiness, but a fatality seems to pursue you. And poor Clerval—” + +The name of my unfortunate and murdered friend was an agitation too great to be endured in my weak state; I shed tears. + +“Alas! Yes, my father,” replied I; “some destiny of the most horrible kind hangs over me, and I must live to fulfil it, or surely I should have died on the coffin of Henry.” + +We were not allowed to converse for any length of time, for the precarious state of my health rendered every precaution necessary that could ensure tranquillity. Mr. Kirwin came in and insisted that my strength should not be exhausted by too much exertion. But the appearance of my father was to me like that of my good angel, and I gradually recovered my health. + +As my sickness quitted me, I was absorbed by a gloomy and black melancholy that nothing could dissipate. The image of Clerval was for ever before me, ghastly and murdered. More than once the agitation into which these reflections threw me made my friends dread a dangerous relapse. Alas! Why did they preserve so miserable and detested a life? It was surely that I might fulfil my destiny, which is now drawing to a close. Soon, oh, very soon, will death extinguish these throbbings and relieve me from the mighty weight of anguish that bears me to the dust; and, in executing the award of justice, I shall also sink to rest. Then the appearance of death was distant, although the wish was ever present to my thoughts; and I often sat for hours motionless and speechless, wishing for some mighty revolution that might bury me and my destroyer in its ruins. + +The season of the assizes approached. I had already been three months in prison, and although I was still weak and in continual danger of a relapse, I was obliged to travel nearly a hundred miles to the country town where the court was held. Mr. Kirwin charged himself with every care of collecting witnesses and arranging my defence. I was spared the disgrace of appearing publicly as a criminal, as the case was not brought before the court that decides on life and death. The grand jury rejected the bill, on its being proved that I was on the Orkney Islands at the hour the body of my friend was found; and a fortnight after my removal I was liberated from prison. + +My father was enraptured on finding me freed from the vexations of a criminal charge, that I was again allowed to breathe the fresh atmosphere and permitted to return to my native country. I did not participate in these feelings, for to me the walls of a dungeon or a palace were alike hateful. The cup of life was poisoned for ever, and although the sun shone upon me, as upon the happy and gay of heart, I saw around me nothing but a dense and frightful darkness, penetrated by no light but the glimmer of two eyes that glared upon me. Sometimes they were the expressive eyes of Henry, languishing in death, the dark orbs nearly covered by the lids and the long black lashes that fringed them; sometimes it was the watery, clouded eyes of the monster, as I first saw them in my chamber at Ingolstadt. + +My father tried to awaken in me the feelings of affection. He talked of Geneva, which I should soon visit, of Elizabeth and Ernest; but these words only drew deep groans from me. Sometimes, indeed, I felt a wish for happiness and thought with melancholy delight of my beloved cousin or longed, with a devouring maladie du pays, to see once more the blue lake and rapid Rhone, that had been so dear to me in early childhood; but my general state of feeling was a torpor in which a prison was as welcome a residence as the divinest scene in nature; and these fits were seldom interrupted but by paroxysms of anguish and despair. At these moments I often endeavoured to put an end to the existence I loathed, and it required unceasing attendance and vigilance to restrain me from committing some dreadful act of violence. + +Yet one duty remained to me, the recollection of which finally triumphed over my selfish despair. It was necessary that I should return without delay to Geneva, there to watch over the lives of those I so fondly loved and to lie in wait for the murderer, that if any chance led me to the place of his concealment, or if he dared again to blast me by his presence, I might, with unfailing aim, put an end to the existence of the monstrous image which I had endued with the mockery of a soul still more monstrous. My father still desired to delay our departure, fearful that I could not sustain the fatigues of a journey, for I was a shattered wreck—the shadow of a human being. My strength was gone. I was a mere skeleton, and fever night and day preyed upon my wasted frame. + +Still, as I urged our leaving Ireland with such inquietude and impatience, my father thought it best to yield. We took our passage on board a vessel bound for Havre-de-Grace and sailed with a fair wind from the Irish shores. It was midnight. I lay on the deck looking at the stars and listening to the dashing of the waves. I hailed the darkness that shut Ireland from my sight, and my pulse beat with a feverish joy when I reflected that I should soon see Geneva. The past appeared to me in the light of a frightful dream; yet the vessel in which I was, the wind that blew me from the detested shore of Ireland, and the sea which surrounded me, told me too forcibly that I was deceived by no vision and that Clerval, my friend and dearest companion, had fallen a victim to me and the monster of my creation. I repassed, in my memory, my whole life; my quiet happiness while residing with my family in Geneva, the death of my mother, and my departure for Ingolstadt. I remembered, shuddering, the mad enthusiasm that hurried me on to the creation of my hideous enemy, and I called to mind the night in which he first lived. I was unable to pursue the train of thought; a thousand feelings pressed upon me, and I wept bitterly. + +Ever since my recovery from the fever, I had been in the custom of taking every night a small quantity of laudanum, for it was by means of this drug only that I was enabled to gain the rest necessary for the preservation of life. Oppressed by the recollection of my various misfortunes, I now swallowed double my usual quantity and soon slept profoundly. But sleep did not afford me respite from thought and misery; my dreams presented a thousand objects that scared me. Towards morning I was possessed by a kind of nightmare; I felt the fiend’s grasp in my neck and could not free myself from it; groans and cries rang in my ears. My father, who was watching over me, perceiving my restlessness, awoke me; the dashing waves were around, the cloudy sky above, the fiend was not here: a sense of security, a feeling that a truce was established between the present hour and the irresistible, disastrous future imparted to me a kind of calm forgetfulness, of which the human mind is by its structure peculiarly susceptible. +Chapter 22 + +The voyage came to an end. We landed, and proceeded to Paris. I soon found that I had overtaxed my strength and that I must repose before I could continue my journey. My father’s care and attentions were indefatigable, but he did not know the origin of my sufferings and sought erroneous methods to remedy the incurable ill. He wished me to seek amusement in society. I abhorred the face of man. Oh, not abhorred! They were my brethren, my fellow beings, and I felt attracted even to the most repulsive among them, as to creatures of an angelic nature and celestial mechanism. But I felt that I had no right to share their intercourse. I had unchained an enemy among them whose joy it was to shed their blood and to revel in their groans. How they would, each and all, abhor me and hunt me from the world, did they know my unhallowed acts and the crimes which had their source in me! + +My father yielded at length to my desire to avoid society and strove by various arguments to banish my despair. Sometimes he thought that I felt deeply the degradation of being obliged to answer a charge of murder, and he endeavoured to prove to me the futility of pride. + +“Alas! My father,” said I, “how little do you know me. Human beings, their feelings and passions, would indeed be degraded if such a wretch as I felt pride. Justine, poor unhappy Justine, was as innocent as I, and she suffered the same charge; she died for it; and I am the cause of this—I murdered her. William, Justine, and Henry—they all died by my hands.” + +My father had often, during my imprisonment, heard me make the same assertion; when I thus accused myself, he sometimes seemed to desire an explanation, and at others he appeared to consider it as the offspring of delirium, and that, during my illness, some idea of this kind had presented itself to my imagination, the remembrance of which I preserved in my convalescence. I avoided explanation and maintained a continual silence concerning the wretch I had created. I had a persuasion that I should be supposed mad, and this in itself would for ever have chained my tongue. But, besides, I could not bring myself to disclose a secret which would fill my hearer with consternation and make fear and unnatural horror the inmates of his breast. I checked, therefore, my impatient thirst for sympathy and was silent when I would have given the world to have confided the fatal secret. Yet, still, words like those I have recorded would burst uncontrollably from me. I could offer no explanation of them, but their truth in part relieved the burden of my mysterious woe. + +Upon this occasion my father said, with an expression of unbounded wonder, “My dearest Victor, what infatuation is this? My dear son, I entreat you never to make such an assertion again.” + +“I am not mad,” I cried energetically; “the sun and the heavens, who have viewed my operations, can bear witness of my truth. I am the assassin of those most innocent victims; they died by my machinations. A thousand times would I have shed my own blood, drop by drop, to have saved their lives; but I could not, my father, indeed I could not sacrifice the whole human race.” + +The conclusion of this speech convinced my father that my ideas were deranged, and he instantly changed the subject of our conversation and endeavoured to alter the course of my thoughts. He wished as much as possible to obliterate the memory of the scenes that had taken place in Ireland and never alluded to them or suffered me to speak of my misfortunes. + +As time passed away I became more calm; misery had her dwelling in my heart, but I no longer talked in the same incoherent manner of my own crimes; sufficient for me was the consciousness of them. By the utmost self-violence I curbed the imperious voice of wretchedness, which sometimes desired to declare itself to the whole world, and my manners were calmer and more composed than they had ever been since my journey to the sea of ice. + +A few days before we left Paris on our way to Switzerland, I received the following letter from Elizabeth: + +“My dear Friend, + +“It gave me the greatest pleasure to receive a letter from my uncle dated at Paris; you are no longer at a formidable distance, and I may hope to see you in less than a fortnight. My poor cousin, how much you must have suffered! I expect to see you looking even more ill than when you quitted Geneva. This winter has been passed most miserably, tortured as I have been by anxious suspense; yet I hope to see peace in your countenance and to find that your heart is not totally void of comfort and tranquillity. + +“Yet I fear that the same feelings now exist that made you so miserable a year ago, even perhaps augmented by time. I would not disturb you at this period, when so many misfortunes weigh upon you, but a conversation that I had with my uncle previous to his departure renders some explanation necessary before we meet. + +Explanation! You may possibly say, What can Elizabeth have to explain? If you really say this, my questions are answered and all my doubts satisfied. But you are distant from me, and it is possible that you may dread and yet be pleased with this explanation; and in a probability of this being the case, I dare not any longer postpone writing what, during your absence, I have often wished to express to you but have never had the courage to begin. + +“You well know, Victor, that our union had been the favourite plan of your parents ever since our infancy. We were told this when young, and taught to look forward to it as an event that would certainly take place. We were affectionate playfellows during childhood, and, I believe, dear and valued friends to one another as we grew older. But as brother and sister often entertain a lively affection towards each other without desiring a more intimate union, may not such also be our case? Tell me, dearest Victor. Answer me, I conjure you by our mutual happiness, with simple truth—Do you not love another? + +“You have travelled; you have spent several years of your life at Ingolstadt; and I confess to you, my friend, that when I saw you last autumn so unhappy, flying to solitude from the society of every creature, I could not help supposing that you might regret our connection and believe yourself bound in honour to fulfil the wishes of your parents, although they opposed themselves to your inclinations. But this is false reasoning. I confess to you, my friend, that I love you and that in my airy dreams of futurity you have been my constant friend and companion. But it is your happiness I desire as well as my own when I declare to you that our marriage would render me eternally miserable unless it were the dictate of your own free choice. Even now I weep to think that, borne down as you are by the cruellest misfortunes, you may stifle, by the word honour, all hope of that love and happiness which would alone restore you to yourself. I, who have so disinterested an affection for you, may increase your miseries tenfold by being an obstacle to your wishes. Ah! Victor, be assured that your cousin and playmate has too sincere a love for you not to be made miserable by this supposition. Be happy, my friend; and if you obey me in this one request, remain satisfied that nothing on earth will have the power to interrupt my tranquillity. + +“Do not let this letter disturb you; do not answer tomorrow, or the next day, or even until you come, if it will give you pain. My uncle will send me news of your health, and if I see but one smile on your lips when we meet, occasioned by this or any other exertion of mine, I shall need no other happiness. + +“Elizabeth Lavenza. + +“Geneva, May 18th, 17—” + +This letter revived in my memory what I had before forgotten, the threat of the fiend—“I will be with you on your wedding-night!” Such was my sentence, and on that night would the dæmon employ every art to destroy me and tear me from the glimpse of happiness which promised partly to console my sufferings. On that night he had determined to consummate his crimes by my death. Well, be it so; a deadly struggle would then assuredly take place, in which if he were victorious I should be at peace and his power over me be at an end. If he were vanquished, I should be a free man. Alas! What freedom? Such as the peasant enjoys when his family have been massacred before his eyes, his cottage burnt, his lands laid waste, and he is turned adrift, homeless, penniless, and alone, but free. Such would be my liberty except that in my Elizabeth I possessed a treasure, alas, balanced by those horrors of remorse and guilt which would pursue me until death. + +Sweet and beloved Elizabeth! I read and reread her letter, and some softened feelings stole into my heart and dared to whisper paradisiacal dreams of love and joy; but the apple was already eaten, and the angel’s arm bared to drive me from all hope. Yet I would die to make her happy. If the monster executed his threat, death was inevitable; yet, again, I considered whether my marriage would hasten my fate. My destruction might indeed arrive a few months sooner, but if my torturer should suspect that I postponed it, influenced by his menaces, he would surely find other and perhaps more dreadful means of revenge. He had vowed to be with me on my wedding-night, yet he did not consider that threat as binding him to peace in the meantime, for as if to show me that he was not yet satiated with blood, he had murdered Clerval immediately after the enunciation of his threats. I resolved, therefore, that if my immediate union with my cousin would conduce either to hers or my father’s happiness, my adversary’s designs against my life should not retard it a single hour. + +In this state of mind I wrote to Elizabeth. My letter was calm and affectionate. “I fear, my beloved girl,” I said, “little happiness remains for us on earth; yet all that I may one day enjoy is centred in you. Chase away your idle fears; to you alone do I consecrate my life and my endeavours for contentment. I have one secret, Elizabeth, a dreadful one; when revealed to you, it will chill your frame with horror, and then, far from being surprised at my misery, you will only wonder that I survive what I have endured. I will confide this tale of misery and terror to you the day after our marriage shall take place, for, my sweet cousin, there must be perfect confidence between us. But until then, I conjure you, do not mention or allude to it. This I most earnestly entreat, and I know you will comply.” + +In about a week after the arrival of Elizabeth’s letter we returned to Geneva. The sweet girl welcomed me with warm affection, yet tears were in her eyes as she beheld my emaciated frame and feverish cheeks. I saw a change in her also. She was thinner and had lost much of that heavenly vivacity that had before charmed me; but her gentleness and soft looks of compassion made her a more fit companion for one blasted and miserable as I was. + +The tranquillity which I now enjoyed did not endure. Memory brought madness with it, and when I thought of what had passed, a real insanity possessed me; sometimes I was furious and burnt with rage, sometimes low and despondent. I neither spoke nor looked at anyone, but sat motionless, bewildered by the multitude of miseries that overcame me. + +Elizabeth alone had the power to draw me from these fits; her gentle voice would soothe me when transported by passion and inspire me with human feelings when sunk in torpor. She wept with me and for me. When reason returned, she would remonstrate and endeavour to inspire me with resignation. Ah! It is well for the unfortunate to be resigned, but for the guilty there is no peace. The agonies of remorse poison the luxury there is otherwise sometimes found in indulging the excess of grief. + +Soon after my arrival my father spoke of my immediate marriage with Elizabeth. I remained silent. + +“Have you, then, some other attachment?” + +“None on earth. I love Elizabeth and look forward to our union with delight. Let the day therefore be fixed; and on it I will consecrate myself, in life or death, to the happiness of my cousin.” + +“My dear Victor, do not speak thus. Heavy misfortunes have befallen us, but let us only cling closer to what remains and transfer our love for those whom we have lost to those who yet live. Our circle will be small but bound close by the ties of affection and mutual misfortune. And when time shall have softened your despair, new and dear objects of care will be born to replace those of whom we have been so cruelly deprived.” + +Such were the lessons of my father. But to me the remembrance of the threat returned; nor can you wonder that, omnipotent as the fiend had yet been in his deeds of blood, I should almost regard him as invincible, and that when he had pronounced the words “I shall be with you on your wedding-night,” I should regard the threatened fate as unavoidable. But death was no evil to me if the loss of Elizabeth were balanced with it, and I therefore, with a contented and even cheerful countenance, agreed with my father that if my cousin would consent, the ceremony should take place in ten days, and thus put, as I imagined, the seal to my fate. + +Great God! If for one instant I had thought what might be the hellish intention of my fiendish adversary, I would rather have banished myself for ever from my native country and wandered a friendless outcast over the earth than have consented to this miserable marriage. But, as if possessed of magic powers, the monster had blinded me to his real intentions; and when I thought that I had prepared only my own death, I hastened that of a far dearer victim. + +As the period fixed for our marriage drew nearer, whether from cowardice or a prophetic feeling, I felt my heart sink within me. But I concealed my feelings by an appearance of hilarity that brought smiles and joy to the countenance of my father, but hardly deceived the ever-watchful and nicer eye of Elizabeth. She looked forward to our union with placid contentment, not unmingled with a little fear, which past misfortunes had impressed, that what now appeared certain and tangible happiness might soon dissipate into an airy dream and leave no trace but deep and everlasting regret. + +Preparations were made for the event, congratulatory visits were received, and all wore a smiling appearance. I shut up, as well as I could, in my own heart the anxiety that preyed there and entered with seeming earnestness into the plans of my father, although they might only serve as the decorations of my tragedy. Through my father’s exertions a part of the inheritance of Elizabeth had been restored to her by the Austrian government. A small possession on the shores of Como belonged to her. It was agreed that, immediately after our union, we should proceed to Villa Lavenza and spend our first days of happiness beside the beautiful lake near which it stood. + +In the meantime I took every precaution to defend my person in case the fiend should openly attack me. I carried pistols and a dagger constantly about me and was ever on the watch to prevent artifice, and by these means gained a greater degree of tranquillity. Indeed, as the period approached, the threat appeared more as a delusion, not to be regarded as worthy to disturb my peace, while the happiness I hoped for in my marriage wore a greater appearance of certainty as the day fixed for its solemnisation drew nearer and I heard it continually spoken of as an occurrence which no accident could possibly prevent. + +Elizabeth seemed happy; my tranquil demeanour contributed greatly to calm her mind. But on the day that was to fulfil my wishes and my destiny, she was melancholy, and a presentiment of evil pervaded her; and perhaps also she thought of the dreadful secret which I had promised to reveal to her on the following day. My father was in the meantime overjoyed, and, in the bustle of preparation, only recognised in the melancholy of his niece the diffidence of a bride. + +After the ceremony was performed a large party assembled at my father’s, but it was agreed that Elizabeth and I should commence our journey by water, sleeping that night at Evian and continuing our voyage on the following day. The day was fair, the wind favourable; all smiled on our nuptial embarkation. + +Those were the last moments of my life during which I enjoyed the feeling of happiness. We passed rapidly along; the sun was hot, but we were sheltered from its rays by a kind of canopy while we enjoyed the beauty of the scene, sometimes on one side of the lake, where we saw Mont Salêve, the pleasant banks of Montalègre, and at a distance, surmounting all, the beautiful Mont Blanc, and the assemblage of snowy mountains that in vain endeavour to emulate her; sometimes coasting the opposite banks, we saw the mighty Jura opposing its dark side to the ambition that would quit its native country, and an almost insurmountable barrier to the invader who should wish to enslave it. + +I took the hand of Elizabeth. “You are sorrowful, my love. Ah! If you knew what I have suffered and what I may yet endure, you would endeavour to let me taste the quiet and freedom from despair that this one day at least permits me to enjoy.” + +“Be happy, my dear Victor,” replied Elizabeth; “there is, I hope, nothing to distress you; and be assured that if a lively joy is not painted in my face, my heart is contented. Something whispers to me not to depend too much on the prospect that is opened before us, but I will not listen to such a sinister voice. Observe how fast we move along and how the clouds, which sometimes obscure and sometimes rise above the dome of Mont Blanc, render this scene of beauty still more interesting. Look also at the innumerable fish that are swimming in the clear waters, where we can distinguish every pebble that lies at the bottom. What a divine day! How happy and serene all nature appears!” + +Thus Elizabeth endeavoured to divert her thoughts and mine from all reflection upon melancholy subjects. But her temper was fluctuating; joy for a few instants shone in her eyes, but it continually gave place to distraction and reverie. + +The sun sank lower in the heavens; we passed the river Drance and observed its path through the chasms of the higher and the glens of the lower hills. The Alps here come closer to the lake, and we approached the amphitheatre of mountains which forms its eastern boundary. The spire of Evian shone under the woods that surrounded it and the range of mountain above mountain by which it was overhung. + +The wind, which had hitherto carried us along with amazing rapidity, sank at sunset to a light breeze; the soft air just ruffled the water and caused a pleasant motion among the trees as we approached the shore, from which it wafted the most delightful scent of flowers and hay. The sun sank beneath the horizon as we landed, and as I touched the shore I felt those cares and fears revive which soon were to clasp me and cling to me for ever. +Chapter 23 + +It was eight o’clock when we landed; we walked for a short time on the shore, enjoying the transitory light, and then retired to the inn and contemplated the lovely scene of waters, woods, and mountains, obscured in darkness, yet still displaying their black outlines. + +The wind, which had fallen in the south, now rose with great violence in the west. The moon had reached her summit in the heavens and was beginning to descend; the clouds swept across it swifter than the flight of the vulture and dimmed her rays, while the lake reflected the scene of the busy heavens, rendered still busier by the restless waves that were beginning to rise. Suddenly a heavy storm of rain descended. + +I had been calm during the day, but so soon as night obscured the shapes of objects, a thousand fears arose in my mind. I was anxious and watchful, while my right hand grasped a pistol which was hidden in my bosom; every sound terrified me, but I resolved that I would sell my life dearly and not shrink from the conflict until my own life or that of my adversary was extinguished. + +Elizabeth observed my agitation for some time in timid and fearful silence, but there was something in my glance which communicated terror to her, and trembling, she asked, “What is it that agitates you, my dear Victor? What is it you fear?” + +“Oh! Peace, peace, my love,” replied I; “this night, and all will be safe; but this night is dreadful, very dreadful.” + +I passed an hour in this state of mind, when suddenly I reflected how fearful the combat which I momentarily expected would be to my wife, and I earnestly entreated her to retire, resolving not to join her until I had obtained some knowledge as to the situation of my enemy. + +She left me, and I continued some time walking up and down the passages of the house and inspecting every corner that might afford a retreat to my adversary. But I discovered no trace of him and was beginning to conjecture that some fortunate chance had intervened to prevent the execution of his menaces when suddenly I heard a shrill and dreadful scream. It came from the room into which Elizabeth had retired. As I heard it, the whole truth rushed into my mind, my arms dropped, the motion of every muscle and fibre was suspended; I could feel the blood trickling in my veins and tingling in the extremities of my limbs. This state lasted but for an instant; the scream was repeated, and I rushed into the room. + +Great God! Why did I not then expire! Why am I here to relate the destruction of the best hope and the purest creature on earth? She was there, lifeless and inanimate, thrown across the bed, her head hanging down and her pale and distorted features half covered by her hair. Everywhere I turn I see the same figure—her bloodless arms and relaxed form flung by the murderer on its bridal bier. Could I behold this and live? Alas! Life is obstinate and clings closest where it is most hated. For a moment only did I lose recollection; I fell senseless on the ground. + +When I recovered I found myself surrounded by the people of the inn; their countenances expressed a breathless terror, but the horror of others appeared only as a mockery, a shadow of the feelings that oppressed me. I escaped from them to the room where lay the body of Elizabeth, my love, my wife, so lately living, so dear, so worthy. She had been moved from the posture in which I had first beheld her, and now, as she lay, her head upon her arm and a handkerchief thrown across her face and neck, I might have supposed her asleep. I rushed towards her and embraced her with ardour, but the deadly languor and coldness of the limbs told me that what I now held in my arms had ceased to be the Elizabeth whom I had loved and cherished. The murderous mark of the fiend’s grasp was on her neck, and the breath had ceased to issue from her lips. + +While I still hung over her in the agony of despair, I happened to look up. The windows of the room had before been darkened, and I felt a kind of panic on seeing the pale yellow light of the moon illuminate the chamber. The shutters had been thrown back, and with a sensation of horror not to be described, I saw at the open window a figure the most hideous and abhorred. A grin was on the face of the monster; he seemed to jeer, as with his fiendish finger he pointed towards the corpse of my wife. I rushed towards the window, and drawing a pistol from my bosom, fired; but he eluded me, leaped from his station, and running with the swiftness of lightning, plunged into the lake. + +The report of the pistol brought a crowd into the room. I pointed to the spot where he had disappeared, and we followed the track with boats; nets were cast, but in vain. After passing several hours, we returned hopeless, most of my companions believing it to have been a form conjured up by my fancy. After having landed, they proceeded to search the country, parties going in different directions among the woods and vines. + +I attempted to accompany them and proceeded a short distance from the house, but my head whirled round, my steps were like those of a drunken man, I fell at last in a state of utter exhaustion; a film covered my eyes, and my skin was parched with the heat of fever. In this state I was carried back and placed on a bed, hardly conscious of what had happened; my eyes wandered round the room as if to seek something that I had lost. + +After an interval I arose, and as if by instinct, crawled into the room where the corpse of my beloved lay. There were women weeping around; I hung over it and joined my sad tears to theirs; all this time no distinct idea presented itself to my mind, but my thoughts rambled to various subjects, reflecting confusedly on my misfortunes and their cause. I was bewildered, in a cloud of wonder and horror. The death of William, the execution of Justine, the murder of Clerval, and lastly of my wife; even at that moment I knew not that my only remaining friends were safe from the malignity of the fiend; my father even now might be writhing under his grasp, and Ernest might be dead at his feet. This idea made me shudder and recalled me to action. I started up and resolved to return to Geneva with all possible speed. + +There were no horses to be procured, and I must return by the lake; but the wind was unfavourable, and the rain fell in torrents. However, it was hardly morning, and I might reasonably hope to arrive by night. I hired men to row and took an oar myself, for I had always experienced relief from mental torment in bodily exercise. But the overflowing misery I now felt, and the excess of agitation that I endured rendered me incapable of any exertion. I threw down the oar, and leaning my head upon my hands, gave way to every gloomy idea that arose. If I looked up, I saw scenes which were familiar to me in my happier time and which I had contemplated but the day before in the company of her who was now but a shadow and a recollection. Tears streamed from my eyes. The rain had ceased for a moment, and I saw the fish play in the waters as they had done a few hours before; they had then been observed by Elizabeth. Nothing is so painful to the human mind as a great and sudden change. The sun might shine or the clouds might lower, but nothing could appear to me as it had done the day before. A fiend had snatched from me every hope of future happiness; no creature had ever been so miserable as I was; so frightful an event is single in the history of man. + +But why should I dwell upon the incidents that followed this last overwhelming event? Mine has been a tale of horrors; I have reached their acme, and what I must now relate can but be tedious to you. Know that, one by one, my friends were snatched away; I was left desolate. My own strength is exhausted, and I must tell, in a few words, what remains of my hideous narration. + +I arrived at Geneva. My father and Ernest yet lived, but the former sunk under the tidings that I bore. I see him now, excellent and venerable old man! His eyes wandered in vacancy, for they had lost their charm and their delight—his Elizabeth, his more than daughter, whom he doted on with all that affection which a man feels, who in the decline of life, having few affections, clings more earnestly to those that remain. Cursed, cursed be the fiend that brought misery on his grey hairs and doomed him to waste in wretchedness! He could not live under the horrors that were accumulated around him; the springs of existence suddenly gave way; he was unable to rise from his bed, and in a few days he died in my arms. + +What then became of me? I know not; I lost sensation, and chains and darkness were the only objects that pressed upon me. Sometimes, indeed, I dreamt that I wandered in flowery meadows and pleasant vales with the friends of my youth, but I awoke and found myself in a dungeon. Melancholy followed, but by degrees I gained a clear conception of my miseries and situation and was then released from my prison. For they had called me mad, and during many months, as I understood, a solitary cell had been my habitation. + +Liberty, however, had been a useless gift to me, had I not, as I awakened to reason, at the same time awakened to revenge. As the memory of past misfortunes pressed upon me, I began to reflect on their cause—the monster whom I had created, the miserable dæmon whom I had sent abroad into the world for my destruction. I was possessed by a maddening rage when I thought of him, and desired and ardently prayed that I might have him within my grasp to wreak a great and signal revenge on his cursed head. + +Nor did my hate long confine itself to useless wishes; I began to reflect on the best means of securing him; and for this purpose, about a month after my release, I repaired to a criminal judge in the town and told him that I had an accusation to make, that I knew the destroyer of my family, and that I required him to exert his whole authority for the apprehension of the murderer. + +The magistrate listened to me with attention and kindness. “Be assured, sir,” said he, “no pains or exertions on my part shall be spared to discover the villain.” + +“I thank you,” replied I; “listen, therefore, to the deposition that I have to make. It is indeed a tale so strange that I should fear you would not credit it were there not something in truth which, however wonderful, forces conviction. The story is too connected to be mistaken for a dream, and I have no motive for falsehood.” My manner as I thus addressed him was impressive but calm; I had formed in my own heart a resolution to pursue my destroyer to death, and this purpose quieted my agony and for an interval reconciled me to life. I now related my history briefly but with firmness and precision, marking the dates with accuracy and never deviating into invective or exclamation. + +The magistrate appeared at first perfectly incredulous, but as I continued he became more attentive and interested; I saw him sometimes shudder with horror; at others a lively surprise, unmingled with disbelief, was painted on his countenance. + +When I had concluded my narration, I said, “This is the being whom I accuse and for whose seizure and punishment I call upon you to exert your whole power. It is your duty as a magistrate, and I believe and hope that your feelings as a man will not revolt from the execution of those functions on this occasion.” + +This address caused a considerable change in the physiognomy of my own auditor. He had heard my story with that half kind of belief that is given to a tale of spirits and supernatural events; but when he was called upon to act officially in consequence, the whole tide of his incredulity returned. He, however, answered mildly, “I would willingly afford you every aid in your pursuit, but the creature of whom you speak appears to have powers which would put all my exertions to defiance. Who can follow an animal which can traverse the sea of ice and inhabit caves and dens where no man would venture to intrude? Besides, some months have elapsed since the commission of his crimes, and no one can conjecture to what place he has wandered or what region he may now inhabit.” + +“I do not doubt that he hovers near the spot which I inhabit, and if he has indeed taken refuge in the Alps, he may be hunted like the chamois and destroyed as a beast of prey. But I perceive your thoughts; you do not credit my narrative and do not intend to pursue my enemy with the punishment which is his desert.” + +As I spoke, rage sparkled in my eyes; the magistrate was intimidated. “You are mistaken,” said he. “I will exert myself, and if it is in my power to seize the monster, be assured that he shall suffer punishment proportionate to his crimes. But I fear, from what you have yourself described to be his properties, that this will prove impracticable; and thus, while every proper measure is pursued, you should make up your mind to disappointment.” + +“That cannot be; but all that I can say will be of little avail. My revenge is of no moment to you; yet, while I allow it to be a vice, I confess that it is the devouring and only passion of my soul. My rage is unspeakable when I reflect that the murderer, whom I have turned loose upon society, still exists. You refuse my just demand; I have but one resource, and I devote myself, either in my life or death, to his destruction.” + +I trembled with excess of agitation as I said this; there was a frenzy in my manner, and something, I doubt not, of that haughty fierceness which the martyrs of old are said to have possessed. But to a Genevan magistrate, whose mind was occupied by far other ideas than those of devotion and heroism, this elevation of mind had much the appearance of madness. He endeavoured to soothe me as a nurse does a child and reverted to my tale as the effects of delirium. + +“Man,” I cried, “how ignorant art thou in thy pride of wisdom! Cease; you know not what it is you say.” + +I broke from the house angry and disturbed and retired to meditate on some other mode of action. +Chapter 24 + +My present situation was one in which all voluntary thought was swallowed up and lost. I was hurried away by fury; revenge alone endowed me with strength and composure; it moulded my feelings and allowed me to be calculating and calm at periods when otherwise delirium or death would have been my portion. + +My first resolution was to quit Geneva for ever; my country, which, when I was happy and beloved, was dear to me, now, in my adversity, became hateful. I provided myself with a sum of money, together with a few jewels which had belonged to my mother, and departed. + +And now my wanderings began which are to cease but with life. I have traversed a vast portion of the earth and have endured all the hardships which travellers in deserts and barbarous countries are wont to meet. How I have lived I hardly know; many times have I stretched my failing limbs upon the sandy plain and prayed for death. But revenge kept me alive; I dared not die and leave my adversary in being. + +When I quitted Geneva my first labour was to gain some clue by which I might trace the steps of my fiendish enemy. But my plan was unsettled, and I wandered many hours round the confines of the town, uncertain what path I should pursue. As night approached I found myself at the entrance of the cemetery where William, Elizabeth, and my father reposed. I entered it and approached the tomb which marked their graves. Everything was silent except the leaves of the trees, which were gently agitated by the wind; the night was nearly dark, and the scene would have been solemn and affecting even to an uninterested observer. The spirits of the departed seemed to flit around and to cast a shadow, which was felt but not seen, around the head of the mourner. + +The deep grief which this scene had at first excited quickly gave way to rage and despair. They were dead, and I lived; their murderer also lived, and to destroy him I must drag out my weary existence. I knelt on the grass and kissed the earth and with quivering lips exclaimed, “By the sacred earth on which I kneel, by the shades that wander near me, by the deep and eternal grief that I feel, I swear; and by thee, O Night, and the spirits that preside over thee, to pursue the dæmon who caused this misery, until he or I shall perish in mortal conflict. For this purpose I will preserve my life; to execute this dear revenge will I again behold the sun and tread the green herbage of earth, which otherwise should vanish from my eyes for ever. And I call on you, spirits of the dead, and on you, wandering ministers of vengeance, to aid and conduct me in my work. Let the cursed and hellish monster drink deep of agony; let him feel the despair that now torments me.” + +I had begun my adjuration with solemnity and an awe which almost assured me that the shades of my murdered friends heard and approved my devotion, but the furies possessed me as I concluded, and rage choked my utterance. + +I was answered through the stillness of night by a loud and fiendish laugh. It rang on my ears long and heavily; the mountains re-echoed it, and I felt as if all hell surrounded me with mockery and laughter. Surely in that moment I should have been possessed by frenzy and have destroyed my miserable existence but that my vow was heard and that I was reserved for vengeance. The laughter died away, when a well-known and abhorred voice, apparently close to my ear, addressed me in an audible whisper, “I am satisfied, miserable wretch! You have determined to live, and I am satisfied.” + +I darted towards the spot from which the sound proceeded, but the devil eluded my grasp. Suddenly the broad disk of the moon arose and shone full upon his ghastly and distorted shape as he fled with more than mortal speed. + +I pursued him, and for many months this has been my task. Guided by a slight clue, I followed the windings of the Rhone, but vainly. The blue Mediterranean appeared, and by a strange chance, I saw the fiend enter by night and hide himself in a vessel bound for the Black Sea. I took my passage in the same ship, but he escaped, I know not how. + +Amidst the wilds of Tartary and Russia, although he still evaded me, I have ever followed in his track. Sometimes the peasants, scared by this horrid apparition, informed me of his path; sometimes he himself, who feared that if I lost all trace of him I should despair and die, left some mark to guide me. The snows descended on my head, and I saw the print of his huge step on the white plain. To you first entering on life, to whom care is new and agony unknown, how can you understand what I have felt and still feel? Cold, want, and fatigue were the least pains which I was destined to endure; I was cursed by some devil and carried about with me my eternal hell; yet still a spirit of good followed and directed my steps and when I most murmured would suddenly extricate me from seemingly insurmountable difficulties. Sometimes, when nature, overcome by hunger, sank under the exhaustion, a repast was prepared for me in the desert that restored and inspirited me. The fare was, indeed, coarse, such as the peasants of the country ate, but I will not doubt that it was set there by the spirits that I had invoked to aid me. Often, when all was dry, the heavens cloudless, and I was parched by thirst, a slight cloud would bedim the sky, shed the few drops that revived me, and vanish. + +I followed, when I could, the courses of the rivers; but the dæmon generally avoided these, as it was here that the population of the country chiefly collected. In other places human beings were seldom seen, and I generally subsisted on the wild animals that crossed my path. I had money with me and gained the friendship of the villagers by distributing it; or I brought with me some food that I had killed, which, after taking a small part, I always presented to those who had provided me with fire and utensils for cooking. + +My life, as it passed thus, was indeed hateful to me, and it was during sleep alone that I could taste joy. O blessed sleep! Often, when most miserable, I sank to repose, and my dreams lulled me even to rapture. The spirits that guarded me had provided these moments, or rather hours, of happiness that I might retain strength to fulfil my pilgrimage. Deprived of this respite, I should have sunk under my hardships. During the day I was sustained and inspirited by the hope of night, for in sleep I saw my friends, my wife, and my beloved country; again I saw the benevolent countenance of my father, heard the silver tones of my Elizabeth’s voice, and beheld Clerval enjoying health and youth. Often, when wearied by a toilsome march, I persuaded myself that I was dreaming until night should come and that I should then enjoy reality in the arms of my dearest friends. What agonising fondness did I feel for them! How did I cling to their dear forms, as sometimes they haunted even my waking hours, and persuade myself that they still lived! At such moments vengeance, that burned within me, died in my heart, and I pursued my path towards the destruction of the dæmon more as a task enjoined by heaven, as the mechanical impulse of some power of which I was unconscious, than as the ardent desire of my soul. + +What his feelings were whom I pursued I cannot know. Sometimes, indeed, he left marks in writing on the barks of the trees or cut in stone that guided me and instigated my fury. “My reign is not yet over”—these words were legible in one of these inscriptions—“you live, and my power is complete. Follow me; I seek the everlasting ices of the north, where you will feel the misery of cold and frost, to which I am impassive. You will find near this place, if you follow not too tardily, a dead hare; eat and be refreshed. Come on, my enemy; we have yet to wrestle for our lives, but many hard and miserable hours must you endure until that period shall arrive.” + +Scoffing devil! Again do I vow vengeance; again do I devote thee, miserable fiend, to torture and death. Never will I give up my search until he or I perish; and then with what ecstasy shall I join my Elizabeth and my departed friends, who even now prepare for me the reward of my tedious toil and horrible pilgrimage! + +As I still pursued my journey to the northward, the snows thickened and the cold increased in a degree almost too severe to support. The peasants were shut up in their hovels, and only a few of the most hardy ventured forth to seize the animals whom starvation had forced from their hiding-places to seek for prey. The rivers were covered with ice, and no fish could be procured; and thus I was cut off from my chief article of maintenance. + +The triumph of my enemy increased with the difficulty of my labours. One inscription that he left was in these words: “Prepare! Your toils only begin; wrap yourself in furs and provide food, for we shall soon enter upon a journey where your sufferings will satisfy my everlasting hatred.” + +My courage and perseverance were invigorated by these scoffing words; I resolved not to fail in my purpose, and calling on Heaven to support me, I continued with unabated fervour to traverse immense deserts, until the ocean appeared at a distance and formed the utmost boundary of the horizon. Oh! How unlike it was to the blue seasons of the south! Covered with ice, it was only to be distinguished from land by its superior wildness and ruggedness. The Greeks wept for joy when they beheld the Mediterranean from the hills of Asia, and hailed with rapture the boundary of their toils. I did not weep, but I knelt down and with a full heart thanked my guiding spirit for conducting me in safety to the place where I hoped, notwithstanding my adversary’s gibe, to meet and grapple with him. + +Some weeks before this period I had procured a sledge and dogs and thus traversed the snows with inconceivable speed. I know not whether the fiend possessed the same advantages, but I found that, as before I had daily lost ground in the pursuit, I now gained on him, so much so that when I first saw the ocean he was but one day’s journey in advance, and I hoped to intercept him before he should reach the beach. With new courage, therefore, I pressed on, and in two days arrived at a wretched hamlet on the seashore. I inquired of the inhabitants concerning the fiend and gained accurate information. A gigantic monster, they said, had arrived the night before, armed with a gun and many pistols, putting to flight the inhabitants of a solitary cottage through fear of his terrific appearance. He had carried off their store of winter food, and placing it in a sledge, to draw which he had seized on a numerous drove of trained dogs, he had harnessed them, and the same night, to the joy of the horror-struck villagers, had pursued his journey across the sea in a direction that led to no land; and they conjectured that he must speedily be destroyed by the breaking of the ice or frozen by the eternal frosts. + +On hearing this information I suffered a temporary access of despair. He had escaped me, and I must commence a destructive and almost endless journey across the mountainous ices of the ocean, amidst cold that few of the inhabitants could long endure and which I, the native of a genial and sunny climate, could not hope to survive. Yet at the idea that the fiend should live and be triumphant, my rage and vengeance returned, and like a mighty tide, overwhelmed every other feeling. After a slight repose, during which the spirits of the dead hovered round and instigated me to toil and revenge, I prepared for my journey. + +I exchanged my land-sledge for one fashioned for the inequalities of the Frozen Ocean, and purchasing a plentiful stock of provisions, I departed from land. + +I cannot guess how many days have passed since then, but I have endured misery which nothing but the eternal sentiment of a just retribution burning within my heart could have enabled me to support. Immense and rugged mountains of ice often barred up my passage, and I often heard the thunder of the ground sea, which threatened my destruction. But again the frost came and made the paths of the sea secure. + +By the quantity of provision which I had consumed, I should guess that I had passed three weeks in this journey; and the continual protraction of hope, returning back upon the heart, often wrung bitter drops of despondency and grief from my eyes. Despair had indeed almost secured her prey, and I should soon have sunk beneath this misery. Once, after the poor animals that conveyed me had with incredible toil gained the summit of a sloping ice mountain, and one, sinking under his fatigue, died, I viewed the expanse before me with anguish, when suddenly my eye caught a dark speck upon the dusky plain. I strained my sight to discover what it could be and uttered a wild cry of ecstasy when I distinguished a sledge and the distorted proportions of a well-known form within. Oh! With what a burning gush did hope revisit my heart! Warm tears filled my eyes, which I hastily wiped away, that they might not intercept the view I had of the dæmon; but still my sight was dimmed by the burning drops, until, giving way to the emotions that oppressed me, I wept aloud. + +But this was not the time for delay; I disencumbered the dogs of their dead companion, gave them a plentiful portion of food, and after an hour’s rest, which was absolutely necessary, and yet which was bitterly irksome to me, I continued my route. The sledge was still visible, nor did I again lose sight of it except at the moments when for a short time some ice-rock concealed it with its intervening crags. I indeed perceptibly gained on it, and when, after nearly two days’ journey, I beheld my enemy at no more than a mile distant, my heart bounded within me. + +But now, when I appeared almost within grasp of my foe, my hopes were suddenly extinguished, and I lost all trace of him more utterly than I had ever done before. A ground sea was heard; the thunder of its progress, as the waters rolled and swelled beneath me, became every moment more ominous and terrific. I pressed on, but in vain. The wind arose; the sea roared; and, as with the mighty shock of an earthquake, it split and cracked with a tremendous and overwhelming sound. The work was soon finished; in a few minutes a tumultuous sea rolled between me and my enemy, and I was left drifting on a scattered piece of ice that was continually lessening and thus preparing for me a hideous death. + +In this manner many appalling hours passed; several of my dogs died, and I myself was about to sink under the accumulation of distress when I saw your vessel riding at anchor and holding forth to me hopes of succour and life. I had no conception that vessels ever came so far north and was astounded at the sight. I quickly destroyed part of my sledge to construct oars, and by these means was enabled, with infinite fatigue, to move my ice raft in the direction of your ship. I had determined, if you were going southwards, still to trust myself to the mercy of the seas rather than abandon my purpose. I hoped to induce you to grant me a boat with which I could pursue my enemy. But your direction was northwards. You took me on board when my vigour was exhausted, and I should soon have sunk under my multiplied hardships into a death which I still dread, for my task is unfulfilled. + +Oh! When will my guiding spirit, in conducting me to the dæmon, allow me the rest I so much desire; or must I die, and he yet live? If I do, swear to me, Walton, that he shall not escape, that you will seek him and satisfy my vengeance in his death. And do I dare to ask of you to undertake my pilgrimage, to endure the hardships that I have undergone? No; I am not so selfish. Yet, when I am dead, if he should appear, if the ministers of vengeance should conduct him to you, swear that he shall not live—swear that he shall not triumph over my accumulated woes and survive to add to the list of his dark crimes. He is eloquent and persuasive, and once his words had even power over my heart; but trust him not. His soul is as hellish as his form, full of treachery and fiend-like malice. Hear him not; call on the names of William, Justine, Clerval, Elizabeth, my father, and of the wretched Victor, and thrust your sword into his heart. I will hover near and direct the steel aright. + +Walton, in continuation. + +August 26th, 17—. + +You have read this strange and terrific story, Margaret; and do you not feel your blood congeal with horror, like that which even now curdles mine? Sometimes, seized with sudden agony, he could not continue his tale; at others, his voice broken, yet piercing, uttered with difficulty the words so replete with anguish. His fine and lovely eyes were now lighted up with indignation, now subdued to downcast sorrow and quenched in infinite wretchedness. Sometimes he commanded his countenance and tones and related the most horrible incidents with a tranquil voice, suppressing every mark of agitation; then, like a volcano bursting forth, his face would suddenly change to an expression of the wildest rage as he shrieked out imprecations on his persecutor. + +His tale is connected and told with an appearance of the simplest truth, yet I own to you that the letters of Felix and Safie, which he showed me, and the apparition of the monster seen from our ship, brought to me a greater conviction of the truth of his narrative than his asseverations, however earnest and connected. Such a monster has, then, really existence! I cannot doubt it, yet I am lost in surprise and admiration. Sometimes I endeavoured to gain from Frankenstein the particulars of his creature’s formation, but on this point he was impenetrable. + +“Are you mad, my friend?” said he. “Or whither does your senseless curiosity lead you? Would you also create for yourself and the world a demoniacal enemy? Peace, peace! Learn my miseries and do not seek to increase your own.” + +Frankenstein discovered that I made notes concerning his history; he asked to see them and then himself corrected and augmented them in many places, but principally in giving the life and spirit to the conversations he held with his enemy. “Since you have preserved my narration,” said he, “I would not that a mutilated one should go down to posterity.” + +Thus has a week passed away, while I have listened to the strangest tale that ever imagination formed. My thoughts and every feeling of my soul have been drunk up by the interest for my guest which this tale and his own elevated and gentle manners have created. I wish to soothe him, yet can I counsel one so infinitely miserable, so destitute of every hope of consolation, to live? Oh, no! The only joy that he can now know will be when he composes his shattered spirit to peace and death. Yet he enjoys one comfort, the offspring of solitude and delirium; he believes that when in dreams he holds converse with his friends and derives from that communion consolation for his miseries or excitements to his vengeance, that they are not the creations of his fancy, but the beings themselves who visit him from the regions of a remote world. This faith gives a solemnity to his reveries that render them to me almost as imposing and interesting as truth. + +Our conversations are not always confined to his own history and misfortunes. On every point of general literature he displays unbounded knowledge and a quick and piercing apprehension. His eloquence is forcible and touching; nor can I hear him, when he relates a pathetic incident or endeavours to move the passions of pity or love, without tears. What a glorious creature must he have been in the days of his prosperity, when he is thus noble and godlike in ruin! He seems to feel his own worth and the greatness of his fall. + +“When younger,” said he, “I believed myself destined for some great enterprise. My feelings are profound, but I possessed a coolness of judgment that fitted me for illustrious achievements. This sentiment of the worth of my nature supported me when others would have been oppressed, for I deemed it criminal to throw away in useless grief those talents that might be useful to my fellow creatures. When I reflected on the work I had completed, no less a one than the creation of a sensitive and rational animal, I could not rank myself with the herd of common projectors. But this thought, which supported me in the commencement of my career, now serves only to plunge me lower in the dust. All my speculations and hopes are as nothing, and like the archangel who aspired to omnipotence, I am chained in an eternal hell. My imagination was vivid, yet my powers of analysis and application were intense; by the union of these qualities I conceived the idea and executed the creation of a man. Even now I cannot recollect without passion my reveries while the work was incomplete. I trod heaven in my thoughts, now exulting in my powers, now burning with the idea of their effects. From my infancy I was imbued with high hopes and a lofty ambition; but how am I sunk! Oh! My friend, if you had known me as I once was, you would not recognise me in this state of degradation. Despondency rarely visited my heart; a high destiny seemed to bear me on, until I fell, never, never again to rise.” + +Must I then lose this admirable being? I have longed for a friend; I have sought one who would sympathise with and love me. Behold, on these desert seas I have found such a one, but I fear I have gained him only to know his value and lose him. I would reconcile him to life, but he repulses the idea. + +“I thank you, Walton,” he said, “for your kind intentions towards so miserable a wretch; but when you speak of new ties and fresh affections, think you that any can replace those who are gone? Can any man be to me as Clerval was, or any woman another Elizabeth? Even where the affections are not strongly moved by any superior excellence, the companions of our childhood always possess a certain power over our minds which hardly any later friend can obtain. They know our infantine dispositions, which, however they may be afterwards modified, are never eradicated; and they can judge of our actions with more certain conclusions as to the integrity of our motives. A sister or a brother can never, unless indeed such symptoms have been shown early, suspect the other of fraud or false dealing, when another friend, however strongly he may be attached, may, in spite of himself, be contemplated with suspicion. But I enjoyed friends, dear not only through habit and association, but from their own merits; and wherever I am, the soothing voice of my Elizabeth and the conversation of Clerval will be ever whispered in my ear. They are dead, and but one feeling in such a solitude can persuade me to preserve my life. If I were engaged in any high undertaking or design, fraught with extensive utility to my fellow creatures, then could I live to fulfil it. But such is not my destiny; I must pursue and destroy the being to whom I gave existence; then my lot on earth will be fulfilled and I may die.” + +My beloved Sister, + +September 2d. + +I write to you, encompassed by peril and ignorant whether I am ever doomed to see again dear England and the dearer friends that inhabit it. I am surrounded by mountains of ice which admit of no escape and threaten every moment to crush my vessel. The brave fellows whom I have persuaded to be my companions look towards me for aid, but I have none to bestow. There is something terribly appalling in our situation, yet my courage and hopes do not desert me. Yet it is terrible to reflect that the lives of all these men are endangered through me. If we are lost, my mad schemes are the cause. + +And what, Margaret, will be the state of your mind? You will not hear of my destruction, and you will anxiously await my return. Years will pass, and you will have visitings of despair and yet be tortured by hope. Oh! My beloved sister, the sickening failing of your heart-felt expectations is, in prospect, more terrible to me than my own death. But you have a husband and lovely children; you may be happy. Heaven bless you and make you so! + +My unfortunate guest regards me with the tenderest compassion. He endeavours to fill me with hope and talks as if life were a possession which he valued. He reminds me how often the same accidents have happened to other navigators who have attempted this sea, and in spite of myself, he fills me with cheerful auguries. Even the sailors feel the power of his eloquence; when he speaks, they no longer despair; he rouses their energies, and while they hear his voice they believe these vast mountains of ice are mole-hills which will vanish before the resolutions of man. These feelings are transitory; each day of expectation delayed fills them with fear, and I almost dread a mutiny caused by this despair. + +September 5th. + +A scene has just passed of such uncommon interest that, although it is highly probable that these papers may never reach you, yet I cannot forbear recording it. + +We are still surrounded by mountains of ice, still in imminent danger of being crushed in their conflict. The cold is excessive, and many of my unfortunate comrades have already found a grave amidst this scene of desolation. Frankenstein has daily declined in health; a feverish fire still glimmers in his eyes, but he is exhausted, and when suddenly roused to any exertion, he speedily sinks again into apparent lifelessness. + +I mentioned in my last letter the fears I entertained of a mutiny. This morning, as I sat watching the wan countenance of my friend—his eyes half closed and his limbs hanging listlessly—I was roused by half a dozen of the sailors, who demanded admission into the cabin. They entered, and their leader addressed me. He told me that he and his companions had been chosen by the other sailors to come in deputation to me to make me a requisition which, in justice, I could not refuse. We were immured in ice and should probably never escape, but they feared that if, as was possible, the ice should dissipate and a free passage be opened, I should be rash enough to continue my voyage and lead them into fresh dangers, after they might happily have surmounted this. They insisted, therefore, that I should engage with a solemn promise that if the vessel should be freed I would instantly direct my course southwards. + +This speech troubled me. I had not despaired, nor had I yet conceived the idea of returning if set free. Yet could I, in justice, or even in possibility, refuse this demand? I hesitated before I answered, when Frankenstein, who had at first been silent, and indeed appeared hardly to have force enough to attend, now roused himself; his eyes sparkled, and his cheeks flushed with momentary vigour. Turning towards the men, he said, + +“What do you mean? What do you demand of your captain? Are you, then, so easily turned from your design? Did you not call this a glorious expedition? “And wherefore was it glorious? Not because the way was smooth and placid as a southern sea, but because it was full of dangers and terror, because at every new incident your fortitude was to be called forth and your courage exhibited, because danger and death surrounded it, and these you were to brave and overcome. For this was it a glorious, for this was it an honourable undertaking. You were hereafter to be hailed as the benefactors of your species, your names adored as belonging to brave men who encountered death for honour and the benefit of mankind. And now, behold, with the first imagination of danger, or, if you will, the first mighty and terrific trial of your courage, you shrink away and are content to be handed down as men who had not strength enough to endure cold and peril; and so, poor souls, they were chilly and returned to their warm firesides. Why, that requires not this preparation; ye need not have come thus far and dragged your captain to the shame of a defeat merely to prove yourselves cowards. Oh! Be men, or be more than men. Be steady to your purposes and firm as a rock. This ice is not made of such stuff as your hearts may be; it is mutable and cannot withstand you if you say that it shall not. Do not return to your families with the stigma of disgrace marked on your brows. Return as heroes who have fought and conquered and who know not what it is to turn their backs on the foe.” + +He spoke this with a voice so modulated to the different feelings expressed in his speech, with an eye so full of lofty design and heroism, that can you wonder that these men were moved? They looked at one another and were unable to reply. I spoke; I told them to retire and consider of what had been said, that I would not lead them farther north if they strenuously desired the contrary, but that I hoped that, with reflection, their courage would return. + +They retired and I turned towards my friend, but he was sunk in languor and almost deprived of life. + +How all this will terminate, I know not, but I had rather die than return shamefully, my purpose unfulfilled. Yet I fear such will be my fate; the men, unsupported by ideas of glory and honour, can never willingly continue to endure their present hardships. + +September 7th. + +The die is cast; I have consented to return if we are not destroyed. Thus are my hopes blasted by cowardice and indecision; I come back ignorant and disappointed. It requires more philosophy than I possess to bear this injustice with patience. + +September 12th. + +It is past; I am returning to England. I have lost my hopes of utility and glory; I have lost my friend. But I will endeavour to detail these bitter circumstances to you, my dear sister; and while I am wafted towards England and towards you, I will not despond. + +September 9th, the ice began to move, and roarings like thunder were heard at a distance as the islands split and cracked in every direction. We were in the most imminent peril, but as we could only remain passive, my chief attention was occupied by my unfortunate guest whose illness increased in such a degree that he was entirely confined to his bed. The ice cracked behind us and was driven with force towards the north; a breeze sprang from the west, and on the 11th the passage towards the south became perfectly free. When the sailors saw this and that their return to their native country was apparently assured, a shout of tumultuous joy broke from them, loud and long-continued. Frankenstein, who was dozing, awoke and asked the cause of the tumult. “They shout,” I said, “because they will soon return to England.” + +“Do you, then, really return?” + +“Alas! Yes; I cannot withstand their demands. I cannot lead them unwillingly to danger, and I must return.” + +“Do so, if you will; but I will not. You may give up your purpose, but mine is assigned to me by Heaven, and I dare not. I am weak, but surely the spirits who assist my vengeance will endow me with sufficient strength.” Saying this, he endeavoured to spring from the bed, but the exertion was too great for him; he fell back and fainted. + +It was long before he was restored, and I often thought that life was entirely extinct. At length he opened his eyes; he breathed with difficulty and was unable to speak. The surgeon gave him a composing draught and ordered us to leave him undisturbed. In the meantime he told me that my friend had certainly not many hours to live. + +His sentence was pronounced, and I could only grieve and be patient. I sat by his bed, watching him; his eyes were closed, and I thought he slept; but presently he called to me in a feeble voice, and bidding me come near, said, “Alas! The strength I relied on is gone; I feel that I shall soon die, and he, my enemy and persecutor, may still be in being. Think not, Walton, that in the last moments of my existence I feel that burning hatred and ardent desire of revenge I once expressed; but I feel myself justified in desiring the death of my adversary. During these last days I have been occupied in examining my past conduct; nor do I find it blamable. In a fit of enthusiastic madness I created a rational creature and was bound towards him to assure, as far as was in my power, his happiness and well-being. This was my duty, but there was another still paramount to that. My duties towards the beings of my own species had greater claims to my attention because they included a greater proportion of happiness or misery. Urged by this view, I refused, and I did right in refusing, to create a companion for the first creature. He showed unparalleled malignity and selfishness in evil; he destroyed my friends; he devoted to destruction beings who possessed exquisite sensations, happiness, and wisdom; nor do I know where this thirst for vengeance may end. Miserable himself that he may render no other wretched, he ought to die. The task of his destruction was mine, but I have failed. When actuated by selfish and vicious motives, I asked you to undertake my unfinished work, and I renew this request now, when I am only induced by reason and virtue. + +“Yet I cannot ask you to renounce your country and friends to fulfil this task; and now that you are returning to England, you will have little chance of meeting with him. But the consideration of these points, and the well balancing of what you may esteem your duties, I leave to you; my judgment and ideas are already disturbed by the near approach of death. I dare not ask you to do what I think right, for I may still be misled by passion. + +“That he should live to be an instrument of mischief disturbs me; in other respects, this hour, when I momentarily expect my release, is the only happy one which I have enjoyed for several years. The forms of the beloved dead flit before me, and I hasten to their arms. Farewell, Walton! Seek happiness in tranquillity and avoid ambition, even if it be only the apparently innocent one of distinguishing yourself in science and discoveries. Yet why do I say this? I have myself been blasted in these hopes, yet another may succeed.” + +His voice became fainter as he spoke, and at length, exhausted by his effort, he sank into silence. About half an hour afterwards he attempted again to speak but was unable; he pressed my hand feebly, and his eyes closed for ever, while the irradiation of a gentle smile passed away from his lips. + +Margaret, what comment can I make on the untimely extinction of this glorious spirit? What can I say that will enable you to understand the depth of my sorrow? All that I should express would be inadequate and feeble. My tears flow; my mind is overshadowed by a cloud of disappointment. But I journey towards England, and I may there find consolation. + +I am interrupted. What do these sounds portend? It is midnight; the breeze blows fairly, and the watch on deck scarcely stir. Again there is a sound as of a human voice, but hoarser; it comes from the cabin where the remains of Frankenstein still lie. I must arise and examine. Good night, my sister. + +Great God! what a scene has just taken place! I am yet dizzy with the remembrance of it. I hardly know whether I shall have the power to detail it; yet the tale which I have recorded would be incomplete without this final and wonderful catastrophe. + +I entered the cabin where lay the remains of my ill-fated and admirable friend. Over him hung a form which I cannot find words to describe—gigantic in stature, yet uncouth and distorted in its proportions. As he hung over the coffin, his face was concealed by long locks of ragged hair; but one vast hand was extended, in colour and apparent texture like that of a mummy. When he heard the sound of my approach, he ceased to utter exclamations of grief and horror and sprung towards the window. Never did I behold a vision so horrible as his face, of such loathsome yet appalling hideousness. I shut my eyes involuntarily and endeavoured to recollect what were my duties with regard to this destroyer. I called on him to stay. + +He paused, looking on me with wonder, and again turning towards the lifeless form of his creator, he seemed to forget my presence, and every feature and gesture seemed instigated by the wildest rage of some uncontrollable passion. + +“That is also my victim!” he exclaimed. “In his murder my crimes are consummated; the miserable series of my being is wound to its close! Oh, Frankenstein! Generous and self-devoted being! What does it avail that I now ask thee to pardon me? I, who irretrievably destroyed thee by destroying all thou lovedst. Alas! He is cold, he cannot answer me.” + +His voice seemed suffocated, and my first impulses, which had suggested to me the duty of obeying the dying request of my friend in destroying his enemy, were now suspended by a mixture of curiosity and compassion. I approached this tremendous being; I dared not again raise my eyes to his face, there was something so scaring and unearthly in his ugliness. I attempted to speak, but the words died away on my lips. The monster continued to utter wild and incoherent self-reproaches. At length I gathered resolution to address him in a pause of the tempest of his passion. + +“Your repentance,” I said, “is now superfluous. If you had listened to the voice of conscience and heeded the stings of remorse before you had urged your diabolical vengeance to this extremity, Frankenstein would yet have lived.” + +“And do you dream?” said the dæmon. “Do you think that I was then dead to agony and remorse? He,” he continued, pointing to the corpse, “he suffered not in the consummation of the deed. Oh! Not the ten-thousandth portion of the anguish that was mine during the lingering detail of its execution. A frightful selfishness hurried me on, while my heart was poisoned with remorse. Think you that the groans of Clerval were music to my ears? My heart was fashioned to be susceptible of love and sympathy, and when wrenched by misery to vice and hatred, it did not endure the violence of the change without torture such as you cannot even imagine. + +“After the murder of Clerval I returned to Switzerland, heart-broken and overcome. I pitied Frankenstein; my pity amounted to horror; I abhorred myself. But when I discovered that he, the author at once of my existence and of its unspeakable torments, dared to hope for happiness, that while he accumulated wretchedness and despair upon me he sought his own enjoyment in feelings and passions from the indulgence of which I was for ever barred, then impotent envy and bitter indignation filled me with an insatiable thirst for vengeance. I recollected my threat and resolved that it should be accomplished. I knew that I was preparing for myself a deadly torture, but I was the slave, not the master, of an impulse which I detested yet could not disobey. Yet when she died! Nay, then I was not miserable. I had cast off all feeling, subdued all anguish, to riot in the excess of my despair. Evil thenceforth became my good. Urged thus far, I had no choice but to adapt my nature to an element which I had willingly chosen. The completion of my demoniacal design became an insatiable passion. And now it is ended; there is my last victim!” + +I was at first touched by the expressions of his misery; yet, when I called to mind what Frankenstein had said of his powers of eloquence and persuasion, and when I again cast my eyes on the lifeless form of my friend, indignation was rekindled within me. “Wretch!” I said. “It is well that you come here to whine over the desolation that you have made. You throw a torch into a pile of buildings, and when they are consumed, you sit among the ruins and lament the fall. Hypocritical fiend! If he whom you mourn still lived, still would he be the object, again would he become the prey, of your accursed vengeance. It is not pity that you feel; you lament only because the victim of your malignity is withdrawn from your power.” + +“Oh, it is not thus—not thus,” interrupted the being. “Yet such must be the impression conveyed to you by what appears to be the purport of my actions. Yet I seek not a fellow feeling in my misery. No sympathy may I ever find. When I first sought it, it was the love of virtue, the feelings of happiness and affection with which my whole being overflowed, that I wished to be participated. But now that virtue has become to me a shadow, and that happiness and affection are turned into bitter and loathing despair, in what should I seek for sympathy? I am content to suffer alone while my sufferings shall endure; when I die, I am well satisfied that abhorrence and opprobrium should load my memory. Once my fancy was soothed with dreams of virtue, of fame, and of enjoyment. Once I falsely hoped to meet with beings who, pardoning my outward form, would love me for the excellent qualities which I was capable of unfolding. I was nourished with high thoughts of honour and devotion. But now crime has degraded me beneath the meanest animal. No guilt, no mischief, no malignity, no misery, can be found comparable to mine. When I run over the frightful catalogue of my sins, I cannot believe that I am the same creature whose thoughts were once filled with sublime and transcendent visions of the beauty and the majesty of goodness. But it is even so; the fallen angel becomes a malignant devil. Yet even that enemy of God and man had friends and associates in his desolation; I am alone. + +“You, who call Frankenstein your friend, seem to have a knowledge of my crimes and his misfortunes. But in the detail which he gave you of them he could not sum up the hours and months of misery which I endured wasting in impotent passions. For while I destroyed his hopes, I did not satisfy my own desires. They were for ever ardent and craving; still I desired love and fellowship, and I was still spurned. Was there no injustice in this? Am I to be thought the only criminal, when all humankind sinned against me? Why do you not hate Felix, who drove his friend from his door with contumely? Why do you not execrate the rustic who sought to destroy the saviour of his child? Nay, these are virtuous and immaculate beings! I, the miserable and the abandoned, am an abortion, to be spurned at, and kicked, and trampled on. Even now my blood boils at the recollection of this injustice. + +“But it is true that I am a wretch. I have murdered the lovely and the helpless; I have strangled the innocent as they slept and grasped to death his throat who never injured me or any other living thing. I have devoted my creator, the select specimen of all that is worthy of love and admiration among men, to misery; I have pursued him even to that irremediable ruin. There he lies, white and cold in death. You hate me, but your abhorrence cannot equal that with which I regard myself. I look on the hands which executed the deed; I think on the heart in which the imagination of it was conceived and long for the moment when these hands will meet my eyes, when that imagination will haunt my thoughts no more. + +“Fear not that I shall be the instrument of future mischief. My work is nearly complete. Neither yours nor any man’s death is needed to consummate the series of my being and accomplish that which must be done, but it requires my own. Do not think that I shall be slow to perform this sacrifice. I shall quit your vessel on the ice raft which brought me thither and shall seek the most northern extremity of the globe; I shall collect my funeral pile and consume to ashes this miserable frame, that its remains may afford no light to any curious and unhallowed wretch who would create such another as I have been. I shall die. I shall no longer feel the agonies which now consume me or be the prey of feelings unsatisfied, yet unquenched. He is dead who called me into being; and when I shall be no more, the very remembrance of us both will speedily vanish. I shall no longer see the sun or stars or feel the winds play on my cheeks. Light, feeling, and sense will pass away; and in this condition must I find my happiness. Some years ago, when the images which this world affords first opened upon me, when I felt the cheering warmth of summer and heard the rustling of the leaves and the warbling of the birds, and these were all to me, I should have wept to die; now it is my only consolation. Polluted by crimes and torn by the bitterest remorse, where can I find rest but in death? + +“Farewell! I leave you, and in you the last of humankind whom these eyes will ever behold. Farewell, Frankenstein! If thou wert yet alive and yet cherished a desire of revenge against me, it would be better satiated in my life than in my destruction. But it was not so; thou didst seek my extinction, that I might not cause greater wretchedness; and if yet, in some mode unknown to me, thou hadst not ceased to think and feel, thou wouldst not desire against me a vengeance greater than that which I feel. Blasted as thou wert, my agony was still superior to thine, for the bitter sting of remorse will not cease to rankle in my wounds until death shall close them for ever. + +“But soon,” he cried with sad and solemn enthusiasm, “I shall die, and what I now feel be no longer felt. Soon these burning miseries will be extinct. I shall ascend my funeral pile triumphantly and exult in the agony of the torturing flames. The light of that conflagration will fade away; my ashes will be swept into the sea by the winds. My spirit will sleep in peace, or if it thinks, it will not surely think thus. Farewell.” + +He sprang from the cabin-window as he said this, upon the ice raft which lay close to the vessel. He was soon borne away by the waves and lost in darkness and distance. + +'''; diff --git a/super_editor/example_perf/lib/main.dart b/super_editor/example_perf/lib/main.dart new file mode 100644 index 0000000000..af4adfec54 --- /dev/null +++ b/super_editor/example_perf/lib/main.dart @@ -0,0 +1,276 @@ +import 'package:example_perf/demos/long_doc_demo.dart'; +import 'package:example_perf/demos/rebuild_demo.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +void main() { + runApp(const SuperEditorPerfDemoApp()); +} + +class SuperEditorPerfDemoApp extends StatelessWidget { + const SuperEditorPerfDemoApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Super Editor Performance Demo App', + theme: ThemeData( + primarySwatch: Colors.red, + ), + home: const HomeScreen(), + supportedLocales: const [ + Locale('en', ''), + Locale('es', ''), + ], + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + debugShowCheckedModeBanner: false, + ); + } +} + +/// Displays various demos that are selected from a list of +/// options in a drawer. +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final _scaffoldKey = GlobalKey(); + + _MenuItem? _selectedMenuItem; + + @override + void initState() { + super.initState(); + + _selectedMenuItem = _menu[0].items[0]; + } + + void _toggleDrawer() { + if (_scaffoldKey.currentState!.isDrawerOpen) { + Navigator.of(context).pop(); + } else { + _scaffoldKey.currentState!.openDrawer(); + } + } + + void _closeDrawer() { + if (_scaffoldKey.currentState!.isDrawerOpen) { + Navigator.of(context).pop(); + } + } + + void _selectMenuItem(_MenuItem item) { + setState(() { + _selectedMenuItem = item; + _closeDrawer(); + }); + } + + @override + Widget build(BuildContext context) { + // We need a FocusScope above the Overlay so that focus can be shared between + // SuperEditor in one OverlayEntry, and the popover toolbar in another OverlayEntry. + return FocusScope( + // We need our own [Overlay] instead of the one created by the navigator + // because overlay entries added to navigator's [Overlay] are always + // displayed above all routes. + // + // We display the editor's toolbar in an [OverlayEntry], so inserting it + // at the navigator's [Overlay] causes widgets that are displayed in routes, + // e.g. [DropdownButton] items, to be displayed beneath the toolbar. + child: Overlay( + initialEntries: [ + OverlayEntry(builder: (context) { + return Scaffold( + key: _scaffoldKey, + body: Stack( + children: [ + _selectedMenuItem!.pageBuilder(context), + _buildDrawerButton(), + ], + ), + drawer: _buildDrawer(), + ); + }) + ], + ), + ); + } + + Widget _buildDrawerButton() { + return SafeArea( + child: Material( + color: Colors.transparent, + child: SizedBox( + height: 56, + width: 56, + child: IconButton( + icon: const Icon(Icons.menu), + color: Theme.of(context).colorScheme.onSurface, + splashRadius: 24, + onPressed: _toggleDrawer, + ), + ), + ), + ); + } + + Widget _buildDrawer() { + return Drawer( + child: SingleChildScrollView( + primary: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final group in _menu) ...[ + if (group.title != null) _DrawerHeader(title: group.title), + for (final item in group.items) ...[ + _DrawerButton( + icon: item.icon, + title: item.title, + isSelected: item == _selectedMenuItem, + onPressed: () { + _selectMenuItem(item); + }, + ), + ], + const SizedBox(height: 24), + ], + ], + ), + ), + ), + ); + } +} + +// Demo options that are shown in the `HomeScreen` drawer. +final _menu = <_MenuGroup>[ + _MenuGroup( + title: 'Super Editor', + items: [ + _MenuItem( + icon: Icons.description, + title: 'Rebuild Count Demo', + pageBuilder: (context) { + return const RebuildCountDemo(); + }, + ), + _MenuItem( + icon: Icons.description, + title: 'Long Doc Demo', + pageBuilder: (context) { + return const LongDocDemo(); + }, + ), + ], + ), +]; + +class _MenuGroup { + const _MenuGroup({ + this.title, + required this.items, + }); + + final String? title; + final List<_MenuItem> items; +} + +class _MenuItem { + const _MenuItem({ + required this.icon, + required this.title, + required this.pageBuilder, + }); + + final IconData icon; + final String title; + final WidgetBuilder pageBuilder; +} + +class _DrawerHeader extends StatelessWidget { + const _DrawerHeader({ + required this.title, + }); + + final String? title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 16, bottom: 4), + child: Text( + title!, + style: const TextStyle( + color: Color(0xFF444444), + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} + +class _DrawerButton extends StatelessWidget { + const _DrawerButton({ + required this.icon, + required this.title, + this.isSelected = false, + required this.onPressed, + }); + + final IconData icon; + final String title; + final bool isSelected; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ButtonStyle( + backgroundColor: WidgetStateColor.resolveWith((states) { + if (isSelected) { + return const Color(0xFFBBBBBB); + } + + if (states.contains(WidgetState.hovered)) { + return Colors.grey.withValues(alpha: 0.1); + } + + return Colors.transparent; + }), + // splashFactory: NoSplash.splashFactory, + foregroundColor: + WidgetStateColor.resolveWith((states) => isSelected ? Colors.white : const Color(0xFFBBBBBB)), + elevation: WidgetStateProperty.resolveWith((states) => 0), + padding: WidgetStateProperty.resolveWith((states) => const EdgeInsets.all(16))), + onPressed: isSelected ? null : onPressed, + child: Row( + children: [ + const SizedBox(width: 8), + Icon( + icon, + ), + const SizedBox(width: 16), + Expanded( + child: Text(title), + ), + ], + ), + ), + ); + } +} diff --git a/super_editor/example_perf/linux/.gitignore b/super_editor/example_perf/linux/.gitignore new file mode 100644 index 0000000000..d3896c9844 --- /dev/null +++ b/super_editor/example_perf/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/super_editor/example_perf/linux/CMakeLists.txt b/super_editor/example_perf/linux/CMakeLists.txt new file mode 100644 index 0000000000..e3345acec9 --- /dev/null +++ b/super_editor/example_perf/linux/CMakeLists.txt @@ -0,0 +1,145 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example_perf") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.example_perf") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/super_editor/example_perf/linux/flutter/CMakeLists.txt b/super_editor/example_perf/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000000..d5bd01648a --- /dev/null +++ b/super_editor/example_perf/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/super_editor/example_perf/linux/flutter/generated_plugin_registrant.cc b/super_editor/example_perf/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..f6f23bfe97 --- /dev/null +++ b/super_editor/example_perf/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/super_editor/example_perf/linux/flutter/generated_plugin_registrant.h b/super_editor/example_perf/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..e0f0a47bc0 --- /dev/null +++ b/super_editor/example_perf/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/super_editor/example_perf/linux/flutter/generated_plugins.cmake b/super_editor/example_perf/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..f16b4c3421 --- /dev/null +++ b/super_editor/example_perf/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/super_editor/example_perf/linux/main.cc b/super_editor/example_perf/linux/main.cc new file mode 100644 index 0000000000..e7c5c54370 --- /dev/null +++ b/super_editor/example_perf/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/super_editor/example_perf/linux/my_application.cc b/super_editor/example_perf/linux/my_application.cc new file mode 100644 index 0000000000..b6a208eaa8 --- /dev/null +++ b/super_editor/example_perf/linux/my_application.cc @@ -0,0 +1,124 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "example_perf"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "example_perf"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/super_editor/example_perf/linux/my_application.h b/super_editor/example_perf/linux/my_application.h new file mode 100644 index 0000000000..72271d5e41 --- /dev/null +++ b/super_editor/example_perf/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/super_editor/example_perf/macos/.gitignore b/super_editor/example_perf/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/super_editor/example_perf/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/super_editor/example_perf/macos/Flutter/Flutter-Debug.xcconfig b/super_editor/example_perf/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..4b81f9b2d2 --- /dev/null +++ b/super_editor/example_perf/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_editor/example_perf/macos/Flutter/Flutter-Release.xcconfig b/super_editor/example_perf/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5caa9d1579 --- /dev/null +++ b/super_editor/example_perf/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_editor/example_perf/macos/Flutter/GeneratedPluginRegistrant.swift b/super_editor/example_perf/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..8236f5728c --- /dev/null +++ b/super_editor/example_perf/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/super_editor/example_perf/macos/Podfile b/super_editor/example_perf/macos/Podfile new file mode 100644 index 0000000000..c795730db8 --- /dev/null +++ b/super_editor/example_perf/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14' + +# 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__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/super_editor/example_perf/macos/Runner.xcodeproj/project.pbxproj b/super_editor/example_perf/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..ae77ed4e81 --- /dev/null +++ b/super_editor/example_perf/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,695 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + 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 */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 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 */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 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 */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example_perf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example_perf.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example_perf.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 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 /* example_perf.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 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 */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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; + alwaysOutOfDate = 1; + 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 */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 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 = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.examplePerf.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example_perf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example_perf"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.examplePerf.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example_perf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example_perf"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.examplePerf.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example_perf.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example_perf"; + }; + name = Profile; + }; + 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.14; + 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.14; + 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.14; + 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 */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 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 */; +} diff --git a/super_editor/example_perf/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor/example_perf/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor/example_perf/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor/example_perf/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_editor/example_perf/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..cf66581495 --- /dev/null +++ b/super_editor/example_perf/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_perf/macos/Runner.xcworkspace/contents.xcworkspacedata b/super_editor/example_perf/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..1d526a16ed --- /dev/null +++ b/super_editor/example_perf/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_editor/example_perf/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor/example_perf/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor/example_perf/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor/example_perf/macos/Runner/AppDelegate.swift b/super_editor/example_perf/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..d53ef64377 --- /dev/null +++ b/super_editor/example_perf/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..82b6f9d9a3 Binary files /dev/null and b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000..13b35eba55 Binary files /dev/null and b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000..0a3f5fa40f Binary files /dev/null and b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000..bdb57226d5 Binary files /dev/null and b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000000..f083318e09 Binary files /dev/null and b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000..326c0e72c9 Binary files /dev/null and b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000..2f1632cfdd Binary files /dev/null and b/super_editor/example_perf/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/super_editor/example_perf/macos/Runner/Base.lproj/MainMenu.xib b/super_editor/example_perf/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/super_editor/example_perf/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor/example_perf/macos/Runner/Configs/AppInfo.xcconfig b/super_editor/example_perf/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..32e9cd8975 --- /dev/null +++ b/super_editor/example_perf/macos/Runner/Configs/AppInfo.xcconfig @@ -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 = example_perf + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.examplePerf + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. diff --git a/super_editor/example_perf/macos/Runner/Configs/Debug.xcconfig b/super_editor/example_perf/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/super_editor/example_perf/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_editor/example_perf/macos/Runner/Configs/Release.xcconfig b/super_editor/example_perf/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/super_editor/example_perf/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_editor/example_perf/macos/Runner/Configs/Warnings.xcconfig b/super_editor/example_perf/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/super_editor/example_perf/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/super_editor/example_perf/macos/Runner/DebugProfile.entitlements b/super_editor/example_perf/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/super_editor/example_perf/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/super_editor/example_perf/macos/Runner/Info.plist b/super_editor/example_perf/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/super_editor/example_perf/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/super_editor/example_perf/macos/Runner/MainFlutterWindow.swift b/super_editor/example_perf/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..3cc05eb234 --- /dev/null +++ b/super_editor/example_perf/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/super_editor/example_perf/macos/Runner/Release.entitlements b/super_editor/example_perf/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/super_editor/example_perf/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/super_editor/example_perf/macos/RunnerTests/RunnerTests.swift b/super_editor/example_perf/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..5418c9f539 --- /dev/null +++ b/super_editor/example_perf/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import FlutterMacOS +import Cocoa +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_editor/example_perf/pubspec.lock b/super_editor/example_perf/pubspec.lock new file mode 100644 index 0000000000..e2438001d5 --- /dev/null +++ b/super_editor/example_perf/pubspec.lock @@ -0,0 +1,675 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + attributed_text: + dependency: "direct overridden" + description: + path: "../../attributed_text" + relative: true + source: path + version: "0.3.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_test_robots: + dependency: transitive + description: + name: flutter_test_robots + sha256: bccae4f8d67d0bc0c79d1c31f4dc2db09beb35545563a5bd27d0a792c9d26446 + url: "https://pub.dev" + source: hosted + version: "0.0.23" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + follow_the_leader: + dependency: transitive + description: + name: follow_the_leader + sha256: "798baf5211ca2461c8462d4c8e94f57bf989758f8204056d607eb9a20f1cf794" + url: "https://pub.dev" + source: hosted + version: "0.0.4+8" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + http: + dependency: transitive + description: + name: http + sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "39caf989ccc72c63e87b961851a74257141938599ed2db45fbd9403fee0db423" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + meta: + dependency: transitive + description: + name: meta + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + mime: + dependency: transitive + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlord: + dependency: transitive + description: + name: overlord + sha256: "576256bc9ce3fb0ae3042bbb26eed67bdb26a5045dd7e3c851aae65b0bbab2f5" + url: "https://pub.dev" + source: hosted + version: "0.0.3+5" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + super_editor: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.2.6" + super_editor_markdown: + dependency: "direct overridden" + description: + path: "../../super_editor_markdown" + relative: true + source: path + version: "0.1.5" + super_text_layout: + dependency: "direct main" + description: + path: "../../super_text_layout" + relative: true + source: path + version: "0.1.9" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + url: "https://pub.dev" + source: hosted + version: "1.25.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" + url: "https://pub.dev" + source: hosted + version: "6.2.6" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + uuid: + dependency: transitive + description: + name: uuid + sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" + url: "https://pub.dev" + source: hosted + version: "4.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + url: "https://pub.dev" + source: hosted + version: "14.2.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.19.0" diff --git a/super_editor/example_perf/pubspec.yaml b/super_editor/example_perf/pubspec.yaml new file mode 100644 index 0000000000..bec910838b --- /dev/null +++ b/super_editor/example_perf/pubspec.yaml @@ -0,0 +1,104 @@ +name: example_perf +description: "A new Flutter project." +# 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 + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ">=3.3.0-170.0.dev <4.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 + flutter_localizations: + sdk: flutter + super_editor: + path: ../ + super_text_layout: + path: ../../super_text_layout + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.6 + +dependency_overrides: + # Override to local mono-repo path so devs can test this repo + # against changes that they're making to other mono-repo packages + super_editor: + path: ../ + super_text_layout: + path: ../../super_text_layout + attributed_text: + path: ../../attributed_text + +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: ^3.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 packages. +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 diff --git a/super_editor/example_perf/web/favicon.png b/super_editor/example_perf/web/favicon.png new file mode 100644 index 0000000000..8aaa46ac1a Binary files /dev/null and b/super_editor/example_perf/web/favicon.png differ diff --git a/super_editor/example_perf/web/icons/Icon-192.png b/super_editor/example_perf/web/icons/Icon-192.png new file mode 100644 index 0000000000..b749bfef07 Binary files /dev/null and b/super_editor/example_perf/web/icons/Icon-192.png differ diff --git a/super_editor/example_perf/web/icons/Icon-512.png b/super_editor/example_perf/web/icons/Icon-512.png new file mode 100644 index 0000000000..88cfd48dff Binary files /dev/null and b/super_editor/example_perf/web/icons/Icon-512.png differ diff --git a/super_editor/example_perf/web/icons/Icon-maskable-192.png b/super_editor/example_perf/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000..eb9b4d76e5 Binary files /dev/null and b/super_editor/example_perf/web/icons/Icon-maskable-192.png differ diff --git a/super_editor/example_perf/web/icons/Icon-maskable-512.png b/super_editor/example_perf/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000..d69c56691f Binary files /dev/null and b/super_editor/example_perf/web/icons/Icon-maskable-512.png differ diff --git a/super_editor/example_perf/web/index.html b/super_editor/example_perf/web/index.html new file mode 100644 index 0000000000..9a4b2e449d --- /dev/null +++ b/super_editor/example_perf/web/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + example_perf + + + + + + + + + + diff --git a/super_editor/example_perf/web/manifest.json b/super_editor/example_perf/web/manifest.json new file mode 100644 index 0000000000..ebeec4830b --- /dev/null +++ b/super_editor/example_perf/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example_perf", + "short_name": "example_perf", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/super_editor/example_perf/windows/.gitignore b/super_editor/example_perf/windows/.gitignore new file mode 100644 index 0000000000..d492d0d98c --- /dev/null +++ b/super_editor/example_perf/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/super_editor/example_perf/windows/CMakeLists.txt b/super_editor/example_perf/windows/CMakeLists.txt new file mode 100644 index 0000000000..6402be77c5 --- /dev/null +++ b/super_editor/example_perf/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(example_perf LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example_perf") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/super_editor/example_perf/windows/flutter/CMakeLists.txt b/super_editor/example_perf/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000000..903f4899d6 --- /dev/null +++ b/super_editor/example_perf/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/super_editor/example_perf/windows/flutter/generated_plugin_registrant.cc b/super_editor/example_perf/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..4f7884874d --- /dev/null +++ b/super_editor/example_perf/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/super_editor/example_perf/windows/flutter/generated_plugin_registrant.h b/super_editor/example_perf/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..dc139d85a9 --- /dev/null +++ b/super_editor/example_perf/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/super_editor/example_perf/windows/flutter/generated_plugins.cmake b/super_editor/example_perf/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..88b22e5c77 --- /dev/null +++ b/super_editor/example_perf/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/super_editor/example_perf/windows/runner/CMakeLists.txt b/super_editor/example_perf/windows/runner/CMakeLists.txt new file mode 100644 index 0000000000..394917c053 --- /dev/null +++ b/super_editor/example_perf/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/super_editor/example_perf/windows/runner/Runner.rc b/super_editor/example_perf/windows/runner/Runner.rc new file mode 100644 index 0000000000..45bf39e816 --- /dev/null +++ b/super_editor/example_perf/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "example_perf" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example_perf" "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "example_perf.exe" "\0" + VALUE "ProductName", "example_perf" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/super_editor/example_perf/windows/runner/flutter_window.cpp b/super_editor/example_perf/windows/runner/flutter_window.cpp new file mode 100644 index 0000000000..955ee3038f --- /dev/null +++ b/super_editor/example_perf/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/super_editor/example_perf/windows/runner/flutter_window.h b/super_editor/example_perf/windows/runner/flutter_window.h new file mode 100644 index 0000000000..6da0652f05 --- /dev/null +++ b/super_editor/example_perf/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/super_editor/example_perf/windows/runner/main.cpp b/super_editor/example_perf/windows/runner/main.cpp new file mode 100644 index 0000000000..64be783b94 --- /dev/null +++ b/super_editor/example_perf/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"example_perf", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/super_editor/example_perf/windows/runner/resource.h b/super_editor/example_perf/windows/runner/resource.h new file mode 100644 index 0000000000..66a65d1e4a --- /dev/null +++ b/super_editor/example_perf/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/super_editor/example_perf/windows/runner/resources/app_icon.ico b/super_editor/example_perf/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000..c04e20caf6 Binary files /dev/null and b/super_editor/example_perf/windows/runner/resources/app_icon.ico differ diff --git a/super_editor/example_perf/windows/runner/runner.exe.manifest b/super_editor/example_perf/windows/runner/runner.exe.manifest new file mode 100644 index 0000000000..a42ea7687c --- /dev/null +++ b/super_editor/example_perf/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/super_editor/example_perf/windows/runner/utils.cpp b/super_editor/example_perf/windows/runner/utils.cpp new file mode 100644 index 0000000000..b2b08734db --- /dev/null +++ b/super_editor/example_perf/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length <= 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/super_editor/example_perf/windows/runner/utils.h b/super_editor/example_perf/windows/runner/utils.h new file mode 100644 index 0000000000..3879d54755 --- /dev/null +++ b/super_editor/example_perf/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/super_editor/example_perf/windows/runner/win32_window.cpp b/super_editor/example_perf/windows/runner/win32_window.cpp new file mode 100644 index 0000000000..60608d0fe5 --- /dev/null +++ b/super_editor/example_perf/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/super_editor/example_perf/windows/runner/win32_window.h b/super_editor/example_perf/windows/runner/win32_window.h new file mode 100644 index 0000000000..e901dde684 --- /dev/null +++ b/super_editor/example_perf/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/super_editor/lib/src/chat/message_page_scaffold.dart b/super_editor/lib/src/chat/message_page_scaffold.dart new file mode 100644 index 0000000000..b3455a6300 --- /dev/null +++ b/super_editor/lib/src/chat/message_page_scaffold.dart @@ -0,0 +1,1566 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_keyboard/super_keyboard.dart'; + +/// A scaffold for a chat experience in which a conversation thread is +/// displayed, with a message editor mounted to the bottom of the chat area. +/// +/// In the case of an app running on a phone, this scaffold is typically used +/// for the entire screen. On a tablet, this scaffold might be used for just a +/// chat pane. +/// +/// The bottom sheet in this scaffold supports various sizing modes. These modes +/// can be queried and altered with a given [controller]. +class MessagePageScaffold extends RenderObjectWidget { + const MessagePageScaffold({ + super.key, + this.controller, + required this.contentBuilder, + required this.bottomSheetBuilder, + this.bottomSheetMinimumTopGap = 200, + this.bottomSheetMinimumHeight = 150, + this.bottomSheetCollapsedMaximumHeight = double.infinity, + }); + + final MessagePageController? controller; + + /// Builds the content within this scaffold, e.g., a chat conversation thread. + final MessagePageScaffoldContentBuilder contentBuilder; + + /// Builds the bottom sheet within this scaffold, e.g., a chat message editor. + final WidgetBuilder bottomSheetBuilder; + + /// When dragging the bottom sheet up, or when filling it with content, + /// this is the minimum gap allowed between the sheet and the top of this + /// scaffold. + /// + /// When the bottom sheet reaches the minimum gap, it stops getting taller, + /// and its content scrolls. + final double bottomSheetMinimumTopGap; + + /// The shortest that the bottom sheet can ever be, regardless of content or + /// height mode. + final double bottomSheetMinimumHeight; + + /// The maximum height that the bottom sheet can expand to, as the intrinsic height + /// of the content increases. + /// + /// E.g., The user starts with a single line of text and then starts inserting + /// newlines. As the user continues to add newlines, this height is where the sheet + /// stops growing taller. + /// + /// This height applies when the sheet is collapsed, i.e., not expanded. If the user + /// expands the sheet, then the maximum height of the sheet would be the maximum allowed + /// layout height, minus [bottomSheetMinimumTopGap]. + final double bottomSheetCollapsedMaximumHeight; + + @override + RenderObjectElement createElement() { + return MessagePageElement(this); + } + + @override + RenderMessagePageScaffold createRenderObject(BuildContext context) { + return RenderMessagePageScaffold( + context as MessagePageElement, + controller, + bottomSheetMinimumTopGap: bottomSheetMinimumTopGap, + bottomSheetMinimumHeight: bottomSheetMinimumHeight, + bottomSheetCollapsedMaximumHeight: bottomSheetCollapsedMaximumHeight, + ); + } + + @override + void updateRenderObject( + BuildContext context, RenderMessagePageScaffold renderObject) { + renderObject + ..bottomSheetMinimumTopGap = bottomSheetMinimumTopGap + ..bottomSheetMinimumHeight = bottomSheetMinimumHeight + ..bottomSheetCollapsedMaximumHeight = bottomSheetCollapsedMaximumHeight; + + if (controller != null) { + renderObject.controller = controller!; + } + } +} + +/// Builder that builds the content subtree within a [MessagePageScaffold]. +typedef MessagePageScaffoldContentBuilder = Widget Function( + BuildContext context, double bottomSpacing); + +/// Height sizing policy for a bottom sheet within a [MessagePageScaffold]. +enum BottomSheetMode { + /// The bottom sheet is as small possible, showing a partial display of its + /// overall content. + preview, + + /// The bottom sheet is intrinsically sized, making itself as tall as it + /// wants, so long as it doesn't exceed the maximum height. + intrinsic, + + /// The user is dragging the sheet - it's exactly the height needed to match + /// the user's finger position, clamped between a minimum and maximum height. + dragging, + + /// The user released a drag and the sheet is animating either to an + /// [intrinsic] or [expanded] position. + settling, + + /// The sheet is forced to be as tall as it can be, up to the maximum height. + expanded; +} + +/// Controller for a [MessagePageScaffold]. +class MessagePageController with ChangeNotifier { + MessagePageSheetHeightPolicy get sheetHeightPolicy => _sheetHeightPolicy; + MessagePageSheetHeightPolicy _sheetHeightPolicy = + MessagePageSheetHeightPolicy.minimumHeight; + set sheetHeightPolicy(MessagePageSheetHeightPolicy policy) { + if (policy == _sheetHeightPolicy) { + return; + } + + _sheetHeightPolicy = policy; + notifyListeners(); + } + + bool get isPreview => + _collapsedMode == MessagePageSheetCollapsedMode.preview && + !isSliding && + !isDragging; + + bool get isIntrinsic => + _collapsedMode == MessagePageSheetCollapsedMode.intrinsic && + !isSliding && + !isDragging; + + MessagePageSheetCollapsedMode get collapsedMode => _collapsedMode; + var _collapsedMode = MessagePageSheetCollapsedMode.preview; + set collapsedMode(MessagePageSheetCollapsedMode newMode) { + if (newMode == _collapsedMode) { + return; + } + + _collapsedMode = newMode; + notifyListeners(); + } + + bool get isCollapsed => + _desiredSheetMode == MessagePageSheetMode.collapsed && + !isSliding && + !isDragging; + + bool get isExpanded => + _desiredSheetMode == MessagePageSheetMode.expanded && + !isSliding && + !isDragging; + + bool get isSliding => _isSliding; + bool _isSliding = false; + set isSliding(bool newValue) { + if (newValue == _isSliding) { + return; + } + + _isSliding = newValue; + notifyListeners(); + } + + MessagePageSheetMode get desiredSheetMode => _desiredSheetMode; + MessagePageSheetMode _desiredSheetMode = MessagePageSheetMode.collapsed; + set desiredSheetMode(MessagePageSheetMode sheetMode) { + if (sheetMode == _desiredSheetMode) { + return; + } + + _desiredSheetMode = sheetMode; + notifyListeners(); + } + + /// Sets the bottom sheet's desired mode to `collapsed`. + /// + /// Even in the collapsed mode, the sheet might be taller or shorter + /// than the stable collapsed height, because the user can drag the + /// sheet, and the sheet also animates from the drag position to the + /// desired mode. + void collapse() { + if (_desiredSheetMode == MessagePageSheetMode.collapsed) { + return; + } + + _desiredSheetMode = MessagePageSheetMode.collapsed; + notifyListeners(); + } + + /// Sets the bottom sheet's desired mode to `expanded`. + /// + /// Even in the expanded mode, the sheet might be taller or shorter + /// than the stable expanded height, because the user can drag the + /// sheet, and the sheet also animates from the drag position to the + /// desired mode. + void expand() { + if (_desiredSheetMode == MessagePageSheetMode.expanded) { + return; + } + + _desiredSheetMode = MessagePageSheetMode.expanded; + notifyListeners(); + } + + /// The user's current drag interaction with the editor sheet. + MessagePageDragMode get dragMode => _dragMode; + MessagePageDragMode _dragMode = MessagePageDragMode.idle; + + bool get isIdle => dragMode == MessagePageDragMode.idle; + + bool get isDragging => dragMode == MessagePageDragMode.dragging; + + /// When the user is dragging up/down on the editor, this is the desired + /// y-value of the top edge of the editor area. + /// + /// This y-value may not be precisely respected, e.g., the user drags so far + /// up that this value exceeds the max y-value allowed for the editor. + double? get desiredGlobalTopY => _desiredGlobalTopY; + double? _desiredGlobalTopY; + + void onDragStart(double desiredGlobalTopY) { + assert( + _dragMode == MessagePageDragMode.idle, + 'You called onDragStart() while a drag is in progress. You need to end one drag before starting another.', + ); + + _dragMode = MessagePageDragMode.dragging; + _desiredGlobalTopY = desiredGlobalTopY; + + notifyListeners(); + } + + void onDragUpdate(double desiredGlobalTopY) { + assert( + _dragMode == MessagePageDragMode.dragging, + 'You must call onDragStart() before calling onDragUpdate()', + ); + if (desiredGlobalTopY == _desiredGlobalTopY) { + return; + } + + _desiredGlobalTopY = desiredGlobalTopY; + + notifyListeners(); + } + + void onDragEnd() { + assert( + _dragMode == MessagePageDragMode.dragging, + 'You must call onDragStart() before calling onDragEnd()', + ); + + _dragMode = MessagePageDragMode.idle; + _desiredGlobalTopY = null; + + notifyListeners(); + } + + /// The bottom spacing that was most recently used to build the scaffold. + /// + /// This is a debug value and should only be used for logging. + final debugMostRecentBottomSpacing = ValueNotifier(null); +} + +enum MessagePageSheetHeightPolicy { + minimumHeight('minimum'), + intrinsicHeight('intrinsic'); + + const MessagePageSheetHeightPolicy(this.name); + + final String name; +} + +enum MessagePageSheetCollapsedMode { + /// The bottom sheet should be explicitly sized with a preview of its content. + preview('preview'), + + /// The bottom sheet should be sized intrinsically, clamped by a minimum and + /// maximum height. + intrinsic('intrinsic'); + + const MessagePageSheetCollapsedMode(this.name); + + final String name; +} + +enum MessagePageSheetMode { + collapsed('collapsed'), + expanded('expanded'); + + const MessagePageSheetMode(this.name); + + final String name; +} + +enum MessagePageDragMode { + idle('idle'), + dragging('dragging'); + + const MessagePageDragMode(this.name); + + final String name; +} + +/// `Element` for a [MessagePageScaffold] widget. +class MessagePageElement extends RenderObjectElement { + MessagePageElement(MessagePageScaffold super.widget); + + Element? _content; + Element? _bottomSheet; + + @override + MessagePageScaffold get widget => super.widget as MessagePageScaffold; + + @override + RenderMessagePageScaffold get renderObject => + super.renderObject as RenderMessagePageScaffold; + + @override + void mount(Element? parent, Object? newSlot) { + messagePageElementLog.info('MessagePageElement - mounting'); + super.mount(parent, newSlot); + + _content = inflateWidget( + // Run initial build with zero bottom spacing because we haven't + // run layout on the message editor yet, which determines the content + // bottom spacing. + widget.contentBuilder(this, 0), + _contentSlot, + ); + + _bottomSheet = + inflateWidget(widget.bottomSheetBuilder(this), _bottomSheetSlot); + } + + @override + void activate() { + messagePageElementLog.info('MessagePageElement - activating'); + _didActivateSinceLastBuild = false; + super.activate(); + } + + // Whether this `Element` has been built since the last time `activate()` was run. + var _didActivateSinceLastBuild = false; + + @override + void deactivate() { + messagePageElementLog.info('MessagePageElement - deactivating'); + _didDeactivateSinceLastBuild = false; + super.deactivate(); + } + + // Whether this `Element` has been built since the last time `deactivate()` was run. + bool _didDeactivateSinceLastBuild = false; + + @override + void unmount() { + messagePageElementLog.info('MessagePageElement - unmounting'); + super.unmount(); + } + + @override + void markNeedsBuild() { + super.markNeedsBuild(); + + // Invalidate our content child's layout. + // + // Typically, nothing needs to be done in this method for children, because + // typically the superclass marks children as needing to rebuild and that's + // it. But our content only builds during layout. Therefore, to schedule a + // build for our content, we need to request a new layout pass, which we do + // here. + // + // Note: `markNeedsBuild()` is called when ancestor inherited widgets change + // their value. Failure to honor this method would result in our + // subtrees missing rebuilds related to ancestors changing. + _content?.renderObject?.markNeedsLayout(); + } + + @override + void performRebuild() { + super.performRebuild(); + + // Rebuild our bottom sheet widget. + // + // We don't rebuild our content widget because we only want content to + // build during layout. + updateChild( + _bottomSheet, widget.bottomSheetBuilder(this), _bottomSheetSlot); + } + + void buildContent(double bottomSpacing) { + messagePageElementLog.info('MessagePageElement ($hashCode) - (re)building content'); + widget.controller?.debugMostRecentBottomSpacing.value = bottomSpacing; + + owner!.buildScope(this, () { + if (_content == null) { + _content = inflateWidget( + widget.contentBuilder(this, bottomSpacing), + _contentSlot, + ); + } else { + _content = super.updateChild( + _content, + widget.contentBuilder(this, bottomSpacing), + _contentSlot, + ); + } + }); + + // The activation and deactivation processes involve visiting children, which + // we must honor, but the visitation happens some time after the actual call + // to activate and deactivate. So we remember when activation and deactivation + // happened, and now that we've built the `_content`, we clear those flags because + // we assume whatever visitation those processes need to do is now done, since + // we did a build. To learn more about this situation, look at `visitChildren`. + _didActivateSinceLastBuild = false; + _didDeactivateSinceLastBuild = false; + } + + @override + void update(MessagePageScaffold newWidget) { + super.update(newWidget); + + _content = + updateChild(_content, widget.contentBuilder(this, 0), _contentSlot) ?? + _content; + _bottomSheet = updateChild( + _bottomSheet, widget.bottomSheetBuilder(this), _bottomSheetSlot); + } + + @override + Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) { + if (newSlot == _contentSlot) { + // Only rebuild the content during layout because it depends upon bottom + // spacing. Mark needs layout so that we ensure a rebuild happens. + renderObject.markNeedsLayout(); + return null; + } + + return super.updateChild(child, newWidget, newSlot); + } + + @override + void insertRenderObjectChild(RenderObject child, Object? slot) { + assert( + _isChatScaffoldSlot(slot!), + 'Invalid ChatScaffold child slot: $slot', + ); + + renderObject.insertChild(child, slot!); + } + + @override + void moveRenderObjectChild( + RenderObject child, + Object? oldSlot, + Object? newSlot, + ) { + assert( + child.parent == renderObject, + 'Render object protocol violation - tried to move a render object within a parent that already owns it.', + ); + assert( + oldSlot != null, + 'Render object protocol violation - tried to move a render object with a null oldSlot', + ); + assert( + newSlot != null, + 'Render object protocol violation - tried to move a render object with a null newSlot', + ); + assert( + _isChatScaffoldSlot(oldSlot!), + 'Invalid ChatScaffold child slot: $oldSlot', + ); + assert( + _isChatScaffoldSlot(newSlot!), + 'Invalid ChatScaffold child slot: $newSlot', + ); + assert( + child is RenderBox, + 'Expected RenderBox child but was given: ${child.runtimeType}', + ); + + if (child is! RenderBox) { + return; + } + + if (oldSlot == _contentSlot && newSlot == _bottomSheetSlot) { + renderObject._bottomSheet = child; + } else if (oldSlot == _bottomSheetSlot && newSlot == _contentSlot) { + renderObject._content = child; + } + } + + @override + void removeRenderObjectChild(RenderObject child, Object? slot) { + assert( + child is RenderBox, + 'Invalid child type (${child.runtimeType}), expected RenderBox', + ); + assert( + child.parent == renderObject, + 'Render object protocol violation - tried to remove render object that is not owned by this parent', + ); + assert( + slot != null, + 'Render object protocol violation - tried to remove a render object for a null slot', + ); + assert( + _isChatScaffoldSlot(slot!), + 'Invalid ChatScaffold child slot: $slot', + ); + + renderObject.removeChild(child, slot!); + } + + @override + void visitChildren(ElementVisitor visitor) { + if (_bottomSheet != null) { + visitor(_bottomSheet!); + } + + // Building the `_content` is tricky and we're still not sure how to do it + // correctly. Originally, we refused to visit `_content` when `WidgetsBinding.instance.locked` + // is `true`. The original warning about this was the following: + // + // WARNING: Do not visit content when "locked". If you do, then the pipeline + // owner will collect that child for rebuild, e.g., for hot reload, and the + // pipeline owner will tell it to build before the message editor is laid + // out. We only want the content to build during the layout phase, after the + // message editor is laid out. + // + // However, error stacktraces have been showing up for a while whenever the tree + // structure adds/removes widgets in the tree. One way to see this was to open the + // Flutter debugger and enable the widget selector. This adds the widget selector + // widget to tree, and seems to trigger the bug: + // + // 'package:flutter/src/widgets/framework.dart': Failed assertion: line 6164 pos 14: + // '_dependents.isEmpty': is not true. + // + // This happens because when this `Element` runs `deactivate()`, its super class visits + // all the children to deactivate them, too. When that happens, we're apparently + // locked, so we weren't visiting `_content`. This resulted in an error for any + // `_content` subtree widget that setup an `InheritedWidget` dependency, because + // that dependency didn't have a chance to release. + // + // To deal with deactivation, I tried adding a flag during deactivation so that + // we visit `_content` during deactivation. I then discovered that the visitation + // related to deactivation happens sometime after the call to `deactivate()`. So instead + // of only allowing visitation during `deactivate()`, I tracked whether this `Element` + // was in a deactivated state, and allowed visitation when in a deactivated state. + // + // I then found that there's a similar issue during `activate()`. This also needs to + // recursively activate the subtree `Element`s, sometime after the call to `activate()`. + // Therefore, whether activated or deactivated, we need to allow visitation, but we're + // always either activated or deactivated, so this approach needed to be further adjusted. + // + // Presently, when `activate()` or `deactivate()` runs, a flag is set for each one. + // When either of those flags are `true`, we allow visitation. We reset those flags + // during the building of `_content`, as a way to recognize when the activation or + // deactivation process must be finished. + // + // For reference, when hot restarting or hot reloading if we don't enable visitation + // during activation, we get the following error: + // + // The following assertion was thrown during performLayout(): + // 'package:flutter/src/widgets/framework.dart': Failed assertion: line 4323 pos 7: '_lifecycleState == + // _ElementLifecycle.active && + // newWidget != widget && + // Widget.canUpdate(widget, newWidget)': is not true. + + // FIXME: locked is supposed to be private. We're using it as a proxy + // indication for when the build owner wants to build. Find an + // appropriate way to distinguish this. + // ignore: invalid_use_of_protected_member + if (!WidgetsBinding.instance.locked || !_didActivateSinceLastBuild || !_didDeactivateSinceLastBuild) { + if (_content != null) { + visitor(_content!); + } + } + } +} + +/// `RenderObject` for a [MessagePageScaffold] widget. +/// +/// Must be associated with an `Element` of type [MessagePageElement]. +class RenderMessagePageScaffold extends RenderBox { + RenderMessagePageScaffold( + this._element, + MessagePageController? controller, { + required double bottomSheetMinimumTopGap, + required double bottomSheetMinimumHeight, + required double bottomSheetCollapsedMaximumHeight, + }) : _bottomSheetMinimumTopGap = bottomSheetMinimumTopGap, + _bottomSheetMinimumHeight = bottomSheetMinimumHeight, + _bottomSheetCollapsedMaximumHeight = bottomSheetCollapsedMaximumHeight { + _controller = controller ?? MessagePageController(); + _attachToController(); + } + + @override + void dispose() { + _element = null; + super.dispose(); + } + + late Ticker _ticker; + late VelocityTracker _velocityTracker; + late Stopwatch _velocityStopwatch; + late double _expandedHeight; + late double _previewHeight; + late double _intrinsicHeight; + + SpringSimulation? _simulation; + MessagePageSheetMode? _simulationGoalMode; + double? _simulationGoalHeight; + + MessagePageElement? _element; + + BottomSheetMode? _overrideSheetMode; + BottomSheetMode get bottomSheetMode { + if (_overrideSheetMode != null) { + return _overrideSheetMode!; + } + + if (_simulation != null) { + return BottomSheetMode.settling; + } + + if (_controller.isDragging) { + return BottomSheetMode.dragging; + } + + if (_controller.isExpanded) { + return BottomSheetMode.expanded; + } + + if (_controller.isPreview) { + return BottomSheetMode.preview; + } + + return BottomSheetMode.intrinsic; + } + + // ignore: avoid_setters_without_getters + set controller(MessagePageController controller) { + if (controller == _controller) { + return; + } + + _detachFromController(); + _controller = controller; + _attachToController(); + } + + late MessagePageController _controller; + MessagePageDragMode _currentDragMode = MessagePageDragMode.idle; + double? _currentDesiredGlobalTopY; + double? _desiredDragHeight; + bool _isExpandingOrCollapsing = false; + double _animatedHeight = 300; + double _animatedVelocity = 0; + + void _attachToController() { + _currentDragMode = _controller.dragMode; + _controller.addListener(_onControllerChange); + + markNeedsLayout(); + } + + void _onControllerChange() { + // We might change the controller in this listener call, so we stop + // listening to the controller during this function. + _controller.removeListener(_onControllerChange); + var didChange = false; + + if (_currentDragMode != _controller.dragMode) { + switch (_controller.dragMode) { + case MessagePageDragMode.dragging: + // The user just started dragging. + _onDragStart(); + case MessagePageDragMode.idle: + // The user just stopped dragging. + _onDragEnd(); + } + + _currentDragMode = _controller.dragMode; + didChange = true; + } + + if (_controller.dragMode == MessagePageDragMode.dragging && + _currentDesiredGlobalTopY != _controller.desiredGlobalTopY) { + // TODO: don't invalidate layout if we've reached max height and the Y value went higher + _currentDesiredGlobalTopY = _controller.desiredGlobalTopY; + + final pageGlobalBottom = localToGlobal(Offset(0, size.height)).dy; + _desiredDragHeight = pageGlobalBottom - + max(_currentDesiredGlobalTopY!, _bottomSheetMinimumTopGap); + _expandedHeight = size.height - _bottomSheetMinimumTopGap; + + _velocityTracker.addPosition( + _velocityStopwatch.elapsed, + Offset(0, _currentDesiredGlobalTopY!), + ); + + didChange = true; + } + + if (didChange) { + markNeedsLayout(); + } + + // Restore our listener relationship with our controller now that + // our reaction is finished. + _controller.addListener(_onControllerChange); + } + + void _onDragStart() { + _velocityTracker = VelocityTracker.withKind(PointerDeviceKind.touch); + _velocityStopwatch = Stopwatch()..start(); + } + + void _onDragEnd() { + if (SuperKeyboard.instance.mobileGeometry.value.keyboardState == KeyboardState.closing) { + // To avoid a stuttering collapse animation, when dragging ends and the keyboard + // is closing, we immediately jump to a collapsed preview mode. If we animated + // like normal, then on every frame as the keyboard gets shorter, we have to + // restart the animation simulation, which results in a stuttering, buggy animation. + _velocityStopwatch.stop(); + + _isExpandingOrCollapsing = false; + _desiredDragHeight = null; + _controller.desiredSheetMode = MessagePageSheetMode.collapsed; + _controller.collapsedMode = MessagePageSheetCollapsedMode.preview; + return; + } + + _velocityStopwatch.stop(); + + final velocity = + _velocityTracker.getVelocityEstimate()?.pixelsPerSecond.dy ?? 0; + + _startBottomSheetHeightSimulation(velocity: velocity); + } + + void _startBottomSheetHeightSimulation({ + required double velocity, + MessagePageSheetMode? desiredSheetMode, + }) { + _ticker.stop(); + + final minimizedHeight = switch (_controller.collapsedMode) { + MessagePageSheetCollapsedMode.preview => _previewHeight, + MessagePageSheetCollapsedMode.intrinsic => min(_intrinsicHeight, _bottomSheetCollapsedMaximumHeight), + }; + + _controller.desiredSheetMode = desiredSheetMode ?? + (velocity.abs() > 500 // + ? velocity < 0 + ? MessagePageSheetMode.expanded + : MessagePageSheetMode.collapsed + : (_expandedHeight - _desiredDragHeight!).abs() < (_desiredDragHeight! - minimizedHeight).abs() + ? MessagePageSheetMode.expanded + : MessagePageSheetMode.collapsed); + + _updateBottomSheetHeightSimulation(velocity: velocity); + } + + /// Replaces a running bottom sheet height simulation with a newly computed + /// simulation based on the current render object metrics. + /// + /// This method can be called even if no `_simulation` currently exists. + /// However, callers must ensure that `_controller.desiredSheetMode` is + /// already set to the desired value. This method doesn't try to alter the + /// desired sheet mode. + void _updateBottomSheetHeightSimulation({ + required double velocity, + }) { + final minimizedHeight = switch (_controller.collapsedMode) { + MessagePageSheetCollapsedMode.preview => _previewHeight, + MessagePageSheetCollapsedMode.intrinsic => min(_intrinsicHeight, _bottomSheetCollapsedMaximumHeight), + }; + + _controller.isSliding = true; + + final startHeight = _bottomSheet!.size.height; + _simulationGoalMode = _controller.desiredSheetMode; + final newSimulationGoalHeight = + _simulationGoalMode! == MessagePageSheetMode.expanded ? _expandedHeight : minimizedHeight; + if ((newSimulationGoalHeight - startHeight).abs() < 1) { + // We're already at the destination. Fizzle. + _animatedHeight = newSimulationGoalHeight; + _animatedVelocity = 0; + _isExpandingOrCollapsing = false; + _desiredDragHeight = null; + _ticker.stop(); + return; + } + if (newSimulationGoalHeight == _simulationGoalHeight) { + // We're already simulating to this height. We short-circuit when the goal + // hasn't changed so that we don't get rapidly oscillating simulation artifacts. + return; + } + _simulationGoalHeight = newSimulationGoalHeight; + _isExpandingOrCollapsing = true; + + _ticker.stop(); + + messagePageLayoutLog.info('Creating expand/collapse simulation:'); + messagePageLayoutLog.info( + ' - Desired sheet mode: ${_controller.desiredSheetMode}', + ); + messagePageLayoutLog.info(' - Minimized height: $minimizedHeight'); + messagePageLayoutLog.info(' - Expanded height: $_expandedHeight'); + messagePageLayoutLog.info( + ' - Drag height on release: $_desiredDragHeight', + ); + messagePageLayoutLog.info(' - Final height: $_simulationGoalHeight'); + messagePageLayoutLog.info(' - Initial velocity: $velocity'); + + _simulation = SpringSimulation( + const SpringDescription( + mass: 1, + stiffness: 500, + damping: 45, + ), + startHeight, // Start value + _simulationGoalHeight!, // End value + // Invert velocity because we measured velocity moving down the screen, but we + // want to apply velocity to the height of the sheet. A positive screen velocity + // corresponds to a negative sheet height velocity. + -velocity, // Initial velocity. + ); + + _ticker.start(); + } + + void _detachFromController() { + _controller.removeListener(_onControllerChange); + + _currentDragMode = MessagePageDragMode.idle; + _desiredDragHeight = null; + _currentDesiredGlobalTopY = null; + } + + RenderBox? _content; + + RenderBox? _bottomSheet; + + /// The smallest allowable gap between the top of the editor and the top of + /// the screen. + /// + /// If the user drags higher than this point, the editor will remain at a + /// height that preserves this gap. + // ignore: avoid_setters_without_getters + set bottomSheetMinimumTopGap(double newValue) { + if (newValue == _bottomSheetMinimumTopGap) { + return; + } + + _bottomSheetMinimumTopGap = newValue; + + // FIXME: Only invalidate layout if this change impacts the current rendering. + markNeedsLayout(); + } + + double _bottomSheetMinimumTopGap; + + // ignore: avoid_setters_without_getters + set bottomSheetMinimumHeight(double newValue) { + if (newValue == _bottomSheetMinimumHeight) { + return; + } + + _bottomSheetMinimumHeight = newValue; + + // FIXME: Only invalidate layout if this change impacts the current rendering. + markNeedsLayout(); + } + + double _bottomSheetMinimumHeight; + + set bottomSheetMaximumHeight(double newValue) { + if (newValue == _bottomSheetMaximumHeight) { + return; + } + + _bottomSheetMaximumHeight = newValue; + + // FIXME: Only invalidate layout if this change impacts the current rendering. + markNeedsLayout(); + } + + double _bottomSheetMaximumHeight = double.infinity; + + set bottomSheetCollapsedMaximumHeight(double newValue) { + if (newValue == _bottomSheetCollapsedMaximumHeight) { + return; + } + + _bottomSheetCollapsedMaximumHeight = newValue; + + // FIXME: Only invalidate layout if this change impacts the current rendering. + markNeedsLayout(); + } + + double _bottomSheetCollapsedMaximumHeight = double.infinity; + + /// Whether this render object's layout information or its content + /// layout information is dirty. + /// + /// This is set to `true` when `markNeedsLayout` is called and it's + /// set to `false` after laying out the content. + bool get bottomSheetNeedsLayout => _bottomSheetNeedsLayout; + bool _bottomSheetNeedsLayout = true; + + /// Whether we are at the middle of a [performLayout] call. + bool _runningLayout = false; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + + _ticker = Ticker(_onExpandCollapseTick); + + visitChildren((child) { + child.attach(owner); + }); + } + + void _onExpandCollapseTick(Duration elapsedTime) { + final seconds = elapsedTime.inMilliseconds / 1000; + _animatedHeight = _simulation!.x(seconds).clamp(_bottomSheetMinimumHeight, _bottomSheetMaximumHeight); + _animatedVelocity = _simulation!.dx(seconds); + + if (_simulation!.isDone(seconds)) { + _ticker.stop(); + + _simulation = null; + _simulationGoalMode = null; + _simulationGoalHeight = null; + _animatedVelocity = 0; + + _isExpandingOrCollapsing = false; + _currentDesiredGlobalTopY = null; + _desiredDragHeight = null; + + _controller.isSliding = false; + } + + markNeedsLayout(); + } + + @override + void detach() { + // IMPORTANT: we must detach ourselves before detaching our children. + // This is a Flutter framework requirement. + super.detach(); + + _ticker.dispose(); + + // Detach our children. + visitChildren((child) { + child.detach(); + }); + } + + @override + void markNeedsLayout() { + super.markNeedsLayout(); + + if (_runningLayout) { + // We are already in a layout phase. When we call + // ChatScaffoldElement.buildLayers, markNeedsLayout is called again. We + // don't want to mark the message editor as dirty, because otherwise the + // content will never build. + return; + } + _bottomSheetNeedsLayout = true; + } + + @override + List debugDescribeChildren() { + final childDiagnostics = []; + + if (_content != null) { + childDiagnostics.add(_content!.toDiagnosticsNode(name: 'content')); + } + if (_bottomSheet != null) { + childDiagnostics + .add(_bottomSheet!.toDiagnosticsNode(name: 'message_editor')); + } + + return childDiagnostics; + } + + void insertChild(RenderObject child, Object slot) { + assert( + _isChatScaffoldSlot(slot), + 'Render object protocol violation - tried to insert child for invalid slot ($slot)', + ); + + if (slot == _contentSlot) { + _content = child as RenderBox; + } else if (slot == _bottomSheetSlot) { + _bottomSheet = child as RenderBox; + } + + adoptChild(child); + } + + void removeChild(RenderObject child, Object slot) { + assert( + _isChatScaffoldSlot(slot), + 'Render object protocol violation - tried to remove a child for an invalid slot ($slot)', + ); + + if (slot == _contentSlot) { + _content = null; + } else if (slot == _bottomSheetSlot) { + _bottomSheet = null; + } + + dropChild(child); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + if (_content != null) { + visitor(_content!); + } + if (_bottomSheet != null) { + visitor(_bottomSheet!); + } + } + + @override + void performLayout() { + messagePageLayoutLog.info('---------- LAYOUT -------------'); + messagePageLayoutLog.info('Laying out RenderChatScaffold'); + messagePageLayoutLog.info( + 'Sheet mode: ${_controller.desiredSheetMode}, collapsed mode: ${_controller.collapsedMode}'); + if (_content == null) { + size = Size.zero; + _bottomSheetNeedsLayout = false; + return; + } + + _runningLayout = true; + + size = constraints.biggest; + _bottomSheetMaximumHeight = size.height - _bottomSheetMinimumTopGap; + + messagePageLayoutLog.info( + "Measuring the bottom sheet's preview height", + ); + // Do a throw-away layout pass to get the preview height of the bottom + // sheet, bounded within its min/max height. + _overrideSheetMode = BottomSheetMode.preview; + _previewHeight = _bottomSheet!.computeDryLayout(constraints.copyWith(minHeight: 0)).height; + + // Switch back to a real layout pass. + _overrideSheetMode = null; + messagePageLayoutLog.info( + ' - Bottom sheet bounded preview height: $_previewHeight, min height: $_bottomSheetMinimumHeight, max height: $_bottomSheetMaximumHeight', + ); + + messagePageLayoutLog.info( + "Measuring the bottom sheet's intrinsic height", + ); + // Do a throw-away layout pass to get the intrinsic height of the bottom sheet. + _intrinsicHeight = _calculateBoundedIntrinsicHeight( + constraints.copyWith(minHeight: 0), + ); + messagePageLayoutLog.info( + ' - Bottom sheet bounded intrinsic height: $_intrinsicHeight, min height: $_bottomSheetMinimumHeight, max height: $_bottomSheetMaximumHeight', + ); + + final isDragging = !_isExpandingOrCollapsing && _desiredDragHeight != null; + + final minimizedHeight = switch (_controller.collapsedMode) { + MessagePageSheetCollapsedMode.preview => _previewHeight, + MessagePageSheetCollapsedMode.intrinsic => _intrinsicHeight, + }; + + // Max height depends on whether we're collapsed or expanded. + final bottomSheetConstraints = constraints.copyWith( + minHeight: minimizedHeight, + maxHeight: _bottomSheetMaximumHeight, + ); + + if (_isExpandingOrCollapsing) { + messagePageLayoutLog.info('>>>>>>>> Expanding or collapsing animation'); + // We may have started animating with the keyboard up and since then it + // has closed, or vis-a-versa. Check for any changes in our destination + // height. If it's changed, recreate the simulation to stop at the new + // destination. + final currentDestinationHeight = switch (_simulationGoalMode!) { + MessagePageSheetMode.collapsed => switch (_controller.collapsedMode) { + MessagePageSheetCollapsedMode.preview => _previewHeight, + MessagePageSheetCollapsedMode.intrinsic => _intrinsicHeight, + }, + MessagePageSheetMode.expanded => _bottomSheetMaximumHeight, + }; + if (currentDestinationHeight != _simulationGoalHeight) { + // A simulation is running. It's destination height no longer matches + // the destination height that we want. Update the simulation with newly + // computed metrics. + _updateBottomSheetHeightSimulation(velocity: _animatedVelocity); + } + + final minimumHeight = min( + _controller.collapsedMode == MessagePageSheetCollapsedMode.preview ? _previewHeight : _intrinsicHeight, + _bottomSheetCollapsedMaximumHeight); + final animatedHeight = _animatedHeight.clamp(minimumHeight, _bottomSheetMaximumHeight); + + _bottomSheet!.layout( + bottomSheetConstraints.copyWith( + minHeight: max(animatedHeight - 1, 0), + // ^ prevent a layout boundary + maxHeight: animatedHeight, + ), + parentUsesSize: true, + ); + } else if (isDragging) { + messagePageLayoutLog.info('>>>>>>>> User dragging'); + messagePageLayoutLog.info( + ' - drag height: $_desiredDragHeight, minimized height: $minimizedHeight', + ); + + final minimumHeight = min(minimizedHeight, _bottomSheetCollapsedMaximumHeight); + + final strictHeight = _desiredDragHeight!.clamp(minimumHeight, _bottomSheetMaximumHeight); + + messagePageLayoutLog.info(' - bounded drag height: $strictHeight'); + _bottomSheet!.layout( + bottomSheetConstraints.copyWith( + minHeight: strictHeight - 1, + maxHeight: strictHeight, + ), + parentUsesSize: true, + ); + } else if (_controller.desiredSheetMode == MessagePageSheetMode.expanded) { + messagePageLayoutLog.info('>>>>>>>> Stationary expanded'); + messagePageLayoutLog.info( + 'Running layout and forcing editor height to the max: $_expandedHeight', + ); + + _bottomSheet!.layout( + bottomSheetConstraints.copyWith( + minHeight: _expandedHeight - 1, + // ^ Prevent a layout boundary. + maxHeight: _expandedHeight, + ), + parentUsesSize: true, + ); + } else { + messagePageLayoutLog.info('>>>>>>>> Minimized'); + messagePageLayoutLog.info( + 'Running standard editor layout with constraints: $bottomSheetConstraints'); + _bottomSheet!.layout( + bottomSheetConstraints.copyWith( + minHeight: 0, + maxHeight: min(_bottomSheetCollapsedMaximumHeight, _bottomSheetMaximumHeight), + ), + parentUsesSize: true, + ); + } + + (_bottomSheet!.parentData! as BoxParentData).offset = + Offset(0, size.height - _bottomSheet!.size.height); + _bottomSheetNeedsLayout = false; + messagePageLayoutLog + .info('Bottom sheet height: ${_bottomSheet!.size.height}'); + + // Now that we know the size of the message editor, build the content based + // on the bottom spacing needed to push above the editor. + final bottomSpacing = _bottomSheet!.size.height; + messagePageLayoutLog.info(''); + messagePageLayoutLog.info('Building chat scaffold content'); + invokeLayoutCallback((constraints) { + _element!.buildContent(bottomSpacing); + }); + messagePageLayoutLog.info('Laying out chat scaffold content'); + _content!.layout(constraints, parentUsesSize: true); + messagePageLayoutLog.info('Content layout size: ${_content!.size}'); + + _runningLayout = false; + messagePageLayoutLog.info('Done laying out RenderChatScaffold'); + messagePageLayoutLog.info('---------- END LAYOUT ---------'); + } + + double _calculateBoundedIntrinsicHeight(BoxConstraints constraints) { + messagePageLayoutLog.info( + 'Running dry layout on bottom sheet content to find the intrinsic height...'); + messagePageLayoutLog.info(' - Bottom sheet constraints: $constraints'); + messagePageLayoutLog + .info(' - Controller desired sheet mode: ${_controller.collapsedMode}'); + _overrideSheetMode = BottomSheetMode.intrinsic; + messagePageLayoutLog.info(' - Override sheet mode: $_overrideSheetMode'); + + final bottomSheetHeight = _bottomSheet! + .computeDryLayout( + constraints.copyWith(minHeight: 0, maxHeight: double.infinity), + ) + .height; + + _overrideSheetMode = null; + messagePageLayoutLog + .info(" - Child's self-chosen height is: $bottomSheetHeight"); + messagePageLayoutLog.info( + " - Clamping child's height within [$_bottomSheetMinimumHeight, $_bottomSheetMaximumHeight]", + ); + + final boundedIntrinsicHeight = bottomSheetHeight.clamp( + _bottomSheetMinimumHeight, + _bottomSheetMaximumHeight, + ); + messagePageLayoutLog.info( + ' - Bottom sheet intrinsic bounded height: $boundedIntrinsicHeight', + ); + return boundedIntrinsicHeight; + } + + @override + bool hitTestChildren( + BoxHitTestResult result, { + required Offset position, + }) { + // First, hit-test the message editor, which sits on top of the + // content. + if (_bottomSheet != null) { + final childParentData = _bottomSheet!.parentData! as BoxParentData; + + final didHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + return _bottomSheet!.hitTest(result, position: transformed); + }, + ); + + if (didHit) { + return true; + } + } + + // Second, hit-test the content, which sits beneath the message + // editor. + if (_content != null) { + final didHit = _content!.hitTest(result, position: position); + if (didHit) { + // NOTE: I'm not sure if we're supposed to report ourselves when a child + // is hit, or if just the child does that. + result.add(BoxHitTestEntry(this, position)); + return true; + } + } + + return false; + } + + @override + void paint(PaintingContext context, Offset offset) { + messagePagePaintLog.info('---------- PAINT ------------'); + if (_content != null) { + messagePagePaintLog.info('Painting content'); + context.paintChild(_content!, offset); + } + + if (_bottomSheet != null) { + messagePagePaintLog.info( + 'Painting message editor - y-offset: ${size.height - _bottomSheet!.size.height}', + ); + context.paintChild( + _bottomSheet!, + offset + (_bottomSheet!.parentData! as BoxParentData).offset, + ); + } + messagePagePaintLog.info('---------- END PAINT ------------'); + } + + @override + void setupParentData(covariant RenderObject child) { + child.parentData = BoxParentData(); + } +} + +bool _isChatScaffoldSlot(Object slot) => + slot == _contentSlot || slot == _bottomSheetSlot; + +const _contentSlot = 'content'; +const _bottomSheetSlot = 'bottom_sheet'; + +/// Widget that switches its child constraints between a [previewHeight], +/// intrinsic height, and filled height. +/// +/// This widget is intended to be used around a `SuperEditor`, within the bottom +/// sheet in a [MessagePageScaffold] to size the `SuperEditor` correctly based +/// on whether the editor is in preview mode, collapsed, being dragged, +/// is animating, or is expanded. +class BottomSheetEditorHeight extends SingleChildRenderObjectWidget { + const BottomSheetEditorHeight({ + required this.previewHeight, + super.key, + super.child, + }); + + /// The exact height to be used for the editor when in preview mode. + /// + /// Overflowing content is clipped. + final double previewHeight; + + @override + RenderMessageEditorHeight createRenderObject(BuildContext context) { + return RenderMessageEditorHeight( + previewHeight: previewHeight, + ); + } + + @override + void updateRenderObject( + BuildContext context, + RenderMessageEditorHeight renderObject, + ) { + renderObject.previewHeight = previewHeight; + } +} + +class RenderMessageEditorHeight extends RenderBox + with RenderObjectWithChildMixin, RenderProxyBoxMixin { + RenderMessageEditorHeight({ + required double previewHeight, + }) : _previewHeight = previewHeight; + + double _previewHeight; + // ignore: avoid_setters_without_getters + set previewHeight(double newValue) { + if (newValue == _previewHeight) { + return; + } + + _previewHeight = newValue; + markNeedsLayout(); + } + + @override + void markNeedsLayout() { + super.markNeedsLayout(); + + // Force our ancestor scaffold to invalidate layout, too. + // + // There was an issue when integrating this within a client app. + // For example, a previous bug: + // 1. Open the editor + // 2. Fill it with enough content to push to max height + // 3. Drag down to close the keyboard + // Bug: The sheet stays expanded. + // + // It was found that while this RenderMessageEditorHeight was running + // layout correctly in this situation, the MessagePageScaffold wasn't + // running layout, which caused the sheet to stay at its previous height. + // + // This problem was not found in the MessagePageScaffold demo app. Not sure + // what the difference was. + // + // If we find a missing layout invalidation for MessagePageScaffold, and we + // make this call superfluous, then remove this. + final ancestorMessagePageScaffold = _findAncestorMessagePageScaffold(); + // Ancestor scaffold might be null during various lifecycle events, e.g., + // `dropChild()` calls `markNeedsLayout()`, but when we're dropping our + // children, we have likely already been dropped by our parent, too. + if (ancestorMessagePageScaffold != null) { + ancestorMessagePageScaffold.markNeedsLayout(); + } + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + messageEditorHeightLog.info('MessageEditorHeight - computeDryLayout()'); + messageEditorHeightLog.info(' - Constraints: $constraints'); + + final ancestorChatScaffold = _findAncestorMessagePageScaffold(); + messageEditorHeightLog + .info(' - Ancestor chat scaffold: $ancestorChatScaffold'); + + final heightMode = ancestorChatScaffold?.bottomSheetMode; + if (heightMode == null) { + messageEditorHeightLog.info( + " - Couldn't find an ancestor chat scaffold. Deferring to natural layout.", + ); + return _doIntrinsicLayout(constraints, doDryLayout: true); + } + + messageEditorHeightLog.info( + ' - Bottom sheet mode from chat scaffold: $heightMode', + ); + + switch (heightMode) { + case BottomSheetMode.preview: + // Preview mode imposes a specific height on the bottom sheet. + messageEditorHeightLog + .info(' - Desired bottom sheet preview height: $_previewHeight'); + + // We want to be a specific height. Get as close as we can. + final constrainedHeight = constraints.constrainDimensions( + double.infinity, + _previewHeight, + ); + + messageEditorHeightLog.info( + ' - Constrained bottom sheet preview height: $constrainedHeight'); + return constrainedHeight; + case BottomSheetMode.dragging: + case BottomSheetMode.settling: + case BottomSheetMode.expanded: + case BottomSheetMode.intrinsic: + // In regular layout, dragging, settling, and expanded would impose + // their own height on us. However, the purpose of dry layout is to + // report our natural size. Therefore, in all of these cases, we run + // intrinsic size layout. + return _doIntrinsicLayout(constraints, doDryLayout: true); + } + } + + @override + void performLayout() { + messageEditorHeightLog.info('MessageEditorHeight - performLayout()'); + messageEditorHeightLog.info(' - Constraints: $constraints'); + + final ancestorChatScaffold = _findAncestorMessagePageScaffold(); + messageEditorHeightLog + .info(' - Ancestor chat scaffold: $ancestorChatScaffold'); + + final heightMode = ancestorChatScaffold?.bottomSheetMode; + if (heightMode == null) { + messageEditorHeightLog.info( + " - Couldn't find an ancestor chat scaffold. Deferring to natural layout.", + ); + size = _doIntrinsicLayout(constraints, doDryLayout: true); + messageEditorHeightLog.info(' - Our reported size: $size'); + return; + } + + messageEditorHeightLog.info( + ' - Bottom sheet mode from chat scaffold: $heightMode', + ); + + switch (heightMode) { + case BottomSheetMode.preview: + // Preview mode imposes a specific height on the bottom sheet. + messageEditorHeightLog + .info(' - Forcing bottom sheet to preview height: $_previewHeight'); + + // We want to be a specific height. Get as close as we can. + size = constraints.constrainDimensions( + double.infinity, + _previewHeight, + ); + messageEditorHeightLog.info( + ' - Constraints constrained to preview height: $_previewHeight', + ); + child?.layout( + constraints.copyWith( + minHeight: size.height - 1, + maxHeight: size.height, + ), + parentUsesSize: true, + ); + + messageEditorHeightLog.info( + ' - Child preview height: ${child?.size.height}', + ); + return; + case BottomSheetMode.dragging: + case BottomSheetMode.settling: + case BottomSheetMode.expanded: + // Whether dragging, animating, or fully expanded, these conditions + // want to stipulate exactly how tall the bottom sheet should be. + messageEditorHeightLog + .info(' - Mode $heightMode - Filling available height'); + if (!constraints.hasBoundedHeight) { + messageEditorHeightLog + .info(' - No bounded height was provided. Deferring to child'); + size = _doIntrinsicLayout(constraints); + messageEditorHeightLog.info(' - Our reported size: $size'); + return; + } + + messageEditorHeightLog.info( + ' - Using our given bounded height: ${constraints.maxHeight}', + ); + // The available height is bounded. Fill it. + size = constraints.biggest; + child?.layout( + constraints.copyWith( + minHeight: size.height - 1, + // ^ Prevent a layout boundary. + maxHeight: size.height, + ), + parentUsesSize: true, + ); + messageEditorHeightLog.info( + ' - Child filled height: ${child?.size.height}', + ); + return; + case BottomSheetMode.intrinsic: + size = _doIntrinsicLayout(constraints); + messageEditorHeightLog.info(' - Our reported size: $size'); + return; + } + } + + Size _doIntrinsicLayout( + BoxConstraints constraints, { + bool doDryLayout = false, + }) { + messageEditorHeightLog + .info(' - Measuring child intrinsic height. Constraints: $constraints'); + + final child = this.child; + if (child == null) { + return constraints.constrain(Size(constraints.constrainWidth(), 0)); + } + + var childConstraints = constraints.copyWith( + minWidth: constraints.maxWidth, + minHeight: 0, + maxHeight: constraints.maxHeight, + ); + + late final Size intrinsicSize; + if (doDryLayout) { + intrinsicSize = child.computeDryLayout(childConstraints); + } else { + child.layout(childConstraints, parentUsesSize: true); + intrinsicSize = child.size; + } + + messageEditorHeightLog + .info(' - Child intrinsic height: ${intrinsicSize.height}'); + return constraints.constrain(intrinsicSize); + } + + RenderMessagePageScaffold? _findAncestorMessagePageScaffold() { + var ancestor = parent; + while (ancestor != null && ancestor is! RenderMessagePageScaffold) { + ancestor = ancestor.parent; + } + + return ancestor as RenderMessagePageScaffold?; + } +} diff --git a/super_editor/lib/src/chat/plugins/chat_preview_mode_plugin.dart b/super_editor/lib/src/chat/plugins/chat_preview_mode_plugin.dart new file mode 100644 index 0000000000..5ac9dd5823 --- /dev/null +++ b/super_editor/lib/src/chat/plugins/chat_preview_mode_plugin.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart' show TextOverflow; +import 'package:flutter/widgets.dart' show FocusNode; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/layout_single_column.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/text.dart'; + +/// A [SuperEditorPlugin] that adds the concept of a "preview mode", intended for chat use-cases, +/// where a user might open a chat screen with a draft message, and only the beginning of the +/// message should be displayed. +class ChatPreviewModePlugin extends SuperEditorPlugin { + final _previewStylePhase = ChatPreviewStylePhase(); + + /// Returns `true` if this plugin is currently restricting the editor visuals + /// to "preview mode", or `false` if this plugin is doing nothing. + bool get isInPreviewMode => _previewStylePhase.isInPreviewMode; + + set _isInPreviewMode(bool newValue) => _previewStylePhase.isInPreviewMode = newValue; + + bool _isModeLocked = false; + + /// Sets this plugin to "preview mode", regardless of the current focus state, and + /// keeps it there until [unlockDisplayMode] is called. + void lockInPreviewMode() { + _isModeLocked = true; + _isInPreviewMode = true; + } + + /// Sets this plugin to "normal mode" (not preview), regardless of the current focus + /// state, and keeps it there until [unlockDisplayMode] is called. + void lockInNormalMode() { + _isModeLocked = true; + _isInPreviewMode = false; + } + + /// Undoes any previous call to [lockInPreviewMode] or [lockInNormalMode], and synchronizes + /// "preview mode" with the editor's focus state. + void unlockDisplayMode() { + _isModeLocked = false; + _syncPreviewModeWithFocus(); + } + + bool _hasFocus = false; + + @override + void onFocusChange(FocusNode editorFocusNode) { + _hasFocus = editorFocusNode.hasFocus; + + if (!_isModeLocked) { + _syncPreviewModeWithFocus(); + } + } + + /// Sets the plugin to "preview mode" if the editor isn't focused, or "normal mode" if + /// it is focused. + void _syncPreviewModeWithFocus() { + _isInPreviewMode = !_hasFocus; + } + + @override + List get appendedStylePhases => [ + _previewStylePhase, + ]; +} + +/// A [SingleColumnLayoutStylePhase], which restricts the output of the document +/// view model to just a "preview mode". +/// +/// The "preview mode" version removes all component view models after the first +/// view model, and if the first view model is a text model, it's re-configured to +/// restrict to the given [maxLines], and use the given [overflow] indicator. +class ChatPreviewStylePhase extends SingleColumnLayoutStylePhase { + ChatPreviewStylePhase({ + bool isInPreviewMode = false, + this.maxLines = 1, + this.overflow = TextOverflow.ellipsis, + }) : _isInPreviewMode = isInPreviewMode; + + /// The max number of lines of text to display within the first text component, + /// when [isInPreviewMode]. + final int maxLines; + + /// The [TextOverflow] indicator to use, when truncating text in the first text + /// component, due to [maxLines]. + final TextOverflow overflow; + + bool get isInPreviewMode => _isInPreviewMode; + late bool _isInPreviewMode; + set isInPreviewMode(bool newValue) { + if (newValue == _isInPreviewMode) { + return; + } + + _isInPreviewMode = newValue; + markDirty(); + } + + @override + SingleColumnLayoutViewModel style(Document document, SingleColumnLayoutViewModel viewModel) { + if (!_isInPreviewMode) { + // We're not in preview mode. Don't mess with the view model. + return viewModel; + } + + if (viewModel.componentViewModels.isEmpty) { + return viewModel; + } + + var firstViewModel = viewModel.componentViewModels.first; + if (firstViewModel is TextComponentViewModel) { + firstViewModel = (firstViewModel.copy() as TextComponentViewModel) + ..maxLines = maxLines + ..overflow = overflow; + } + + // In preview mode, only show the first node/component. + return SingleColumnLayoutViewModel( + componentViewModels: [ + firstViewModel, + ], + padding: viewModel.padding, + ); + } +} diff --git a/super_editor/lib/src/chat/super_message.dart b/super_editor/lib/src/chat/super_message.dart new file mode 100644 index 0000000000..49e3ee38ca --- /dev/null +++ b/super_editor/lib/src/chat/super_message.dart @@ -0,0 +1,723 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/src/chat/super_message_android_overlays.dart'; +import 'package:super_editor/src/chat/super_message_android_touch_interactor.dart'; +import 'package:super_editor/src/chat/super_message_ios_overlays.dart'; +import 'package:super_editor/src/chat/super_message_ios_touch_interactor.dart'; +import 'package:super_editor/src/chat/super_message_keyboard_interactor.dart'; +import 'package:super_editor/src/chat/super_message_mouse_interactor.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_debug_paint.dart'; +import 'package:super_editor/src/core/document_interaction.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/core/styles.dart'; +import 'package:super_editor/src/default_editor/default_document_editor.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_layout.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_styler_per_component.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_styler_shylesheet.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_styler_user_selection.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart' + show defaultComponentBuilders, defaultInlineTextStyler; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/content_layers_for_boxes.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/flutter/empty_box.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/super_reader/read_only_document_keyboard_interactor.dart'; +import 'package:super_editor/src/super_reader/super_reader.dart'; + +/// A chat message widget. +/// +/// This widget displays an entire rich-text document, laid out as a column. +/// This widget can be used to display simple, short, plain-text chat messages, +/// as well as multi-paragraph, rich-text messages with interspersed images, +/// list items, etc. +/// +/// This message pulls its content from the given [editor]'s [Document]. An +/// [editor] is required whether this widget is used to display a read-only messages, +/// or an editable message. This is because, especially in the case of AI, a +/// message that is read-only for the user may be editable by some other actor. +class SuperMessage extends StatefulWidget { + SuperMessage({ + super.key, + this.focusNode, + this.tapRegionGroupId, + required this.editor, + SuperMessageStyles? styles, + this.customStylePhases = const [], + this.documentUnderlayBuilders = const [], + this.documentOverlayBuilders = defaultSuperMessageDocumentOverlayBuilders, + this.selectionLayerLinks, + this.contentTapDelegateFactories = const [superMessageLaunchLinkTapHandlerFactory], + this.gestureMode, + this.overlayController, + List? keyboardActions, + this.createOverlayControlsClipper, + this.componentBuilders = defaultComponentBuilders, + this.debugPaint = const DebugPaintConfig(), + }) : styles = styles ?? SuperMessageStyles.lightAndDark(), + keyboardActions = keyboardActions ?? superMessageDefaultKeyboardActions; + + final FocusNode? focusNode; + + /// {@template super_message_tap_region_group_id} + /// A group ID for a tap region that surrounds the message and also surrounds any + /// related widgets, such as drag handles and a toolbar. + /// + /// When the message is inside a [TapRegion], tapping at a drag handle causes + /// [TapRegion.onTapOutside] to be called. To prevent that, provide a + /// [tapRegionGroupId] with the same value as the ancestor [TapRegion] groupId. + /// {@endtemplate} + final String? tapRegionGroupId; + + final Editor editor; + + final SuperMessageStyles styles; + + /// Custom style phases that are added to the standard style phases. + /// + /// Documents are styled in a series of phases. A number of such + /// phases are applied, automatically, e.g., text styles, per-component + /// styles, and content selection styles. + /// + /// [customStylePhases] are added after the standard style phases. You can + /// use custom style phases to apply styles that aren't supported with + /// [stylesheet]s. + /// + /// You can also use them to apply styles to your custom [DocumentNode] + /// types that aren't supported by [SuperMessage]. For example, [SuperMessage] + /// doesn't include support for tables within documents, but you could + /// implement a `TableNode` for that purpose. You may then want to make your + /// table styleable. To accomplish this, you add a custom style phase that + /// knows how to interpret and apply table styles for your visual table component. + final List customStylePhases; + + /// Layers that are displayed beneath the document layout, aligned + /// with the location and size of the document layout. + final List documentUnderlayBuilders; + + /// Layers that are displayed on top of the document layout, aligned + /// with the location and size of the document layout. + final List documentOverlayBuilders; + + /// Leader links that connect leader widgets near the user's selection + /// to carets, handles, and other things that want to follow the selection. + /// + /// These links are always created and used within [SuperEditor]. By providing + /// an explicit [selectionLayerLinks], external widgets can also follow the + /// user's selection. + final SelectionLayerLinks? selectionLayerLinks; + + /// List of factories that create a [ContentTapDelegate], which is given an + /// opportunity to respond to taps on content before the editor, itself. + /// + /// A [ContentTapDelegate] might be used, for example, to launch a URL + /// when a user taps on a link. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List contentTapDelegateFactories; + + /// The gesture mode, e.g., mouse or touch. + final DocumentGestureMode? gestureMode; + + /// Shows, hides, and positions a floating toolbar and magnifier. + final MagnifierAndToolbarController? overlayController; + + /// All actions that this editor takes in response to key + /// events, e.g., text entry, newlines, character deletion, + /// copy, paste, etc. + /// + /// These actions are only used when in [TextInputSource.keyboard] + /// mode. + final List keyboardActions; + + /// Creates a clipper that applies to overlay controls, like drag + /// handles, magnifiers, and popover toolbars, preventing the overlay + /// controls from appearing outside the given clipping region. + /// + /// If no clipper factory method is provided, then the overlay controls + /// will be allowed to appear anywhere in the overlay in which they sit + /// (probably the entire screen). + final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; + + final List componentBuilders; + + final DebugPaintConfig debugPaint; + + @override + State createState() => _SuperMessageState(); +} + +class _SuperMessageState extends State { + late FocusNode _focusNode; + + late DocumentContext _messageContext; + + final _documentLayoutKey = GlobalKey(debugLabel: 'SuperMessage-DocumentLayout'); + + Brightness? _mostRecentPresenterBrightness; + SingleColumnLayoutPresenter? _presenter; + late SingleColumnStylesheetStyler _docStylesheetStyler; + final _customUnderlineStyler = CustomUnderlineStyler(); + late SingleColumnLayoutCustomComponentStyler _docLayoutPerComponentBlockStyler; + late SingleColumnLayoutSelectionStyler _docLayoutSelectionStyler; + + List _contentTapHandlers = []; + + // Leader links that connect leader widgets near the user's selection + // to carets, handles, and other things that want to follow the selection. + late SelectionLayerLinks _selectionLinks; + + final _iOSControlsController = SuperMessageIosControlsController(); + final _androidControlsController = SuperMessageAndroidControlsController(); + + @override + void initState() { + super.initState(); + + _focusNode = widget.focusNode ?? FocusNode(debugLabel: 'SuperMessage'); + _focusNode.addListener(_onFocusChange); + + _selectionLinks = widget.selectionLayerLinks ?? SelectionLayerLinks(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final brightness = MediaQuery.platformBrightnessOf(context); + if (brightness != _mostRecentPresenterBrightness) { + _mostRecentPresenterBrightness = brightness; + _initializePresenter(); + } + } + + @override + void didUpdateWidget(covariant SuperMessage oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.focusNode != oldWidget.focusNode) { + _focusNode.removeListener(_onFocusChange); + if (oldWidget.focusNode == null) { + _focusNode.dispose(); + } + + _focusNode = widget.focusNode ?? FocusNode(debugLabel: 'SuperMessage'); + _focusNode.addListener(_onFocusChange); + } + + if (widget.editor != oldWidget.editor || + widget.styles != oldWidget.styles || + !const DeepCollectionEquality().equals(widget.customStylePhases, oldWidget.customStylePhases) || + !const DeepCollectionEquality().equals(widget.componentBuilders, oldWidget.componentBuilders)) { + _initializePresenter(); + } + + if (widget.selectionLayerLinks != oldWidget.selectionLayerLinks) { + _selectionLinks = widget.selectionLayerLinks ?? SelectionLayerLinks(); + } + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + if (widget.focusNode == null) { + _focusNode.dispose(); + } + + for (final handler in _contentTapHandlers) { + handler.dispose(); + } + + _iOSControlsController.dispose(); + + super.dispose(); + } + + void _initializePresenter() { + if (_presenter != null) { + _presenter!.dispose(); + } + + _docStylesheetStyler = SingleColumnStylesheetStyler( + stylesheet: _mostRecentPresenterBrightness == Brightness.dark + ? widget.styles.darkStylesheet + : widget.styles.lightStylesheet, + ); + + _docLayoutPerComponentBlockStyler = SingleColumnLayoutCustomComponentStyler(); + + _docLayoutSelectionStyler = SingleColumnLayoutSelectionStyler( + document: widget.editor.document, + selection: widget.editor.composer.selectionNotifier, + selectionStyles: _mostRecentPresenterBrightness == Brightness.dark + ? widget.styles.darkSelectionStyles + : widget.styles.lightSelectionStyles, + selectedTextColorStrategy: _mostRecentPresenterBrightness == Brightness.dark + ? widget.styles.darkStylesheet.selectedTextColorStrategy + : widget.styles.lightStylesheet.selectedTextColorStrategy, + ); + + _presenter = SingleColumnLayoutPresenter( + document: widget.editor.document, + componentBuilders: widget.componentBuilders, + pipeline: [ + _docStylesheetStyler, + _docLayoutPerComponentBlockStyler, + _customUnderlineStyler, + ...widget.customStylePhases, + // Selection changes are very volatile. Put that phase last + // to minimize view model recalculations. + _docLayoutSelectionStyler, + ], + ); + + _messageContext = DocumentContext( + editor: widget.editor, + getDocumentLayout: () => _documentLayoutKey.currentState as DocumentLayout, + ); + + // Dispose previous tap handlers and create new handlers for our new context. + for (final handler in _contentTapHandlers) { + handler.dispose(); + } + + _contentTapHandlers = widget.contentTapDelegateFactories.map((factory) => factory.call(_messageContext)).toList(); + } + + void _onFocusChange() { + if (!_focusNode.hasFocus && widget.editor.composer.selection != null) { + // This message doesn't have focus. Clear the selection. + widget.editor.execute([ + const ClearSelectionRequest(), + ]); + } + } + + DocumentGestureMode get _gestureMode { + if (widget.gestureMode != null) { + return widget.gestureMode!; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return DocumentGestureMode.android; + case TargetPlatform.iOS: + return DocumentGestureMode.iOS; + default: + return DocumentGestureMode.mouse; + } + } + + @override + Widget build(BuildContext context) { + return SuperMessageKeyboardInteractor( + // Note: This widget adds a `Focus` widget, internally. + focusNode: _focusNode, + messageContext: _messageContext, + keyboardActions: widget.keyboardActions, + child: Builder(builder: (context) { + return _buildGestureInteractor( + context, + child: IntrinsicWidth( + child: BoxContentLayers( + content: (onBuildScheduled) => SingleColumnDocumentLayout( + key: _documentLayoutKey, + presenter: _presenter!, + componentBuilders: widget.componentBuilders, + onBuildScheduled: onBuildScheduled, + wrapWithSliverAdapter: false, + showDebugPaint: widget.debugPaint.layout, + ), + underlays: [ + // Add any underlays that were provided by the client. + for (final underlayBuilder in widget.documentUnderlayBuilders) // + (context) => underlayBuilder.build(context, _messageContext), + ], + overlays: [ + // Layer that positions and sizes leader widgets at the bounds + // of the users selection so that carets, handles, toolbars, and + // other things can follow the selection. + (context) => _SelectionLeadersDocumentLayerBuilder( + links: _selectionLinks, + ).build(context, _messageContext), + // Add any overlays that were provided by the client. + for (final overlayBuilder in widget.documentOverlayBuilders) // + (context) => overlayBuilder.build(context, _messageContext), + ], + ), + ), + ); + }), + ); + } + + Widget _buildGestureInteractor(BuildContext context, {required Widget child}) { + switch (_gestureMode) { + case DocumentGestureMode.mouse: + return SuperMessageMouseInteractor( + focusNode: _focusNode, + messageContext: _messageContext, + contentTapHandlers: _contentTapHandlers, + showDebugPaint: widget.debugPaint.gestures, + child: child, + ); + case DocumentGestureMode.android: + return SuperMessageAndroidControlsScope( + controller: _androidControlsController, + child: Builder( + // ^ Builder to provide widgets below with access to controller. + builder: (context) { + return SuperMessageAndroidTouchInteractor( + focusNode: _focusNode, + editor: widget.editor, + getDocumentLayout: () => _messageContext.documentLayout, + contentTapHandlers: _contentTapHandlers, + showDebugPaint: widget.debugPaint.gestures, + child: SuperMessageAndroidControlsOverlayManager( + editor: widget.editor, + getDocumentLayout: () => _messageContext.documentLayout, + defaultToolbarBuilder: (overlayContext, mobileToolbarKey, focalPoint) => + DefaultAndroidSuperMessageToolbar( + floatingToolbarKey: mobileToolbarKey, + editor: widget.editor, + messageControlsController: SuperMessageAndroidControlsScope.rootOf(context), + focalPoint: focalPoint, + ), + child: child, + ), + ); + }, + ), + ); + case DocumentGestureMode.iOS: + return SuperMessageIosControlsScope( + controller: _iOSControlsController, + child: Builder( + // ^ Builder to provide widgets below with access to controller. + builder: (context) { + return SuperMessageIosTouchInteractor( + focusNode: _focusNode, + messageContext: _messageContext, + documentKey: _documentLayoutKey, + getDocumentLayout: () => _messageContext.documentLayout, + contentTapHandlers: _contentTapHandlers, + showDebugPaint: widget.debugPaint.gestures, + child: SuperMessageIosToolbarOverlayManager( + tapRegionGroupId: widget.tapRegionGroupId, + defaultToolbarBuilder: (overlayContext, mobileToolbarKey, focalPoint) => DefaultIOSSuperMessageToolbar( + floatingToolbarKey: mobileToolbarKey, + editor: widget.editor, + messageControlsController: SuperMessageIosControlsScope.rootOf(context), + focalPoint: focalPoint, + ), + child: SuperMessageIosMagnifierOverlayManager( + child: child, + ), + ), + ); + }), + ); + } + } +} + +/// Creates an [Editor], which is nominally configured for a typical AI message, +/// such as a message generated by ChatGPT or Gemini. +/// +/// This [Editor] still supports document editing, despite being intended for +/// read-only AI messages. This is because AI might generate message bit-by-bit, +/// and AI might also want to change previous messages. Therefore, document +/// editing must still be supported. +Editor createDefaultAiMessageEditor({ + MutableDocument? document, + MutableDocumentComposer? composer, +}) { + return Editor( + editables: { + Editor.documentKey: document ?? MutableDocument.empty(), + Editor.composerKey: composer ?? MutableDocumentComposer(), + }, + requestHandlers: [ + (editor, request) => request is ReplaceDocumentRequest ? ReplaceDocumentCommand(request.nodes) : null, + ...defaultRequestHandlers, + ], + reactionPipeline: List.from(defaultEditorReactions), + isHistoryEnabled: false, + ); +} + +final defaultLightChatStylesheet = Stylesheet( + rules: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.symmetric(horizontal: 12), + Styles.textStyle: const TextStyle( + color: Colors.black, + fontSize: 18, + height: 1.1, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 38, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 26, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 22, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("paragraph"), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(top: 6, bottom: 6), + }; + }, + ), + StyleRule( + const BlockSelector("blockquote"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.grey, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + ], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, +); + +const defaultLightChatSelectionStyles = SelectionStyles( + selectionColor: Color(0xFFACCEF7), +); + +final defaultDarkChatStylesheet = defaultLightChatStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.white, + ), + }; + }, + ), + StyleRule( + const BlockSelector("blockquote"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.grey, + ), + }; + }, + ), + ], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, +); + +const defaultDarkChatSelectionStyles = SelectionStyles( + selectionColor: Color(0xFFACCEF7), +); + +/// Default list of document overlays that are displayed on top of the document +/// layout in a [SuperMessage]. +const defaultSuperMessageDocumentOverlayBuilders = [ + // Adds a Leader around the document selection at a focal point for the iOS floating toolbar. + SuperMessageIosToolbarFocalPointDocumentLayerBuilder(), + // Displays drag handles, specifically for iOS. + SuperMessageIosHandlesDocumentLayerBuilder(), + + // Adds a Leader around the document selection at a focal point for the Android floating toolbar. + SuperMessageAndroidToolbarFocalPointDocumentLayerBuilder(), + // Displays drag handles, specifically for Android. + SuperMessageAndroidHandlesDocumentLayerBuilder(), +]; + +/// Styles that apply to a given [SuperMessage], including a document stylesheet, +/// and selection styles, for both light and dark modes. +class SuperMessageStyles { + SuperMessageStyles({ + required Stylesheet stylesheet, + required SelectionStyles selectionStyles, + }) : lightStylesheet = stylesheet, + lightSelectionStyles = selectionStyles, + darkStylesheet = stylesheet, + darkSelectionStyles = selectionStyles; + + SuperMessageStyles.lightAndDark({ + Stylesheet? lightStylesheet, + SelectionStyles? lightSelectionStyles, + Stylesheet? darkStylesheet, + SelectionStyles? darkSelectionStyles, + }) : lightStylesheet = lightStylesheet ?? defaultLightChatStylesheet, + lightSelectionStyles = lightSelectionStyles ?? defaultLightChatSelectionStyles, + darkStylesheet = darkStylesheet ?? defaultDarkChatStylesheet, + darkSelectionStyles = darkSelectionStyles ?? defaultDarkChatSelectionStyles; + + late final Stylesheet lightStylesheet; + late final SelectionStyles lightSelectionStyles; + + late final Stylesheet darkStylesheet; + late final SelectionStyles darkSelectionStyles; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperMessageStyles && + runtimeType == other.runtimeType && + lightStylesheet == other.lightStylesheet && + lightSelectionStyles == other.lightSelectionStyles && + darkStylesheet == other.darkStylesheet && + darkSelectionStyles == other.darkSelectionStyles; + + @override + int get hashCode => + lightStylesheet.hashCode ^ lightSelectionStyles.hashCode ^ darkStylesheet.hashCode ^ darkSelectionStyles.hashCode; +} + +/// A [SuperMessageDocumentLayerBuilder] that builds a [SelectionLeadersDocumentLayer], which positions +/// leader widgets at the base and extent of the user's selection, so that other widgets +/// can position themselves relative to the user's selection. +class _SelectionLeadersDocumentLayerBuilder implements SuperMessageDocumentLayerBuilder { + const _SelectionLeadersDocumentLayerBuilder({ + required this.links, + // ignore: unused_element_parameter + this.showDebugLeaderBounds = false, + }); + + /// Collections of [LayerLink]s, which are given to leader widgets that are + /// positioned at the selection bounds, and around the full selection. + final SelectionLayerLinks links; + + /// Whether to paint colorful bounds around the leader widgets, for debugging purposes. + final bool showDebugLeaderBounds; + + @override + ContentLayerWidget build(BuildContext context, DocumentContext messageContext) { + return SelectionLeadersDocumentLayer( + document: messageContext.editor.document, + selection: messageContext.editor.composer.selectionNotifier, + links: links, + showDebugLeaderBounds: showDebugLeaderBounds, + ); + } +} + +/// Builds widgets that are displayed at the same position and size as +/// the document layout within a [SuperMessage]. +abstract class SuperMessageDocumentLayerBuilder { + ContentLayerWidget build(BuildContext context, DocumentContext messageContext); +} + +/// A [SuperMessageDocumentLayerBuilder] that builds a [IosToolbarFocalPointDocumentLayer], which +/// positions a `Leader` widget around the document selection, as a focal point for an +/// iOS floating toolbar. +class SuperMessageIosToolbarFocalPointDocumentLayerBuilder implements SuperMessageDocumentLayerBuilder { + const SuperMessageIosToolbarFocalPointDocumentLayerBuilder({ + this.showDebugLeaderBounds = false, + }); + + /// Whether to paint colorful bounds around the leader widget. + final bool showDebugLeaderBounds; + + @override + ContentLayerWidget build(BuildContext context, DocumentContext messageContext) { + if (defaultTargetPlatform != TargetPlatform.iOS || SuperMessageIosControlsScope.maybeNearestOf(context) == null) { + // There's no controls scope. This probably means SuperEditor is configured with + // a non-iOS gesture mode. Build nothing. + return const ContentLayerProxyWidget(child: EmptyBox()); + } + + return IosToolbarFocalPointDocumentLayer( + document: messageContext.editor.document, + selection: messageContext.editor.composer.selectionNotifier, + toolbarFocalPointLink: SuperMessageIosControlsScope.rootOf(context).toolbarFocalPoint, + showDebugLeaderBounds: showDebugLeaderBounds, + ); + } +} + +typedef SuperMessageContentTapDelegateFactory = ContentTapDelegate Function(DocumentContext messageContext); + +ContentTapDelegate superMessageLaunchLinkTapHandlerFactory(DocumentContext messageContext) => + SuperReaderLaunchLinkTapHandler(messageContext.document); + +/// Keyboard actions for the standard [SuperReader]. +final superMessageDefaultKeyboardActions = [ + removeCollapsedSelectionWhenShiftIsReleased, + expandSelectionWithLeftArrow, + expandSelectionWithRightArrow, + expandSelectionWithUpArrow, + expandSelectionWithDownArrow, + expandSelectionToLineStartWithHomeOnWindowsAndLinux, + expandSelectionToLineEndWithEndOnWindowsAndLinux, + expandSelectionToLineStartWithCtrlAOnWindowsAndLinux, + expandSelectionToLineEndWithCtrlEOnWindowsAndLinux, + selectAllWhenCmdAIsPressedOnMac, + selectAllWhenCtlAIsPressedOnWindowsAndLinux, + copyWhenCmdCIsPressedOnMac, + copyWhenCtlCIsPressedOnWindowsAndLinux, +]; + +/// Executes this action, if the action wants to run, and returns +/// a desired [ExecutionInstruction] to either continue or halt +/// execution of actions. +/// +/// It is possible that an action makes changes and then returns +/// [ExecutionInstruction.continueExecution] to continue execution. +/// +/// It is possible that an action does nothing and then returns +/// [ExecutionInstruction.haltExecution] to prevent further execution. +typedef SuperMessageKeyboardAction = ExecutionInstruction Function({ + required DocumentContext documentContext, + required KeyEvent keyEvent, +}); diff --git a/super_editor/lib/src/chat/super_message_android_overlays.dart b/super_editor/lib/src/chat/super_message_android_overlays.dart new file mode 100644 index 0000000000..5cb773074a --- /dev/null +++ b/super_editor/lib/src/chat/super_message_android_overlays.dart @@ -0,0 +1,956 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' show Colors, Theme; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; +import 'package:super_editor/src/chat/super_message.dart'; +import 'package:super_editor/src/chat/super_message_android_touch_interactor.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/document_gestures_touch_android.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart'; +import 'package:super_editor/src/infrastructure/flutter/empty_box.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/android_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/drag_handle_selection.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/magnifier.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/toolbar.dart'; +import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; +import 'package:super_editor/src/infrastructure/touch_controls.dart'; + +/// Adds and removes an Android-style editor controls overlay, as dictated by an ancestor +/// [SuperMessageAndroidControlsScope]. +class SuperMessageAndroidControlsOverlayManager extends StatefulWidget { + const SuperMessageAndroidControlsOverlayManager({ + super.key, + this.tapRegionGroupId, + required this.editor, + required this.getDocumentLayout, + this.defaultToolbarBuilder, + this.showDebugPaint = false, + this.child, + }); + + /// {@macro super_editor_tap_region_group_id} + final String? tapRegionGroupId; + + final Editor editor; + final DocumentLayoutResolver getDocumentLayout; + + final DocumentFloatingToolbarBuilder? defaultToolbarBuilder; + + /// Paints some extra visual ornamentation to help with + /// debugging, when `true`. + final bool showDebugPaint; + + final Widget? child; + + @override + State createState() => SuperMessageAndroidControlsOverlayManagerState(); +} + +@visibleForTesting +class SuperMessageAndroidControlsOverlayManagerState extends State { + final _boundsKey = GlobalKey(); + final _overlayController = OverlayPortalController(); + + SuperMessageAndroidControlsController? _controlsController; + late FollowerAligner _toolbarAligner; + + // The type of handle that the user started dragging, e.g., upstream or downstream. + // + // The drag handle type varies independently from the drag selection bound. + HandleType? _dragHandleType; + AndroidTextFieldDragHandleSelectionStrategy? _dragHandleSelectionStrategy; + + final _dragHandleSelectionGlobalFocalPoint = ValueNotifier(null); + final _magnifierFocalPoint = ValueNotifier(null); + + late final DocumentHandleGestureDelegate _upstreamHandleGesturesDelegate; + late final DocumentHandleGestureDelegate _downstreamHandleGesturesDelegate; + + @override + void initState() { + super.initState(); + + widget.editor.composer.selectionNotifier.addListener(_onSelectionChange); + + _upstreamHandleGesturesDelegate = DocumentHandleGestureDelegate( + onPanStart: (details) => _onHandlePanStart(details, HandleType.upstream), + onPanUpdate: _onHandlePanUpdate, + onPanEnd: (details) => _onHandlePanEnd(details, HandleType.upstream), + onPanCancel: () => _onHandlePanCancel(HandleType.upstream), + ); + + _downstreamHandleGesturesDelegate = DocumentHandleGestureDelegate( + onTap: () { + // Register tap down to win gesture arena ASAP. + }, + onPanStart: (details) => _onHandlePanStart(details, HandleType.downstream), + onPanUpdate: _onHandlePanUpdate, + onPanEnd: (details) => _onHandlePanEnd(details, HandleType.downstream), + onPanCancel: () => _onHandlePanCancel(HandleType.downstream), + ); + + onNextFrame((_) { + // Call `show()` at the end of the frame because calling during a build + // process blows up. + _overlayController.show(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsController = SuperMessageAndroidControlsScope.rootOf(context); + // TODO: Replace CupertinoPopoverToolbarAligner aligner with a generic aligner because this code runs on Android. + _toolbarAligner = CupertinoPopoverToolbarAligner( + toolbarVerticalOffsetAbove: 20, + toolbarVerticalOffsetBelow: 90, + ); + } + + @override + void didUpdateWidget(SuperMessageAndroidControlsOverlayManager oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.editor.composer.selectionNotifier != oldWidget.editor.composer.selectionNotifier) { + oldWidget.editor.composer.selectionNotifier.removeListener(_onSelectionChange); + widget.editor.composer.selectionNotifier.addListener(_onSelectionChange); + } + } + + @override + void dispose() { + widget.editor.composer.selectionNotifier.removeListener(_onSelectionChange); + super.dispose(); + } + + @visibleForTesting + bool get wantsToDisplayToolbar => _controlsController!.shouldShowToolbar.value; + + @visibleForTesting + bool get wantsToDisplayMagnifier => _controlsController!.shouldShowMagnifier.value; + + void _onSelectionChange() { + final selection = widget.editor.composer.selection; + if (selection == null) { + return; + } + + if (selection.isCollapsed && + _controlsController!.shouldShowExpandedHandles.value == true && + _dragHandleType == null) { + // The selection is collapsed, but the expanded handles are visible and the user isn't dragging a handle. + // This can happen when the selection is expanded, and the user deletes the selected text. The only situation + // where the expanded handles should be visible when the selection is collapsed is when the selection + // collapses while the user is dragging an expanded handle, which isn't the case here. Hide the handles. + _controlsController! + ..hideExpandedHandles() + ..hideMagnifier() + ..hideToolbar(); + } + } + + void _updateDragHandleSelection(DocumentSelection newSelection, SelectionChangeType changeType) { + if (newSelection != widget.editor.composer.selection) { + widget.editor.execute([ + ChangeSelectionRequest(newSelection, changeType, SelectionReason.userInteraction), + ]); + HapticFeedback.lightImpact(); + } + } + + void _onHandlePanStart(DragStartDetails details, HandleType handleType) { + final selection = widget.editor.composer.selection; + if (selection == null) { + throw Exception("Tried to drag a collapsed Android handle when there's no selection."); + } + + final isSelectionDownstream = selection.hasDownstreamAffinity(widget.editor.document); + _dragHandleType = handleType; + late final DocumentPosition selectionBoundPosition; + if (isSelectionDownstream) { + selectionBoundPosition = handleType == HandleType.upstream ? selection.base : selection.extent; + } else { + selectionBoundPosition = handleType == HandleType.upstream ? selection.extent : selection.base; + } + + // Find the global offset for the center of the caret as the selection focal point. + final documentLayout = widget.getDocumentLayout(); + // FIXME: this logic makes sense for selecting characters, but what about images? Does it make sense to set the focal point at the center of the image? + final centerOfContentAtOffset = documentLayout.getAncestorOffsetFromDocumentOffset( + documentLayout.getRectForPosition(selectionBoundPosition)!.center, + ); + _dragHandleSelectionGlobalFocalPoint.value = centerOfContentAtOffset; + _magnifierFocalPoint.value = centerOfContentAtOffset; + + final selectionType = switch (handleType) { + HandleType.collapsed => SelectionChangeType.pushCaret, + HandleType.upstream => SelectionChangeType.expandSelection, + HandleType.downstream => SelectionChangeType.expandSelection, + }; + + _dragHandleSelectionStrategy = AndroidTextFieldDragHandleSelectionStrategy( + document: widget.editor.document, + documentLayout: widget.getDocumentLayout(), + select: (newSelection) => _updateDragHandleSelection(newSelection, selectionType), + )..onHandlePanStart(details, selection, handleType); + + // Update the controls for handle dragging. + _controlsController! + ..showMagnifier() + ..hideToolbar(); + } + + void _onHandlePanUpdate(DragUpdateDetails details) { + if (_dragHandleSelectionGlobalFocalPoint.value == null) { + throw Exception( + "Tried to pan an Android drag handle but the focal point is null. The focal point is set when the drag begins. This shouldn't be possible."); + } + + // Move the selection focal point by the given delta. + _dragHandleSelectionGlobalFocalPoint.value = _dragHandleSelectionGlobalFocalPoint.value! + details.delta; + + _dragHandleSelectionStrategy!.onHandlePanUpdate(details); + + // Update the magnifier based on the latest drag handle offset. + _moveMagnifierToDragHandleOffset(dragDx: details.delta.dx); + } + + void _onHandlePanEnd(DragEndDetails details, HandleType handleType) { + _dragHandleSelectionStrategy = null; + _onHandleDragEnd(handleType); + } + + void _onHandlePanCancel(HandleType handleType) { + _dragHandleSelectionStrategy = null; + _onHandleDragEnd(handleType); + } + + void _onHandleDragEnd(HandleType handleType) { + _dragHandleSelectionStrategy = null; + _dragHandleType = null; + _dragHandleSelectionGlobalFocalPoint.value = null; + _magnifierFocalPoint.value = null; + + // Start blinking the caret again, and hide the magnifier. + _controlsController!.hideMagnifier(); + + if (widget.editor.composer.selection?.isCollapsed == true && + const [HandleType.upstream, HandleType.downstream].contains(handleType)) { + // The user dragged an expanded handle until the selection collapsed and then released the handle. + // While the user was dragging, the expanded handles were displayed. + // Show the collapsed. + _controlsController!.hideExpandedHandles(); + } + + if (widget.editor.composer.selection?.isCollapsed == false) { + // The selection is expanded, show the toolbar. + _controlsController!.showToolbar(); + } + } + + void _moveMagnifierToDragHandleOffset({ + double dragDx = 0, + }) { + // Move the selection to the document position that's nearest the focal point. + final documentLayout = widget.getDocumentLayout(); + final nearestPosition = documentLayout.getDocumentPositionNearestToOffset( + documentLayout.getDocumentOffsetFromAncestorOffset(_dragHandleSelectionGlobalFocalPoint.value!), + )!; + + final centerOfContentInContentSpace = documentLayout.getRectForPosition(nearestPosition)!.center; + + // Move the magnifier focal point to match the drag x-offset, but always remain focused on the vertical + // center of the line. + final centerOfContentAtNearestPosition = + documentLayout.getAncestorOffsetFromDocumentOffset(centerOfContentInContentSpace); + _magnifierFocalPoint.value = Offset( + _magnifierFocalPoint.value!.dx + dragDx, + centerOfContentAtNearestPosition.dy, + ); + } + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _overlayController, + overlayChildBuilder: _buildOverlay, + child: widget.child, + ); + } + + Widget _buildOverlay(BuildContext context) { + return TapRegion( + groupId: widget.tapRegionGroupId, + child: Stack( + key: _boundsKey, + clipBehavior: Clip.none, + children: [ + _buildMagnifierFocalPoint(), + if (widget.showDebugPaint) // + _buildDebugSelectionFocalPoint(), + _buildMagnifier(), + // Handles and toolbar are built after the magnifier so that they don't appear in the magnifier. + ..._buildExpandedHandles(), + _buildToolbar(), + ], + ), + ); + } + + List _buildExpandedHandles() { + if (_controlsController!.expandedHandlesBuilder != null) { + return [ + ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowExpandedHandles, + builder: (context, shouldShow, child) { + return _controlsController!.expandedHandlesBuilder!( + context, + upstreamHandleKey: DocumentKeys.upstreamHandle, + upstreamFocalPoint: _controlsController!.upstreamHandleFocalPoint, + upstreamGestureDelegate: _upstreamHandleGesturesDelegate, + downstreamHandleKey: DocumentKeys.downstreamHandle, + downstreamFocalPoint: _controlsController!.downstreamHandleFocalPoint, + downstreamGestureDelegate: _downstreamHandleGesturesDelegate, + shouldShow: shouldShow, + ); + }, + ) + ]; + } + + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + return [ + ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowExpandedHandles, + builder: (context, shouldShow, child) { + if (!shouldShow) { + return const SizedBox(); + } + + return Follower.withOffset( + link: _controlsController!.upstreamHandleFocalPoint, + leaderAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topRight, + showWhenUnlinked: false, + // Use the offset to account for the invisible expanded touch region around the handle. + offset: + -AndroidSelectionHandle.defaultTouchRegionExpansion.topRight * MediaQuery.devicePixelRatioOf(context), + child: RawGestureDetector( + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..shouldAccept = () { + return true; + } + ..dragStartBehavior = DragStartBehavior.down + ..onDown = (DragDownDetails details) { + // No-op: this method is only here to beat out any ancestor + // Scrollable that's also trying to drag. + } + ..onStart = _upstreamHandleGesturesDelegate.onPanStart + ..onUpdate = _upstreamHandleGesturesDelegate.onPanUpdate + ..onEnd = _upstreamHandleGesturesDelegate.onPanEnd + ..onCancel = _upstreamHandleGesturesDelegate.onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + child: AndroidSelectionHandle( + key: DocumentKeys.upstreamHandle, + handleType: HandleType.upstream, + color: _controlsController!.controlsColor ?? Theme.of(context).primaryColor, + ), + ), + ); + }, + ), + ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowExpandedHandles, + builder: (context, shouldShow, child) { + if (!shouldShow) { + return const SizedBox(); + } + + return Follower.withOffset( + link: _controlsController!.downstreamHandleFocalPoint, + leaderAnchor: Alignment.bottomRight, + followerAnchor: Alignment.topLeft, + showWhenUnlinked: false, + // Use the offset to account for the invisible expanded touch region around the handle. + offset: + -AndroidSelectionHandle.defaultTouchRegionExpansion.topLeft * MediaQuery.devicePixelRatioOf(context), + child: RawGestureDetector( + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..shouldAccept = () { + return true; + } + ..dragStartBehavior = DragStartBehavior.down + ..onDown = (DragDownDetails details) { + // No-op: this method is only here to beat out any ancestor + // Scrollable that's also trying to drag. + } + ..onStart = _downstreamHandleGesturesDelegate.onPanStart + ..onUpdate = _downstreamHandleGesturesDelegate.onPanUpdate + ..onEnd = _downstreamHandleGesturesDelegate.onPanEnd + ..onCancel = _downstreamHandleGesturesDelegate.onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + child: AndroidSelectionHandle( + key: DocumentKeys.downstreamHandle, + handleType: HandleType.downstream, + color: _controlsController!.controlsColor ?? Theme.of(context).primaryColor, + ), + ), + ); + }, + ), + ]; + } + + Widget _buildToolbar() { + return ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowToolbar, + builder: (context, shouldShow, child) { + return shouldShow ? child! : const SizedBox(); + }, + child: Follower.withAligner( + link: _controlsController!.toolbarFocalPoint, + aligner: _toolbarAligner, + boundary: const ScreenFollowerBoundary(), + showDebugPaint: false, + child: _toolbarBuilder(context, DocumentKeys.mobileToolbar, _controlsController!.toolbarFocalPoint), + ), + ); + } + + DocumentFloatingToolbarBuilder get _toolbarBuilder { + return _controlsController!.toolbarBuilder ?? // + widget.defaultToolbarBuilder ?? + (_, __, ___) => const SizedBox(); + } + + Widget _buildMagnifierFocalPoint() { + return ValueListenableBuilder( + valueListenable: _magnifierFocalPoint, + builder: (context, focalPoint, child) { + if (focalPoint == null) { + return const SizedBox(); + } + + return Positioned( + left: focalPoint.dx, + top: focalPoint.dy, + width: 1, + height: 1, + child: Leader( + link: _controlsController!.magnifierFocalPoint, + ), + ); + }, + ); + } + + Widget _buildMagnifier() { + return ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowMagnifier, + builder: (context, shouldShow, child) { + return _controlsController!.magnifierBuilder != null // + ? _controlsController!.magnifierBuilder!( + context, + DocumentKeys.magnifier, + _controlsController!.magnifierFocalPoint, + shouldShow, + ) + : _buildDefaultMagnifier( + context, + DocumentKeys.magnifier, + _controlsController!.magnifierFocalPoint, + shouldShow, + ); + }, + ); + } + + Widget _buildDefaultMagnifier(BuildContext context, Key magnifierKey, LeaderLink focalPoint, bool isVisible) { + if (!isVisible) { + return const SizedBox(); + } + + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + return Follower.withOffset( + link: _controlsController!.magnifierFocalPoint, + offset: Offset(0, -54 * devicePixelRatio), + leaderAnchor: Alignment.center, + followerAnchor: Alignment.center, + boundary: const ScreenFollowerBoundary(), + child: AndroidMagnifyingGlass( + key: magnifierKey, + magnificationScale: 1.5, + offsetFromFocalPoint: const Offset(0, -54), + ), + ); + } + + Widget _buildDebugSelectionFocalPoint() { + return ValueListenableBuilder( + valueListenable: _dragHandleSelectionGlobalFocalPoint, + builder: (context, focalPoint, child) { + if (focalPoint == null) { + return const SizedBox(); + } + + return Positioned( + left: focalPoint.dx, + top: focalPoint.dy, + child: FractionalTranslation( + translation: const Offset(-0.5, -0.5), + child: Container( + width: 5, + height: 5, + color: Colors.red, + ), + ), + ); + }, + ); + } +} + +/// A [SuperMessageDocumentLayerBuilder] that builds an [AndroidToolbarFocalPointDocumentLayer], which +/// positions a [Leader] widget around the document selection, as a focal point for an Android +/// floating toolbar. +class SuperMessageAndroidToolbarFocalPointDocumentLayerBuilder implements SuperMessageDocumentLayerBuilder { + const SuperMessageAndroidToolbarFocalPointDocumentLayerBuilder({ + this.showDebugLeaderBounds = false, + }); + + /// Whether to paint colorful bounds around the leader widget. + final bool showDebugLeaderBounds; + + @override + ContentLayerWidget build(BuildContext context, DocumentContext editorContext) { + if (defaultTargetPlatform != TargetPlatform.android || + SuperMessageAndroidControlsScope.maybeNearestOf(context) == null) { + // There's no controls scope. This probably means SuperMessage is configured with + // a non-Android gesture mode. Build nothing. + return const ContentLayerProxyWidget(child: EmptyBox()); + } + + return AndroidToolbarFocalPointDocumentLayer( + document: editorContext.document, + selection: editorContext.composer.selectionNotifier, + toolbarFocalPointLink: SuperMessageAndroidControlsScope.rootOf(context).toolbarFocalPoint, + showDebugLeaderBounds: showDebugLeaderBounds, + ); + } +} + +/// A [SuperMessageLayerBuilder], which builds an [SuperMessageAndroidHandlesDocumentLayer], +/// which displays Android-style caret and handles. +class SuperMessageAndroidHandlesDocumentLayerBuilder implements SuperMessageDocumentLayerBuilder { + const SuperMessageAndroidHandlesDocumentLayerBuilder({ + this.caretColor, + this.caretWidth = 2, + }); + + /// The (optional) color of the caret (not the drag handle), by default the color + /// defers to the root [SuperMessageAndroidControlsScope], or the app theme if the + /// controls controller has no preference for the color. + final Color? caretColor; + + final double caretWidth; + + @override + ContentLayerWidget build(BuildContext context, DocumentContext editContext) { + if (defaultTargetPlatform != TargetPlatform.android || + SuperMessageAndroidControlsScope.maybeNearestOf(context) == null) { + // There's no controls scope. This probably means SuperMessage is configured with + // a non-Android gesture mode. Build nothing. + return const ContentLayerProxyWidget(child: EmptyBox()); + } + + return SuperMessageAndroidHandlesDocumentLayer( + document: editContext.document, + documentLayout: editContext.documentLayout, + selection: editContext.composer.selectionNotifier, + changeSelection: (newSelection, changeType, reason) { + editContext.editor.execute([ + ChangeSelectionRequest(newSelection, changeType, reason), + const ClearComposingRegionRequest(), + ]); + }, + caretWidth: caretWidth, + caretColor: caretColor, + ); + } +} + +/// A document layer that displays an Android-style caret, and positions [Leader]s for the Android +/// collapsed and expanded drag handles. +/// +/// This layer positions and paints the caret directly, rather than using `Leader`s and `Follower`s, +/// because its position is based on the document layout, rather than the user's gesture behavior. +class SuperMessageAndroidHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { + const SuperMessageAndroidHandlesDocumentLayer({ + super.key, + required this.document, + required this.documentLayout, + required this.selection, + required this.changeSelection, + this.caretWidth = 2, + this.caretColor, + this.showDebugPaint = false, + }); + + final Document document; + + final DocumentLayout documentLayout; + + final ValueListenable selection; + + final void Function(DocumentSelection?, SelectionChangeType, String selectionReason) changeSelection; + + final double caretWidth; + + /// Color used to render the Android-style caret (not handles), by default the color + /// is retrieved from the root [SuperEditorAndroidControlsController]. + final Color? caretColor; + + final bool showDebugPaint; + + @override + DocumentLayoutLayerState createState() => + SuperMessageAndroidControlsDocumentLayerState(); +} + +@visibleForTesting +class SuperMessageAndroidControlsDocumentLayerState + extends DocumentLayoutLayerState + with SingleTickerProviderStateMixin { + SuperMessageAndroidControlsController? _controlsController; + + @override + void initState() { + super.initState(); + + widget.selection.addListener(_onSelectionChange); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (_controlsController != null) { + _controlsController!.areSelectionHandlesAllowed.removeListener(_onSelectionHandlesAllowedChange); + } + + _controlsController = SuperMessageAndroidControlsScope.rootOf(context); + _controlsController!.areSelectionHandlesAllowed.addListener(_onSelectionHandlesAllowedChange); + } + + @override + void didUpdateWidget(SuperMessageAndroidHandlesDocumentLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + } + } + + @override + void dispose() { + widget.selection.removeListener(_onSelectionChange); + _controlsController!.areSelectionHandlesAllowed.removeListener(_onSelectionHandlesAllowedChange); + super.dispose(); + } + + @visibleForTesting + bool get isUpstreamHandleDisplayed => layoutData?.upstream != null; + + @visibleForTesting + bool get isDownstreamHandleDisplayed => layoutData?.downstream != null; + + void _onSelectionChange() { + setState(() { + // Schedule a new layout computation because the handles need to move. + }); + } + + void _onSelectionHandlesAllowedChange() { + setState(() { + // The controller went from allowing selection handles to disallowing them, or vis-a-versa. + // Rebuild this widget to show/hide the handles. + }); + } + + @override + DocumentSelectionLayout? computeLayoutDataWithDocumentLayout( + BuildContext contentLayersContext, BuildContext documentContext, DocumentLayout documentLayout) { + final selection = widget.selection.value; + if (selection == null) { + return null; + } + + if (!_controlsController!.areSelectionHandlesAllowed.value) { + // We don't want to show any selection handles. + return null; + } + + if (selection.isCollapsed && !_controlsController!.shouldShowExpandedHandles.value) { + Rect caretRect = documentLayout.getEdgeForPosition(selection.extent)!; + + // Default caret width used by the Android caret. + const caretWidth = 2; + + // Use the content's RenderBox instead of the layer's RenderBox to get the layer's width. + // + // ContentLayers works in four steps: + // + // 1. The content is built. + // 2. The content is laid out. + // 3. The layers are built. + // 4. The layers are laid out. + // + // The computeLayoutData method is called during the layer's build, which means that the + // layer's RenderBox is outdated, because it wasn't laid out yet for the current frame. + // Use the content's RenderBox, which was already laid out for the current frame. + final contentBox = documentContext.findRenderObject(); + if (contentBox != null) { + if (contentBox is RenderSliver && contentBox.hasSize && caretRect.left + caretWidth >= contentBox.size.width) { + // Adjust the caret position to make it entirely visible because it's currently placed + // partially or entirely outside of the layers' bounds. This can happen for downstream selections + // of block components that take all the available width. + caretRect = Rect.fromLTWH( + contentBox.size.width - caretWidth, + caretRect.top, + caretRect.width, + caretRect.height, + ); + } else if (contentBox is RenderBox && + contentBox.hasSize && + caretRect.left + caretWidth >= contentBox.size.width) { + // Adjust the caret position to make it entirely visible because it's currently placed + // partially or entirely outside of the layers' bounds. This can happen for downstream selections + // of block components that take all the available width. + caretRect = Rect.fromLTWH( + contentBox.size.width - caretWidth, + caretRect.top, + caretRect.width, + caretRect.height, + ); + } + } + + return DocumentSelectionLayout( + caret: caretRect, + ); + } else { + return DocumentSelectionLayout( + upstream: documentLayout.getRectForPosition( + widget.document.selectUpstreamPosition(selection.base, selection.extent), + )!, + downstream: documentLayout.getRectForPosition( + widget.document.selectDownstreamPosition(selection.base, selection.extent), + )!, + expandedSelectionBounds: documentLayout.getRectForSelection( + selection.base, + selection.extent, + ), + ); + } + } + + @override + Widget doBuild(BuildContext context, DocumentSelectionLayout? layoutData) { + return IgnorePointer( + child: SizedBox.expand( + child: layoutData != null // + ? _buildHandles(layoutData) + : const SizedBox(), + ), + ); + } + + Widget _buildHandles(DocumentSelectionLayout layoutData) { + if (widget.selection.value == null) { + return const SizedBox.shrink(); + } + + return Stack( + children: [ + if (layoutData.upstream != null && layoutData.downstream != null) + ..._buildExpandedHandleLeaders( + upstream: layoutData.upstream!, + downstream: layoutData.downstream!, + ), + ], + ); + } + + List _buildExpandedHandleLeaders({ + required Rect upstream, + required Rect downstream, + }) { + return [ + Positioned.fromRect( + rect: upstream, + child: Leader(link: _controlsController!.upstreamHandleFocalPoint), + ), + Positioned.fromRect( + rect: downstream, + child: Leader(link: _controlsController!.downstreamHandleFocalPoint), + ), + ]; + } +} + +/// An Android floating toolbar, which includes standard buttons for [SuperMessage]s. +class DefaultAndroidSuperMessageToolbar extends StatelessWidget { + const DefaultAndroidSuperMessageToolbar({ + super.key, + this.floatingToolbarKey, + required this.editor, + required this.messageControlsController, + required this.focalPoint, + }); + + final Key? floatingToolbarKey; + final LeaderLink focalPoint; + final Editor editor; + final SuperMessageAndroidControlsController messageControlsController; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: editor.composer.selectionNotifier, + builder: (context, selection, child) { + return AndroidTextEditingFloatingToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + onCopyPressed: selection == null || !selection.isCollapsed // + ? _copy + : null, + onSelectAllPressed: _selectAll, + ); + }, + ); + } + + void _copy() { + final textToCopy = _textInSelection( + document: editor.document, + documentSelection: editor.composer.selection!, + ); + _saveToClipboard(textToCopy); + + messageControlsController.hideToolbar(); + } + + void _selectAll() { + if (editor.document.isEmpty) { + return; + } + + editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: editor.document.first.id, + nodePosition: editor.document.first.beginningPosition, + ), + extent: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: editor.document.last.endPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + } + + Future _saveToClipboard(String text) { + return Clipboard.setData(ClipboardData(text: text)); + } + + String _textInSelection({ + required Document document, + required DocumentSelection documentSelection, + }) { + final selectedNodes = document.getNodesInside( + documentSelection.base, + documentSelection.extent, + ); + + final buffer = StringBuffer(); + for (int i = 0; i < selectedNodes.length; ++i) { + final selectedNode = selectedNodes[i]; + dynamic nodeSelection; + + if (i == 0) { + // This is the first node and it may be partially selected. + final baseSelectionPosition = selectedNode.id == documentSelection.base.nodeId + ? documentSelection.base.nodePosition + : documentSelection.extent.nodePosition; + + final extentSelectionPosition = + selectedNodes.length > 1 ? selectedNode.endPosition : documentSelection.extent.nodePosition; + + nodeSelection = selectedNode.computeSelection( + base: baseSelectionPosition, + extent: extentSelectionPosition, + ); + } else if (i == selectedNodes.length - 1) { + // This is the last node and it may be partially selected. + final nodePosition = selectedNode.id == documentSelection.base.nodeId + ? documentSelection.base.nodePosition + : documentSelection.extent.nodePosition; + + nodeSelection = selectedNode.computeSelection( + base: selectedNode.beginningPosition, + extent: nodePosition, + ); + } else { + // This node is fully selected. Copy the whole thing. + nodeSelection = selectedNode.computeSelection( + base: selectedNode.beginningPosition, + extent: selectedNode.endPosition, + ); + } + + final nodeContent = selectedNode.copyContent(nodeSelection); + if (nodeContent != null) { + buffer.write(nodeContent); + if (i < selectedNodes.length - 1) { + buffer.writeln(); + } + } + } + return buffer.toString(); + } +} diff --git a/super_editor/lib/src/chat/super_message_android_touch_interactor.dart b/super_editor/lib/src/chat/super_message_android_touch_interactor.dart new file mode 100644 index 0000000000..b136bab169 --- /dev/null +++ b/super_editor/lib/src/chat/super_message_android_touch_interactor.dart @@ -0,0 +1,918 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/long_press_selection.dart'; +import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; + +/// An [InheritedWidget] that provides shared access to a [SuperMessageAndroidControlsController], +/// which coordinates the state of Android controls like the caret, handles, magnifier, etc. +/// +/// This widget and its associated controller exist so that [SuperMessage] has maximum freedom +/// in terms of where to implement Android gestures vs the magnifier vs the toolbar. +/// Each of these responsibilities have some unique differences, which make them difficult or +/// impossible to implement within a single widget. By sharing a controller, a group of independent +/// widgets can work together to cover those various responsibilities. +/// +/// Centralizing a controller in an [InheritedWidget] also allows [SuperMessage] to share that +/// control with application code outside of [SuperMessage], by placing a [SuperMessageAndroidControlsScope] +/// above the [SuperMessage] in the widget tree. For this reason, [SuperMessage] should access +/// the [SuperMessageAndroidControlsScope] through [rootOf]. +class SuperMessageAndroidControlsScope extends InheritedWidget { + /// Finds the highest [SuperMessageAndroidControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperMessageAndroidControlsController]. + static SuperMessageAndroidControlsController rootOf(BuildContext context) { + final data = maybeRootOf(context); + + if (data == null) { + throw Exception( + "Tried to depend upon the root SuperEditorAndroidControlsScope but no such ancestor widget exists."); + } + + return data; + } + + static SuperMessageAndroidControlsController? maybeRootOf(BuildContext context) { + InheritedElement? root; + + context.visitAncestorElements((element) { + if (element is! InheritedElement || element.widget is! SuperMessageAndroidControlsScope) { + // Keep visiting. + return true; + } + + root = element; + + // Keep visiting, to ensure we get the root scope. + return true; + }); + + if (root == null) { + return null; + } + + // Create build dependency on the Android controls context. + context.dependOnInheritedElement(root!); + + // Return the current Android controls data. + return (root!.widget as SuperMessageAndroidControlsScope).controller; + } + + /// Finds the nearest [SuperMessageAndroidControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperMessageAndroidControlsController]. + static SuperMessageAndroidControlsController nearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()!.controller; + + static SuperMessageAndroidControlsController? maybeNearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.controller; + + const SuperMessageAndroidControlsScope({ + super.key, + required this.controller, + required super.child, + }); + + final SuperMessageAndroidControlsController controller; + + @override + bool updateShouldNotify(SuperMessageAndroidControlsScope oldWidget) { + return controller != oldWidget.controller; + } +} + +/// A controller, which coordinates the state of various Android editor controls, including +/// the caret, handles, magnifier, and toolbar. +class SuperMessageAndroidControlsController { + SuperMessageAndroidControlsController({ + this.controlsColor, + LeaderLink? upstreamHandleFocalPoint, + LeaderLink? downstreamHandleFocalPoint, + this.expandedHandlesBuilder, + this.magnifierBuilder, + this.toolbarBuilder, + this.createOverlayControlsClipper, + }) : upstreamHandleFocalPoint = upstreamHandleFocalPoint ?? LeaderLink(), + downstreamHandleFocalPoint = downstreamHandleFocalPoint ?? LeaderLink(); + + void dispose() { + _shouldShowMagnifier.dispose(); + _shouldShowToolbar.dispose(); + } + + /// Color of the drag handles on Android. + /// + /// The default handle builders honor this color. If custom handle builders are + /// provided, its up to those handle builders to honor this color, or not. + final Color? controlsColor; + + /// The focal point for the upstream drag handle, when the selection is expanded. + /// + /// The upstream handle builder should place its handle near this focal point. + final LeaderLink upstreamHandleFocalPoint; + + /// The focal point for the downstream drag handle, when the selection is expanded. + /// + /// The downstream handle builder should place its handle near this focal point. + final LeaderLink downstreamHandleFocalPoint; + + /// Whether the expanded drag handles should be displayed right now. + ValueListenable get shouldShowExpandedHandles => _shouldShowExpandedHandles; + final _shouldShowExpandedHandles = ValueNotifier(false); + + /// Shows the expanded drag handles by setting [shouldShowExpandedHandles] to `true`. + void showExpandedHandles() { + _shouldShowExpandedHandles.value = true; + } + + /// Hides the expanded drag handles by setting [shouldShowExpandedHandles] to `false`. + void hideExpandedHandles() => _shouldShowExpandedHandles.value = false; + + /// {@template are_selection_handles_allowed} + /// Whether or not the selection handles are allowed to be displayed. + /// + /// Typically, whenever the selection changes, the drag handles are displayed. However, + /// there are some cases where we want to select some content, but don't show the + /// drag handles. For example, when the user taps a misspelled word, we might want to select + /// the misspelled word without showing any handles. + /// + /// Defaults to `true`. + /// {@endtemplate} + ValueListenable get areSelectionHandlesAllowed => _areSelectionHandlesAllowed; + final _areSelectionHandlesAllowed = ValueNotifier(true); + + /// Temporarily prevents any selection handles from being displayed. + /// + /// Call this when you want to select some content, but don't want to show the drag handles. + /// [allowSelectionHandles] must be called to allow the drag handles to be displayed again. + void preventSelectionHandles() => _areSelectionHandlesAllowed.value = false; + + /// Allows the selection handles to be displayed after they have been temporarily + /// prevented by [preventSelectionHandles]. + void allowSelectionHandles() => _areSelectionHandlesAllowed.value = true; + + /// (Optional) Builder to create the visual representation of the expanded drag handles. + /// + /// If [expandedHandlesBuilder] is `null`, default Android handles are displayed. + final DocumentExpandedHandlesBuilder? expandedHandlesBuilder; + + /// Whether the Android magnifier should be displayed right now. + ValueListenable get shouldShowMagnifier => _shouldShowMagnifier; + final _shouldShowMagnifier = ValueNotifier(false); + + /// Shows the magnifier by setting [shouldShowMagnifier] to `true`. + void showMagnifier() => _shouldShowMagnifier.value = true; + + /// Hides the magnifier by setting [shouldShowMagnifier] to `false`. + void hideMagnifier() => _shouldShowMagnifier.value = false; + + /// Toggles [shouldShowMagnifier]. + void toggleMagnifier() => _shouldShowMagnifier.value = !_shouldShowMagnifier.value; + + /// Link to a location where a magnifier should be focused. + /// + /// The magnifier builder should place the magnifier near this focal point. + final magnifierFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the magnifier. + /// + /// If [magnifierBuilder] is `null`, a default Android magnifier is displayed. + final DocumentMagnifierBuilder? magnifierBuilder; + + /// Whether the Android floating toolbar should be displayed right now. + ValueListenable get shouldShowToolbar => _shouldShowToolbar; + final _shouldShowToolbar = ValueNotifier(false); + + /// Shows the toolbar by setting [shouldShowToolbar] to `true`. + void showToolbar() => _shouldShowToolbar.value = true; + + /// Hides the toolbar by setting [shouldShowToolbar] to `false`. + void hideToolbar() => _shouldShowToolbar.value = false; + + /// Toggles [shouldShowToolbar]. + void toggleToolbar() => _shouldShowToolbar.value = !_shouldShowToolbar.value; + + /// Link to a location where a toolbar should be focused. + /// + /// This link probably points to a rectangle, such as a bounding rectangle + /// around the user's selection. Therefore, the toolbar builder shouldn't + /// assume that this focal point is a single pixel. + final toolbarFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the floating + /// toolbar. + /// + /// If [toolbarBuilder] is `null`, a default Android toolbar is displayed. + final DocumentFloatingToolbarBuilder? toolbarBuilder; + + /// Creates a clipper that restricts where the toolbar and magnifier can + /// appear in the overlay. + /// + /// If no clipper factory method is provided, then the overlay controls + /// will be allowed to appear anywhere in the overlay in which they sit + /// (probably the entire screen). + final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; +} + +/// Document gesture interactor that's designed for Android touch input, e.g., +/// drag to scroll, and handles to control selection. +class SuperMessageAndroidTouchInteractor extends StatefulWidget { + const SuperMessageAndroidTouchInteractor({ + Key? key, + required this.focusNode, + required this.editor, + required this.getDocumentLayout, + this.contentTapHandlers = const [], + this.showDebugPaint = false, + required this.child, + }) : super(key: key); + + final FocusNode focusNode; + + final Editor editor; + final DocumentLayout Function() getDocumentLayout; + + /// Optional list of handlers that respond to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List contentTapHandlers; + + final bool showDebugPaint; + + final Widget child; + + @override + State createState() => _SuperMessageAndroidTouchInteractorState(); +} + +class _SuperMessageAndroidTouchInteractorState extends State + with WidgetsBindingObserver, SingleTickerProviderStateMixin { + SuperMessageAndroidControlsController? _controlsController; + + Offset? _globalTapDownOffset; + Offset? _globalStartDragOffset; + Offset? _dragStartInDoc; + Offset? _startDragPositionOffset; + Offset? _globalDragOffset; + + final _magnifierGlobalOffset = ValueNotifier(null); + + Timer? _tapDownLongPressTimer; + bool get _isLongPressInProgress => _longPressStrategy != null; + AndroidDocumentLongPressSelectionStrategy? _longPressStrategy; + + bool _isCaretDragInProgress = false; + + // Cached view metrics to ignore unnecessary didChangeMetrics calls. + Size? _lastSize; + ViewPadding? _lastInsets; + + final _interactor = GlobalKey(); + + @override + void initState() { + super.initState(); + + widget.editor.document.addListener(_onDocumentChange); + widget.editor.composer.selectionNotifier.addListener(_onSelectionChange); + + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final view = View.of(context); + _lastSize = view.physicalSize; + _lastInsets = view.viewInsets; + + _controlsController = SuperMessageAndroidControlsScope.rootOf(context); + } + + @override + void didUpdateWidget(SuperMessageAndroidTouchInteractor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.editor.document != oldWidget.editor.document) { + oldWidget.editor.document.removeListener(_onDocumentChange); + widget.editor.document.addListener(_onDocumentChange); + } + + if (widget.editor.composer.selectionNotifier != oldWidget.editor.composer.selectionNotifier) { + oldWidget.editor.composer.selectionNotifier.removeListener(_onSelectionChange); + widget.editor.composer.selectionNotifier.addListener(_onSelectionChange); + } + } + + @override + void didChangeMetrics() { + // It is possible to get the notification even though the metrics for view are same. + final view = View.of(context); + final size = view.physicalSize; + final insets = view.viewInsets; + if (size == _lastSize && + _lastInsets?.left == insets.left && + _lastInsets?.right == insets.right && + _lastInsets?.top == insets.top && + _lastInsets?.bottom == insets.bottom) { + return; + } + _lastSize = size; + _lastInsets = insets; + + // The available screen dimensions may have changed, e.g., due to keyboard + // appearance/disappearance. Reflow the layout. Use a post-frame callback + // to give the rest of the UI a chance to reflow, first. + onNextFrame((_) { + setState(() { + // reflow document layout + }); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + + widget.editor.document.removeListener(_onDocumentChange); + widget.editor.composer.selectionNotifier.removeListener(_onSelectionChange); + + super.dispose(); + } + + /// Returns the layout for the current document, which answers questions + /// about the locations and sizes of visual components within the layout. + DocumentLayout get _docLayout => widget.getDocumentLayout(); + + Offset _getDocumentOffsetFromGlobalOffset(Offset globalOffset) { + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); + } + + /// Returns the render box for the interactor gesture detector. + RenderBox get interactorBox => _interactor.currentContext!.findRenderObject() as RenderBox; + + void _onDocumentChange(_) { + // The user might start typing when the toolbar is visible. Hide it. + _controlsController!.hideToolbar(); + } + + void _onSelectionChange() { + if (widget.editor.composer.selection == null) { + _controlsController! + ..hideExpandedHandles() + ..hideMagnifier() + ..hideToolbar(); + return; + } + } + + void _onTapDown(TapDownDetails details) { + _globalTapDownOffset = details.globalPosition; + _tapDownLongPressTimer?.cancel(); + _tapDownLongPressTimer = Timer(kLongPressTimeout, _onLongPressDown); + } + + void _onTapCancel() { + _tapDownLongPressTimer?.cancel(); + _tapDownLongPressTimer = null; + } + + // Runs when a tap down has lasted long enough to signify a long-press. + void _onLongPressDown() { + _longPressStrategy = AndroidDocumentLongPressSelectionStrategy( + document: widget.editor.document, + documentLayout: _docLayout, + select: _updateLongPressSelection, + ); + + final didLongPressSelectionStart = _longPressStrategy!.onLongPressStart( + tapDownDocumentOffset: _getDocumentOffsetFromGlobalOffset(_globalTapDownOffset!), + ); + if (!didLongPressSelectionStart) { + _longPressStrategy = null; + return; + } + + // A long-press selection is in progress. Initially show the toolbar, but nothing else. + _controlsController! + ..hideExpandedHandles() + ..hideMagnifier() + ..showToolbar(); + + widget.focusNode.requestFocus(); + } + + void _onTapUp(TapUpDetails details) { + // Stop waiting for a long-press to start. + _tapDownLongPressTimer?.cancel(); + + // Cancel any on-going long-press. + if (_isLongPressInProgress) { + _onLongPressEnd(); + _longPressStrategy = null; + _magnifierGlobalOffset.value = null; + return; + } + + editorGesturesLog.info("Tap down on document"); + final docOffset = _getDocumentOffsetFromGlobalOffset(details.globalPosition); + editorGesturesLog.fine(" - document offset: $docOffset"); + + for (final handler in widget.contentTapHandlers) { + final result = handler.onTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + editorGesturesLog.fine(" - tapped document position: $docPosition"); + + if (docPosition == null) { + _clearSelection(); + } + + final selection = widget.editor.composer.selection; + if (selection != null && docPosition != null && !selection.containsPosition(widget.editor.document, docPosition)) { + // The user tapped outside the current selection. Clear the selection. + _clearSelection(); + } + + _showAndHideEditingControlsAfterTapSelection(); + + widget.focusNode.requestFocus(); + } + + void _onDoubleTapDown(TapDownDetails details) { + editorGesturesLog.info("Double tap down on document"); + final docOffset = _getDocumentOffsetFromGlobalOffset(details.globalPosition); + editorGesturesLog.fine(" - document offset: $docOffset"); + + for (final handler in widget.contentTapHandlers) { + final result = handler.onDoubleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + editorGesturesLog.fine(" - tapped document position: $docPosition"); + + if (docPosition != null) { + final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; + if (!tappedComponent.isVisualSelectionSupported()) { + // The user tapped a non-selectable component, so we can't select a word. + // The editor will remain focused and selection will remain in the nearest + // selectable component, as set in _onTapUp. + return; + } + + bool didSelectContent = _selectWordAt( + docPosition: docPosition, + docLayout: _docLayout, + ); + + if (!didSelectContent) { + didSelectContent = _selectBlockAt(docPosition); + } + + if (!didSelectContent) { + // Place the document selection at the location where the + // user tapped. + _selectPosition(docPosition); + } + } else { + _clearSelection(); + } + + _showAndHideEditingControlsAfterTapSelection(); + + widget.focusNode.requestFocus(); + } + + bool _selectBlockAt(DocumentPosition position) { + if (position.nodePosition is! UpstreamDownstreamNodePosition) { + return false; + } + + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: position.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: position.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + + return true; + } + + void _onTripleTapDown(TapDownDetails details) { + editorGesturesLog.info("Triple tap down on document"); + final docOffset = _getDocumentOffsetFromGlobalOffset(details.globalPosition); + editorGesturesLog.fine(" - document offset: $docOffset"); + + for (final handler in widget.contentTapHandlers) { + final result = handler.onTripleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + editorGesturesLog.fine(" - tapped document position: $docPosition"); + if (docPosition != null) { + // The user tapped a non-selectable component, so we can't select a paragraph. + // The editor will remain focused and selection will remain in the nearest + // selectable component, as set in _onTapUp. + final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; + if (!tappedComponent.isVisualSelectionSupported()) { + return; + } + + final didSelectParagraph = _selectParagraphAt( + docPosition: docPosition, + docLayout: _docLayout, + ); + if (!didSelectParagraph) { + // Place the document selection at the location where the + // user tapped. + _selectPosition(docPosition); + } + } else { + _clearSelection(); + } + + _showAndHideEditingControlsAfterTapSelection(); + + widget.focusNode.requestFocus(); + } + + void _showAndHideEditingControlsAfterTapSelection() { + if (widget.editor.composer.selection == null) { + // There's no selection. Hide all controls. + _controlsController! + ..hideExpandedHandles() + ..hideMagnifier() + ..hideToolbar(); + } else if (!widget.editor.composer.selection!.isCollapsed) { + // The selection is expanded. + _controlsController! + ..showExpandedHandles() + ..showToolbar() + ..hideMagnifier(); + } + } + + void _onPanStart(DragStartDetails details) { + // Stop waiting for a long-press to start, if a long press isn't already in-progress. + _tapDownLongPressTimer?.cancel(); + + _globalStartDragOffset = details.globalPosition; + _dragStartInDoc = _getDocumentOffsetFromGlobalOffset(details.globalPosition); + + _startDragPositionOffset = _dragStartInDoc!; + + if (_isLongPressInProgress) { + _onLongPressPanStart(details); + return; + } + + final isTapOverCaret = _isOverCaret(_globalTapDownOffset!); + + if (isTapOverCaret) { + _onCaretDragPanStart(details); + return; + } + } + + bool _isOverCaret(Offset globalOffset) { + if (widget.editor.composer.selection?.isCollapsed != true) { + return false; + } + + final collapsedPosition = widget.editor.composer.selection?.extent; + if (collapsedPosition == null) { + return false; + } + + final extentRect = _docLayout.getRectForPosition(collapsedPosition)!; + final caretRect = Rect.fromLTWH(extentRect.left - 1, extentRect.center.dy, 1, 1).inflate(24); + + final tapDocumentOffset = widget.getDocumentLayout().getDocumentOffsetFromAncestorOffset(_globalTapDownOffset!); + return caretRect.contains(tapDocumentOffset); + } + + void _onLongPressPanStart(DragStartDetails details) { + _longPressStrategy!.onLongPressDragStart(details); + + // Tell the overlay where to put the magnifier. + _magnifierGlobalOffset.value = details.globalPosition; + + _controlsController! + ..hideToolbar() + ..showMagnifier(); + } + + void _onCaretDragPanStart(DragStartDetails details) { + _isCaretDragInProgress = true; + + // Tell the overlay where to put the magnifier. + _magnifierGlobalOffset.value = details.globalPosition; + + _controlsController! + ..hideToolbar() + ..showMagnifier(); + } + + void _onPanUpdate(DragUpdateDetails details) { + _globalDragOffset = details.globalPosition; + + if (_isLongPressInProgress) { + _onLongPressPanUpdate(details); + return; + } + + if (_isCaretDragInProgress) { + _onCaretDragPanUpdate(details); + return; + } + } + + void _onLongPressPanUpdate(DragUpdateDetails details) { + final fingerDragDelta = _globalDragOffset! - _globalStartDragOffset!; + final fingerDocumentOffset = _docLayout.getDocumentOffsetFromAncestorOffset(details.globalPosition); + final fingerDocumentPosition = _docLayout.getDocumentPositionNearestToOffset( + _startDragPositionOffset! + fingerDragDelta, + ); + _longPressStrategy!.onLongPressDragUpdate(fingerDocumentOffset, fingerDocumentPosition); + } + + void _onCaretDragPanUpdate(DragUpdateDetails details) { + final fingerDragDelta = _globalDragOffset! - _globalStartDragOffset!; + final fingerDocumentPosition = _docLayout.getDocumentPositionNearestToOffset( + _startDragPositionOffset! + fingerDragDelta, + )!; + if (fingerDocumentPosition != widget.editor.composer.selection!.extent) { + HapticFeedback.lightImpact(); + } + _selectPosition(fingerDocumentPosition); + } + + void _updateLongPressSelection(DocumentSelection newSelection) { + if (newSelection != widget.editor.composer.selection) { + _select(newSelection); + HapticFeedback.lightImpact(); + } + + // Note: this needs to happen even when the selection doesn't change, in case + // some controls, like a magnifier, need to follower the user's finger. + _updateOverlayControlsOnLongPressDrag(); + } + + void _updateOverlayControlsOnLongPressDrag() { + final extentDocumentOffset = _docLayout.getRectForPosition(widget.editor.composer.selection!.extent)!.center; + final extentGlobalOffset = _docLayout.getAncestorOffsetFromDocumentOffset(extentDocumentOffset); + + _magnifierGlobalOffset.value = extentGlobalOffset; + } + + void _onPanEnd(DragEndDetails details) { + if (_isLongPressInProgress) { + _onLongPressEnd(); + return; + } + + if (_isCaretDragInProgress) { + _onCaretDragEnd(); + return; + } + } + + void _onPanCancel() { + // When _tapDownLongPressTimer is not null we're waiting for either tapUp or tapCancel, + // which will deal with the long press. + if (_tapDownLongPressTimer == null && _isLongPressInProgress) { + _onLongPressEnd(); + return; + } + + if (_isCaretDragInProgress) { + _onCaretDragEnd(); + return; + } + } + + void _onLongPressEnd() { + _longPressStrategy!.onLongPressEnd(); + + // Cancel any on-going long-press. + _longPressStrategy = null; + _magnifierGlobalOffset.value = null; + + _controlsController!.hideMagnifier(); + if (!widget.editor.composer.selection!.isCollapsed) { + _controlsController! + ..showExpandedHandles() + ..showToolbar(); + } + } + + void _onCaretDragEnd() { + _isCaretDragInProgress = false; + + _magnifierGlobalOffset.value = null; + + _controlsController!.hideMagnifier(); + if (!widget.editor.composer.selection!.isCollapsed) { + _controlsController! + ..showExpandedHandles() + ..showToolbar(); + } + } + + bool _selectWordAt({ + required DocumentPosition docPosition, + required DocumentLayout docLayout, + }) { + final newSelection = getWordSelection(docPosition: docPosition, docLayout: docLayout); + if (newSelection != null) { + widget.editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + return true; + } else { + return false; + } + } + + bool _selectParagraphAt({ + required DocumentPosition docPosition, + required DocumentLayout docLayout, + }) { + final newSelection = getParagraphSelection(docPosition: docPosition, docLayout: docLayout); + if (newSelection != null) { + widget.editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + return true; + } else { + return false; + } + } + + void _selectPosition(DocumentPosition position) { + editorGesturesLog.fine("Setting document selection to $position"); + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: position, + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + } + + void _select(DocumentSelection newSelection) { + widget.editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + } + + void _clearSelection() { + editorGesturesLog.fine("Clearing document selection"); + widget.editor.execute([ + const ClearSelectionRequest(), + const ClearComposingRegionRequest(), + ]); + } + + @override + Widget build(BuildContext context) { + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + // PanGestureRecognizer is above contents to have first pass at gestures, but it only accepts + // gestures that are over caret or handles or when a long press is in progress. + // TapGestureRecognizer is below contents so that it doesn't interferes with buttons and other + // tappable widgets. + return Stack( + children: [ + // Layer below + Positioned.fill( + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapCancel = _onTapCancel + ..onTapUp = _onTapUp + ..onDoubleTapDown = _onDoubleTapDown + ..onTripleTapDown = _onTripleTapDown + ..gestureSettings = gestureSettings; + }, + ), + }, + ), + ), + widget.child, + // Layer above + Positioned.fill( + child: RawGestureDetector( + key: _interactor, + behavior: HitTestBehavior.translucent, + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..shouldAccept = () { + if (_globalTapDownOffset == null) { + return false; + } + return _isOverCaret(_globalTapDownOffset!) || _isLongPressInProgress; + } + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + ), + ), + ], + ); + } +} diff --git a/super_editor/lib/src/chat/super_message_ios_overlays.dart b/super_editor/lib/src/chat/super_message_ios_overlays.dart new file mode 100644 index 0000000000..456b9d7d85 --- /dev/null +++ b/super_editor/lib/src/chat/super_message_ios_overlays.dart @@ -0,0 +1,419 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' + show Theme, TextButton, ColorScheme, Colors, ThemeData, kMinInteractiveDimension, NoSplash, MaterialTapTargetSize; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/src/chat/super_message.dart' show SuperMessageDocumentLayerBuilder; +import 'package:super_editor/src/chat/super_message_ios_touch_interactor.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/flutter/empty_box.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/colors.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.dart'; +import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; + +/// Adds and removes an iOS-style editor toolbar, as dictated by an ancestor +/// [SuperMessageIosControlsScope]. +class SuperMessageIosToolbarOverlayManager extends StatefulWidget { + const SuperMessageIosToolbarOverlayManager({ + super.key, + this.tapRegionGroupId, + this.defaultToolbarBuilder, + this.child, + }); + + /// {@macro super_reader_tap_region_group_id} + final String? tapRegionGroupId; + + final DocumentFloatingToolbarBuilder? defaultToolbarBuilder; + + final Widget? child; + + @override + State createState() => SuperMessageIosToolbarOverlayManagerState(); +} + +@visibleForTesting +class SuperMessageIosToolbarOverlayManagerState extends State { + final OverlayPortalController _overlayPortalController = OverlayPortalController(); + SuperMessageIosControlsController? _controlsController; + + @visibleForTesting + bool get wantsToDisplayToolbar => _controlsController!.shouldShowToolbar.value; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsController = SuperMessageIosControlsScope.rootOf(context); + + // It's possible that `didChangeDependencies` is called during build when pushing a route + // that has a delegated transition. We need to wait until the next frame to show the overlay, + // otherwise this widget crashes, since we can't call `OverlayPortalController.show` during build. + onNextFrame((timeStamp) { + _overlayPortalController.show(); + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child!, + OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: _buildToolbar, + child: const SizedBox(), + ), + ], + ); + } + + Widget _buildToolbar(BuildContext context) { + return TapRegion( + groupId: widget.tapRegionGroupId, + child: IosFloatingToolbarOverlay( + shouldShowToolbar: _controlsController!.shouldShowToolbar, + toolbarFocalPoint: _controlsController!.toolbarFocalPoint, + floatingToolbarBuilder: + _controlsController!.toolbarBuilder ?? widget.defaultToolbarBuilder ?? (_, __, ___) => const SizedBox(), + createOverlayControlsClipper: _controlsController!.createOverlayControlsClipper, + showDebugPaint: false, + ), + ); + } +} + +/// Adds and removes an iOS-style editor magnifier, as dictated by an ancestor +/// [SuperMessageIosControlsScope]. +class SuperMessageIosMagnifierOverlayManager extends StatefulWidget { + const SuperMessageIosMagnifierOverlayManager({ + super.key, + this.child, + }); + + final Widget? child; + + @override + State createState() => SuperMessageIosMagnifierOverlayManagerState(); +} + +@visibleForTesting +class SuperMessageIosMagnifierOverlayManagerState extends State + with SingleTickerProviderStateMixin { + final OverlayPortalController _overlayPortalController = OverlayPortalController(); + SuperMessageIosControlsController? _controlsController; + + @visibleForTesting + bool get wantsToDisplayMagnifier => _controlsController!.shouldShowMagnifier.value; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsController = SuperMessageIosControlsScope.rootOf(context); + + // It's possible that `didChangeDependencies` is called during build when pushing a route + // that has a delegated transition. We need to wait until the next frame to show the overlay, + // otherwise this widget crashes, since we can't call `OverlayPortalController.show` during build. + onNextFrame((timeStamp) { + _overlayPortalController.show(); + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child!, + OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: _buildMagnifier, + child: const SizedBox(), + ), + ], + ); + } + + Widget _buildMagnifier(BuildContext context) { + // Display a magnifier that tracks a focal point. + // + // When the user is dragging an overlay handle, SuperEditor + // position a Leader with a LeaderLink. This magnifier follows that Leader + // via the LeaderLink. + return ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowMagnifier, + builder: (context, shouldShowMagnifier, child) { + return _controlsController!.magnifierBuilder != null // + ? _controlsController!.magnifierBuilder!( + context, + DocumentKeys.magnifier, + _controlsController!.magnifierFocalPoint, + shouldShowMagnifier, + ) + : _buildDefaultMagnifier( + context, + DocumentKeys.magnifier, + _controlsController!.magnifierFocalPoint, + shouldShowMagnifier, + ); + }, + ); + } + + Widget _buildDefaultMagnifier(BuildContext context, Key magnifierKey, LeaderLink magnifierFocalPoint, bool visible) { + if (CurrentPlatform.isWeb) { + // Defer to the browser to display overlay controls on mobile. + return const SizedBox(); + } + + return IOSFollowingMagnifier.roundedRectangle( + magnifierKey: magnifierKey, + show: visible, + leaderLink: magnifierFocalPoint, + // The magnifier is centered with the focal point. Translate it so that it sits + // above the focal point and leave a few pixels between the bottom of the magnifier + // and the focal point. This value was chosen empirically. + offsetFromFocalPoint: Offset(0, (-defaultIosMagnifierSize.height / 2) - 20), + handleColor: _controlsController!.handleColor, + ); + } +} + +/// A [SuperMessageDocumentLayerBuilder], which builds a [IosHandlesDocumentLayer], +/// which displays iOS-style handles. +class SuperMessageIosHandlesDocumentLayerBuilder implements SuperMessageDocumentLayerBuilder { + const SuperMessageIosHandlesDocumentLayerBuilder({ + this.handleColor, + }); + + final Color? handleColor; + + @override + ContentLayerWidget build(BuildContext context, DocumentContext readerContext) { + if (defaultTargetPlatform != TargetPlatform.iOS || SuperMessageIosControlsScope.maybeNearestOf(context) == null) { + // There's no controls scope. This probably means SuperEditor is configured with + // a non-iOS gesture mode. Build nothing. + return const ContentLayerProxyWidget(child: EmptyBox()); + } + + return IosHandlesDocumentLayer( + document: readerContext.document, + documentLayout: readerContext.documentLayout, + selection: readerContext.composer.selectionNotifier, + changeSelection: (newSelection, changeType, reason) { + readerContext.editor.execute([ + ChangeSelectionRequest( + newSelection, + changeType, + reason, + ), + ]); + }, + handleColor: handleColor ?? + SuperMessageIosControlsScope.maybeRootOf(context)?.handleColor ?? + Theme.of(context).primaryColor, + shouldCaretBlink: ValueNotifier(false), + ); + } +} + +/// An iOS floating toolbar, which includes standard buttons for [SuperMessage]s. +class DefaultIOSSuperMessageToolbar extends StatelessWidget { + const DefaultIOSSuperMessageToolbar({ + super.key, + this.floatingToolbarKey, + required this.editor, + required this.messageControlsController, + required this.focalPoint, + }); + + final Key? floatingToolbarKey; + final LeaderLink focalPoint; + final Editor editor; + final SuperMessageIosControlsController messageControlsController; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: editor.composer.selectionNotifier, + builder: (context, selection, child) { + // Note: We have to use a custom toolbar, instead of the official iOS context menu, because + // in Flutter's infinite wisdom they decided to only support the iOS context menu when there's + // an open IME connection. + return _buildToolbar(context); + }, + ); + } + + Widget _buildToolbar(BuildContext context) { + final brightness = Theme.of(context).brightness; + + return Theme( + data: ThemeData( + colorScheme: brightness == Brightness.light // + ? const ColorScheme.light(primary: Colors.black) + : const ColorScheme.dark(primary: Colors.white), + ), + child: CupertinoPopoverToolbar( + key: floatingToolbarKey, + focalPoint: LeaderMenuFocalPoint(link: focalPoint), + elevation: 8.0, + backgroundColor: brightness == Brightness.dark // + ? iOSToolbarDarkBackgroundColor + : iOSToolbarLightBackgroundColor, + activeButtonTextColor: brightness == Brightness.dark // + ? iOSToolbarDarkArrowActiveColor + : iOSToolbarLightArrowActiveColor, + inactiveButtonTextColor: brightness == Brightness.dark // + ? iOSToolbarDarkArrowInactiveColor + : iOSToolbarLightArrowInactiveColor, + children: [ + _buildButton( + onPressed: _copy, + title: 'Copy', + ), + _buildButton( + onPressed: _selectAll, + title: 'Select All', + ), + ], + ), + ); + } + + Widget _buildButton({ + required String title, + required VoidCallback onPressed, + }) { + // TODO: Bring this back after its updated to support theming (Overlord #17) + // return CupertinoPopoverToolbarMenuItem( + // label: title, + // onPressed: onPressed, + // ); + + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + minimumSize: const Size(kMinInteractiveDimension, 0), + padding: EdgeInsets.zero, + splashFactory: NoSplash.splashFactory, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w300, + ), + ), + ), + ); + } + + void _copy() { + final textToCopy = _textInSelection( + document: editor.document, + documentSelection: editor.composer.selection!, + ); + _saveToClipboard(textToCopy); + + messageControlsController.hideToolbar(); + } + + void _selectAll() { + if (editor.document.isEmpty) { + return; + } + + editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: editor.document.first.id, + nodePosition: editor.document.first.beginningPosition, + ), + extent: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: editor.document.last.endPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + } + + Future _saveToClipboard(String text) { + return Clipboard.setData(ClipboardData(text: text)); + } + + String _textInSelection({ + required Document document, + required DocumentSelection documentSelection, + }) { + final selectedNodes = document.getNodesInside( + documentSelection.base, + documentSelection.extent, + ); + + final buffer = StringBuffer(); + for (int i = 0; i < selectedNodes.length; ++i) { + final selectedNode = selectedNodes[i]; + dynamic nodeSelection; + + if (i == 0) { + // This is the first node and it may be partially selected. + final baseSelectionPosition = selectedNode.id == documentSelection.base.nodeId + ? documentSelection.base.nodePosition + : documentSelection.extent.nodePosition; + + final extentSelectionPosition = + selectedNodes.length > 1 ? selectedNode.endPosition : documentSelection.extent.nodePosition; + + nodeSelection = selectedNode.computeSelection( + base: baseSelectionPosition, + extent: extentSelectionPosition, + ); + } else if (i == selectedNodes.length - 1) { + // This is the last node and it may be partially selected. + final nodePosition = selectedNode.id == documentSelection.base.nodeId + ? documentSelection.base.nodePosition + : documentSelection.extent.nodePosition; + + nodeSelection = selectedNode.computeSelection( + base: selectedNode.beginningPosition, + extent: nodePosition, + ); + } else { + // This node is fully selected. Copy the whole thing. + nodeSelection = selectedNode.computeSelection( + base: selectedNode.beginningPosition, + extent: selectedNode.endPosition, + ); + } + + final nodeContent = selectedNode.copyContent(nodeSelection); + if (nodeContent != null) { + buffer.write(nodeContent); + if (i < selectedNodes.length - 1) { + buffer.writeln(); + } + } + } + return buffer.toString(); + } +} diff --git a/super_editor/lib/src/chat/super_message_ios_touch_interactor.dart b/super_editor/lib/src/chat/super_message_ios_touch_interactor.dart new file mode 100644 index 0000000000..bc3374ccfb --- /dev/null +++ b/super_editor/lib/src/chat/super_message_ios_touch_interactor.dart @@ -0,0 +1,852 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/document_operations/selection_operations.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/long_press_selection.dart'; +import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; +import 'package:super_editor/src/infrastructure/touch_controls.dart'; + +/// An [InheritedWidget] that provides shared access to a [SuperMessageIosControlsController], +/// which coordinates the state of iOS controls like drag handles, magnifier, and toolbar. +/// +/// This widget and its associated controller exist so that [SuperMessage] has maximum freedom +/// in terms of where to implement iOS gestures vs handles vs the magnifier vs the toolbar. +/// Each of these responsibilities have some unique differences, which make them difficult +/// or impossible to implement within a single widget. By sharing a controller, a group of +/// independent widgets can work together to cover those various responsibilities. +/// +/// Centralizing a controller in an [InheritedWidget] also allows [SuperMessage] to share that +/// control with application code outside of [SuperMessage], by placing an [SuperMessageIosControlsScope] +/// above the [SuperMessage] in the widget tree. For this reason, [SuperMessage] should access +/// the [SuperMessageIosControlsScope] through [rootOf]. +class SuperMessageIosControlsScope extends InheritedWidget { + /// Finds the highest [SuperMessageIosControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperMessageIosControlsController]. + static SuperMessageIosControlsController rootOf(BuildContext context) { + final data = maybeRootOf(context); + + if (data == null) { + throw Exception("Tried to depend upon the root IosReaderControlsScope but no such ancestor widget exists."); + } + + return data; + } + + static SuperMessageIosControlsController? maybeRootOf(BuildContext context) { + InheritedElement? root; + + context.visitAncestorElements((element) { + if (element is! InheritedElement || element.widget is! SuperMessageIosControlsScope) { + // Keep visiting. + return true; + } + + root = element; + + // Keep visiting, to ensure we get the root scope. + return true; + }); + + if (root == null) { + return null; + } + + // Create build dependency on the iOS controls context. + context.dependOnInheritedElement(root!); + + // Return the current iOS controls data. + return (root!.widget as SuperMessageIosControlsScope).controller; + } + + /// Finds the nearest [SuperMessageIosControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperMessageIosControlsController]. + static SuperMessageIosControlsController nearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()!.controller; + + static SuperMessageIosControlsController? maybeNearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.controller; + + const SuperMessageIosControlsScope({ + super.key, + required this.controller, + required super.child, + }); + + final SuperMessageIosControlsController controller; + + @override + bool updateShouldNotify(SuperMessageIosControlsScope oldWidget) { + return controller != oldWidget.controller; + } +} + +/// A controller, which coordinates the state of various iOS reader controls, including +/// drag handles, magnifier, and toolbar. +class SuperMessageIosControlsController { + SuperMessageIosControlsController({ + this.handleColor, + this.magnifierBuilder, + this.toolbarBuilder, + this.createOverlayControlsClipper, + }); + + void dispose() { + _shouldShowMagnifier.dispose(); + _shouldShowToolbar.dispose(); + } + + /// Color of the text selection drag handles on iOS. + final Color? handleColor; + + /// Whether the iOS magnifier should be displayed right now. + ValueListenable get shouldShowMagnifier => _shouldShowMagnifier; + final _shouldShowMagnifier = ValueNotifier(false); + + /// Shows the magnifier by setting [shouldShowMagnifier] to `true`. + void showMagnifier() => _shouldShowMagnifier.value = true; + + /// Hides the magnifier by setting [shouldShowMagnifier] to `false`. + void hideMagnifier() => _shouldShowMagnifier.value = false; + + /// Toggles [shouldShowMagnifier]. + void toggleMagnifier() => _shouldShowMagnifier.value = !_shouldShowMagnifier.value; + + /// Link to a location where a magnifier should be focused. + final magnifierFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the magnifier. + /// + /// If [magnifierBuilder] is `null`, a default iOS magnifier is displayed. + final DocumentMagnifierBuilder? magnifierBuilder; + + /// Whether the iOS floating toolbar should be displayed right now. + ValueListenable get shouldShowToolbar => _shouldShowToolbar; + final _shouldShowToolbar = ValueNotifier(false); + + /// Shows the toolbar by setting [shouldShowToolbar] to `true`. + void showToolbar() => _shouldShowToolbar.value = true; + + /// Hides the toolbar by setting [shouldShowToolbar] to `false`. + void hideToolbar() => _shouldShowToolbar.value = false; + + /// Toggles [shouldShowToolbar]. + void toggleToolbar() => _shouldShowToolbar.value = !_shouldShowToolbar.value; + + /// Link to a location where a toolbar should be focused. + /// + /// This link probably points to a rectangle, such as a bounding rectangle + /// around the user's selection. Therefore, the toolbar builder shouldn't + /// assume that this focal point is a single pixel. + final toolbarFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the floating + /// toolbar. + /// + /// If [toolbarBuilder] is `null`, a default iOS toolbar is displayed. + final DocumentFloatingToolbarBuilder? toolbarBuilder; + + /// Creates a clipper that restricts where the toolbar and magnifier can + /// appear in the overlay. + /// + /// If no clipper factory method is provided, then the overlay controls + /// will be allowed to appear anywhere in the overlay in which they sit + /// (probably the entire screen). + final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; +} + +/// Document gesture interactor that's designed for iOS touch input, e.g., +/// drag to scroll, double and triple tap to select content, and drag +/// selection ends to expand selection. +/// +/// The primary difference between a read-only touch interactor, and an +/// editing touch interactor, is that read-only documents don't support +/// collapsed selections, i.e., caret display. When the user taps on +/// a read-only document, nothing happens. The user must drag an expanded +/// selection, or double/triple tap to select content. +class SuperMessageIosTouchInteractor extends StatefulWidget { + const SuperMessageIosTouchInteractor({ + Key? key, + required this.focusNode, + required this.messageContext, + required this.documentKey, + required this.getDocumentLayout, + this.contentTapHandlers = const [], + this.showDebugPaint = false, + required this.child, + }) : super(key: key); + + final FocusNode focusNode; + + final DocumentContext messageContext; + + final GlobalKey documentKey; + final DocumentLayout Function() getDocumentLayout; + + /// Optional list of handlers that respond to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List contentTapHandlers; + + final bool showDebugPaint; + + final Widget child; + + @override + State createState() => _SuperMessageIosTouchInteractorState(); +} + +class _SuperMessageIosTouchInteractorState extends State + with WidgetsBindingObserver, SingleTickerProviderStateMixin { + SuperMessageIosControlsController? _controlsController; + + Offset? _globalStartDragOffset; + Offset? _dragStartInDoc; + Offset? _startDragPositionOffset; + Offset? _globalDragOffset; + DragMode? _dragMode; + + // TODO: HandleType is the wrong type here, we need collapsed/base/extent, + // not collapsed/upstream/downstream. Change the type once it's working. + HandleType? _dragHandleType; + + final _magnifierFocalPoint = ValueNotifier(null); + + Timer? _tapDownLongPressTimer; + Offset? _globalTapDownOffset; + bool get _isLongPressInProgress => _longPressStrategy != null; + IosLongPressSelectionStrategy? _longPressStrategy; + + final _interactor = GlobalKey(); + + @override + void initState() { + super.initState(); + + widget.messageContext.document.addListener(_onDocumentChange); + + widget.messageContext.composer.selectionNotifier.addListener(_onSelectionChange); + // If we already have a selection, we may need to display drag handles. + if (widget.messageContext.composer.selection != null) { + _onSelectionChange(); + } + + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsController = SuperMessageIosControlsScope.rootOf(context); + } + + @override + void didUpdateWidget(SuperMessageIosTouchInteractor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.messageContext.document != oldWidget.messageContext.document) { + oldWidget.messageContext.document.removeListener(_onDocumentChange); + widget.messageContext.document.addListener(_onDocumentChange); + } + + if (widget.messageContext.composer != oldWidget.messageContext.composer) { + oldWidget.messageContext.composer.selectionNotifier.removeListener(_onSelectionChange); + widget.messageContext.composer.selectionNotifier.addListener(_onSelectionChange); + + // Selection has changed, we need to update the caret. + if (widget.messageContext.composer.selection != oldWidget.messageContext.composer.selection) { + _onSelectionChange(); + } + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + + widget.messageContext.document.removeListener(_onDocumentChange); + widget.messageContext.composer.selectionNotifier.removeListener(_onSelectionChange); + + super.dispose(); + } + + void _onDocumentChange(_) { + _controlsController!.hideToolbar(); + + onNextFrame((_) { + // The user may have changed the type of node, e.g., paragraph to + // blockquote, which impacts the caret size and position. Reposition + // the caret on the next frame. + // TODO: find a way to only do this when something relevant changes + _updateHandlesAfterSelectionOrLayoutChange(); + }); + } + + void _onSelectionChange() { + // The selection change might correspond to new content that's not + // laid out yet. Wait until the next frame to update visuals. + onNextFrame((_) => _updateHandlesAfterSelectionOrLayoutChange()); + } + + void _updateHandlesAfterSelectionOrLayoutChange() { + final newSelection = widget.messageContext.composer.selection; + + if (newSelection == null) { + _controlsController!.hideToolbar(); + } + } + + /// Returns the layout for the current document, which answers questions + /// about the locations and sizes of visual components within the layout. + DocumentLayout get _docLayout => widget.getDocumentLayout(); + + /// Returns the render box for the interactor gesture detector. + RenderBox get interactorBox => _interactor.currentContext!.findRenderObject() as RenderBox; + + /// Converts the given [interactorOffset] from the [DocumentInteractor]'s coordinate + /// space to the [DocumentLayout]'s coordinate space. + Offset _interactorOffsetToDocumentOffset(Offset interactorOffset) { + final globalOffset = interactorBox.localToGlobal(interactorOffset); + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); + } + + Offset _globalOffsetToDocumentOffset(Offset globalOffset) { + final myBox = context.findRenderObject() as RenderBox; + final docOffset = myBox.globalToLocal(globalOffset); + return docOffset; + } + + void _onTapDown(TapDownDetails details) { + _globalTapDownOffset = details.globalPosition; + _tapDownLongPressTimer?.cancel(); + _tapDownLongPressTimer = Timer(kLongPressTimeout, _onLongPressDown); + } + + void _onTapCancel() { + _tapDownLongPressTimer?.cancel(); + _tapDownLongPressTimer = null; + } + + // Runs when a tap down has lasted long enough to signify a long-press. + void _onLongPressDown() { + final interactorOffset = interactorBox.globalToLocal(_globalTapDownOffset!); + final tapDownDocumentOffset = _interactorOffsetToDocumentOffset(interactorOffset); + final tapDownDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(tapDownDocumentOffset); + if (tapDownDocumentPosition == null) { + return; + } + + if (_isOverBaseHandle(interactorOffset) || _isOverExtentHandle(interactorOffset)) { + // Don't do anything for long presses over the handles, because we want the user + // to be able to drag them without worrying about how long they've pressed. + return; + } + + _globalDragOffset = _globalTapDownOffset; + _longPressStrategy = IosLongPressSelectionStrategy( + document: widget.messageContext.document, + documentLayout: _docLayout, + select: _select, + ); + final didLongPressSelectionStart = _longPressStrategy!.onLongPressStart( + tapDownDocumentOffset: tapDownDocumentOffset, + ); + if (!didLongPressSelectionStart) { + _longPressStrategy = null; + return; + } + + _placeFocalPointNearTouchOffset(); + _controlsController! + ..hideToolbar() + ..showMagnifier(); + + widget.focusNode.requestFocus(); + } + + void _onTapUp(TapUpDetails details) { + // Stop waiting for a long-press to start. + _globalTapDownOffset = null; + _tapDownLongPressTimer?.cancel(); + _controlsController!.hideMagnifier(); + + readerGesturesLog.info("Tap down on document"); + final docOffset = _globalOffsetToDocumentOffset(details.globalPosition); + readerGesturesLog.fine(" - document offset: $docOffset"); + + final selection = widget.messageContext.composer.selection; + if (selection != null && + !selection.isCollapsed && + (_isOverBaseHandle(docOffset) || _isOverExtentHandle(docOffset))) { + _controlsController!.toggleToolbar(); + return; + } + + for (final handler in widget.contentTapHandlers) { + final result = handler.onTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + readerGesturesLog.fine(" - tapped document position: $docPosition"); + if (docPosition != null && + selection != null && + !selection.isCollapsed && + widget.messageContext.document.doesSelectionContainPosition(selection, docPosition)) { + // The user tapped on an expanded selection. Toggle the toolbar. + _controlsController!.toggleToolbar(); + return; + } + + _clearSelection(); + _controlsController!.hideToolbar(); + + widget.focusNode.requestFocus(); + } + + void _onDoubleTapUp(TapUpDetails details) { + readerGesturesLog.info("Double tap down on document"); + final docOffset = _globalOffsetToDocumentOffset(details.globalPosition); + readerGesturesLog.fine(" - document offset: $docOffset"); + + final selection = widget.messageContext.composer.selection; + if (selection != null && + !selection.isCollapsed && + (_isOverBaseHandle(docOffset) || _isOverExtentHandle(docOffset))) { + return; + } + + for (final handler in widget.contentTapHandlers) { + final result = handler.onDoubleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + + _clearSelection(); + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + readerGesturesLog.fine(" - tapped document position: $docPosition"); + if (docPosition != null) { + final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; + if (!tappedComponent.isVisualSelectionSupported()) { + return; + } + + _clearSelection(); + + final wordSelection = getWordSelection(docPosition: docPosition, docLayout: _docLayout); + var didSelectContent = wordSelection != null; + if (wordSelection != null) { + _setSelection(wordSelection); + didSelectContent = true; + } + + if (!didSelectContent) { + final blockSelection = getBlockSelection(docPosition); + if (blockSelection != null) { + _setSelection(blockSelection); + didSelectContent = true; + } + } + } + + final newSelection = widget.messageContext.composer.selection; + if (newSelection == null || newSelection.isCollapsed) { + _controlsController!.hideToolbar(); + } else { + _controlsController!.showToolbar(); + } + + widget.focusNode.requestFocus(); + } + + void _onTripleTapUp(TapUpDetails details) { + readerGesturesLog.info("Triple down down on document"); + final docOffset = _globalOffsetToDocumentOffset(details.globalPosition); + readerGesturesLog.fine(" - document offset: $docOffset"); + + for (final handler in widget.contentTapHandlers) { + final result = handler.onTripleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + + _clearSelection(); + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + readerGesturesLog.fine(" - tapped document position: $docPosition"); + if (docPosition != null) { + final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; + if (!tappedComponent.isVisualSelectionSupported()) { + return; + } + + final paragraphSelection = getParagraphSelection(docPosition: docPosition, docLayout: _docLayout); + if (paragraphSelection != null) { + _setSelection(paragraphSelection); + } + } + + final selection = widget.messageContext.composer.selection; + if (selection == null || selection.isCollapsed) { + _controlsController!.hideToolbar(); + } else { + _controlsController!.showToolbar(); + } + + widget.focusNode.requestFocus(); + } + + void _onPanDown(DragDownDetails details) { + // No-op: this method is only here to beat out any ancestor + // Scrollable that's also trying to drag. + _updateDragStartLocation(details.globalPosition); + } + + void _onPanStart(DragStartDetails details) { + final myBox = context.findRenderObject() as RenderBox; + final docOffset = myBox.globalToLocal(details.globalPosition); + + // Stop waiting for a long-press to start, if a long press isn't already in-progress. + _globalTapDownOffset = null; + _tapDownLongPressTimer?.cancel(); + + // TODO: to help the user drag handles instead of scrolling, try checking touch + // placement during onTapDown, and then pick that up here. I think the little + // bit of slop might be the problem. + final selection = widget.messageContext.composer.selection; + if (selection == null) { + return; + } + + if (_isLongPressInProgress) { + _dragMode = DragMode.longPress; + _dragHandleType = null; + _longPressStrategy!.onLongPressDragStart(); + } else if (_isOverBaseHandle(docOffset)) { + _dragMode = DragMode.base; + _dragHandleType = HandleType.upstream; + } else if (_isOverExtentHandle(docOffset)) { + _dragMode = DragMode.extent; + _dragHandleType = HandleType.downstream; + } + + _controlsController!.hideToolbar(); + + _updateDragStartLocation(details.globalPosition); + } + + bool _isOverBaseHandle(Offset interactorOffset) { + final basePosition = widget.messageContext.composer.selection?.base; + if (basePosition == null) { + return false; + } + + final baseRect = _docLayout.getRectForPosition(basePosition)!; + // The following caretRect offset and size were chosen empirically, based + // on trying to drag the handle from various locations near the handle. + final caretRect = Rect.fromLTWH(baseRect.left - 24, baseRect.top - 24, 48, baseRect.height + 48); + + final docOffset = _interactorOffsetToDocumentOffset(interactorOffset); + return caretRect.contains(docOffset); + } + + bool _isOverExtentHandle(Offset interactorOffset) { + final extentPosition = widget.messageContext.composer.selection?.extent; + if (extentPosition == null) { + return false; + } + + final extentRect = _docLayout.getRectForPosition(extentPosition)!; + // The following caretRect offset and size were chosen empirically, based + // on trying to drag the handle from various locations near the handle. + final caretRect = Rect.fromLTWH(extentRect.left - 24, extentRect.top, 48, extentRect.height + 32); + + final docOffset = _interactorOffsetToDocumentOffset(interactorOffset); + return caretRect.contains(docOffset); + } + + void _onPanUpdate(DragUpdateDetails details) { + // The user is dragging a handle. Update the document selection, and + // auto-scroll, if needed. + _globalDragOffset = details.globalPosition; + + if (_isLongPressInProgress) { + final fingerDragDelta = _globalDragOffset! - _globalStartDragOffset!; + final fingerDocumentOffset = _docLayout.getDocumentOffsetFromAncestorOffset(details.globalPosition); + final fingerDocumentPosition = _docLayout.getDocumentPositionNearestToOffset( + _startDragPositionOffset! + fingerDragDelta, + ); + _longPressStrategy!.onLongPressDragUpdate(fingerDocumentOffset, fingerDocumentPosition); + } else { + _updateSelectionForNewDragHandleLocation(); + } + + _controlsController!.showMagnifier(); + + _placeFocalPointNearTouchOffset(); + } + + void _updateSelectionForNewDragHandleLocation() { + final docDragDelta = _globalDragOffset! - _globalStartDragOffset!; + final docDragPosition = _docLayout.getDocumentPositionNearestToOffset(_startDragPositionOffset! + docDragDelta); + + if (docDragPosition == null) { + return; + } + + if (_dragHandleType == HandleType.upstream) { + _setSelection(widget.messageContext.composer.selection!.copyWith( + base: docDragPosition, + )); + } else if (_dragHandleType == HandleType.downstream) { + _setSelection(widget.messageContext.composer.selection!.copyWith( + extent: docDragPosition, + )); + } + } + + void _onPanEnd(DragEndDetails details) { + if (_dragMode != null) { + _onDragSelectionEnd(); + } + } + + void _onPanCancel() { + if (_dragMode != null) { + _onDragSelectionEnd(); + } + } + + void _onDragSelectionEnd() { + if (_dragMode == DragMode.longPress) { + _onLongPressEnd(); + } else { + _onHandleDragEnd(); + } + } + + void _onLongPressEnd() { + _longPressStrategy!.onLongPressEnd(); + _longPressStrategy = null; + _dragMode = null; + + _updateOverlayControlsAfterFinishingDragSelection(); + } + + void _onHandleDragEnd() { + _dragMode = null; + + _updateOverlayControlsAfterFinishingDragSelection(); + } + + void _updateOverlayControlsAfterFinishingDragSelection() { + _controlsController!.hideMagnifier(); + if (!widget.messageContext.composer.selection!.isCollapsed) { + _controlsController!.showToolbar(); + } else { + // Read-only documents don't support collapsed selections. + _clearSelection(); + } + } + + void _select(DocumentSelection newSelection) { + _setSelection(newSelection); + } + + /// Updates the magnifier focal point in relation to the current drag position. + void _placeFocalPointNearTouchOffset() { + late DocumentPosition? docPositionToMagnify; + + if (_globalTapDownOffset != null) { + // A drag isn't happening. Magnify the position that the user tapped. + final documentOffset = _docLayout.getDocumentOffsetFromAncestorOffset(_globalTapDownOffset!); + docPositionToMagnify = _docLayout.getDocumentPositionNearestToOffset(documentOffset); + } else { + final docDragDelta = _globalDragOffset! - _globalStartDragOffset!; + docPositionToMagnify = _docLayout.getDocumentPositionNearestToOffset(_startDragPositionOffset! + docDragDelta); + } + + final centerOfContentAtOffset = _interactorOffsetToDocumentOffset( + _docLayout.getRectForPosition(docPositionToMagnify!)!.center, + ); + + _magnifierFocalPoint.value = centerOfContentAtOffset; + } + + void _updateDragStartLocation(Offset globalOffset) { + _globalStartDragOffset = globalOffset; + final handleOffsetInInteractor = interactorBox.globalToLocal(globalOffset); + _dragStartInDoc = _interactorOffsetToDocumentOffset(handleOffsetInInteractor); + + final selection = widget.messageContext.composer.selection; + if (_dragHandleType != null && selection != null) { + _startDragPositionOffset = _docLayout + .getRectForPosition( + _dragHandleType! == HandleType.upstream ? selection.base : selection.extent, + )! + .center; + } else { + // User is long-press dragging, which is why there's no drag handle type. + // In this case, the start drag offset is wherever the user touched. + _startDragPositionOffset = _dragStartInDoc!; + } + } + + void _setSelection(DocumentSelection selection) { + widget.messageContext.editor.execute([ + ChangeSelectionRequest( + selection, + SelectionChangeType.clearSelection, + SelectionReason.userInteraction, + ), + ]); + } + + void _clearSelection() { + widget.messageContext.editor.execute([ + const ChangeSelectionRequest( + null, + SelectionChangeType.clearSelection, + SelectionReason.userInteraction, + ), + ]); + } + + @override + Widget build(BuildContext context) { + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + // PanGestureRecognizer is above contents to have first pass at gestures, but it only accepts + // gestures that are over caret or handles or when a long press is in progress. + // TapGestureRecognizer is below contents so that it doesn't interferes with buttons and other + // tappable widgets. + return Stack( + children: [ + // Layer below + Positioned.fill( + child: RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapCancel = _onTapCancel + ..onTapUp = _onTapUp + ..onDoubleTapUp = _onDoubleTapUp + ..onTripleTapUp = _onTripleTapUp + ..gestureSettings = gestureSettings; + }, + ), + }, + ), + ), + widget.child, + // Layer above + Positioned.fill( + child: RawGestureDetector( + key: _interactor, + behavior: HitTestBehavior.translucent, + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..shouldAccept = () { + if (_globalTapDownOffset == null) { + return false; + } + final panDown = interactorBox.globalToLocal(_globalTapDownOffset!); + final isOverHandle = _isOverBaseHandle(panDown) || _isOverExtentHandle(panDown); + return isOverHandle || _isLongPressInProgress; + } + ..dragStartBehavior = DragStartBehavior.down + ..onDown = _onPanDown + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + child: Stack( + children: [ + _buildMagnifierFocalPoint(), + ], + ), + ), + ), + ], + ); + } + + Widget _buildMagnifierFocalPoint() { + return ValueListenableBuilder( + valueListenable: _magnifierFocalPoint, + builder: (context, magnifierOffset, child) { + if (magnifierOffset == null) { + return const SizedBox(); + } + + // When the user is dragging a handle in this overlay, we + // are responsible for positioning the focal point for the + // magnifier to follow. We do that here. + return Positioned( + left: magnifierOffset.dx, + top: magnifierOffset.dy, + child: Leader( + link: _controlsController!.magnifierFocalPoint, + child: const SizedBox(width: 1, height: 1), + ), + ); + }, + ); + } +} diff --git a/super_editor/lib/src/chat/super_message_keyboard_interactor.dart b/super_editor/lib/src/chat/super_message_keyboard_interactor.dart new file mode 100644 index 0000000000..7c7c4a62e7 --- /dev/null +++ b/super_editor/lib/src/chat/super_message_keyboard_interactor.dart @@ -0,0 +1,80 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; + +/// Receives all hardware keyboard input, when focused, and changes the read-only +/// document display, as needed. +/// +/// [keyboardActions] determines the mapping from keyboard key presses +/// to document editing behaviors. [keyboardActions] operates as a +/// Chain of Responsibility. +/// +/// The difference between a read-only keyboard interactor, and an editing keyboard +/// interactor, is the type of service locator that's passed to each handler. For +/// example, the read-only keyboard interactor can't pass a `DocumentEditor` to +/// the keyboard handlers, because read-only documents don't support edits. +class SuperMessageKeyboardInteractor extends StatelessWidget { + const SuperMessageKeyboardInteractor({ + Key? key, + required this.focusNode, + required this.messageContext, + required this.keyboardActions, + required this.child, + this.autofocus = false, + }) : super(key: key); + + /// The source of all key events. + final FocusNode focusNode; + + /// Service locator for document display dependencies. + final DocumentContext messageContext; + + /// All the actions that the user can execute with keyboard keys. + /// + /// [keyboardActions] operates as a Chain of Responsibility. Starting + /// from the beginning of the list, a [DocumentKeyboardAction] is + /// given the opportunity to handle the currently pressed keys. If that + /// [DocumentKeyboardAction] reports the keys as handled, then execution + /// stops. Otherwise, execution continues to the next [DocumentKeyboardAction]. + final List keyboardActions; + + /// Whether or not the [SuperMessageKeyboardInteractor] should autofocus + final bool autofocus; + + /// The [child] widget, which is expected to include the document UI + /// somewhere in the sub-tree. + final Widget child; + + KeyEventResult _onKeyEventPressed(FocusNode node, KeyEvent keyEvent) { + readerKeyLog.info("Handling key press: $keyEvent"); + ExecutionInstruction instruction = ExecutionInstruction.continueExecution; + int index = 0; + while (instruction == ExecutionInstruction.continueExecution && index < keyboardActions.length) { + instruction = keyboardActions[index]( + documentContext: messageContext, + keyEvent: keyEvent, + ); + index += 1; + } + + switch (instruction) { + case ExecutionInstruction.haltExecution: + return KeyEventResult.handled; + case ExecutionInstruction.continueExecution: + case ExecutionInstruction.blocked: + return KeyEventResult.ignored; + } + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: focusNode, + includeSemantics: false, + onKeyEvent: _onKeyEventPressed, + autofocus: autofocus, + child: child, + ); + } +} diff --git a/super_editor/lib/src/chat/super_message_mouse_interactor.dart b/super_editor/lib/src/chat/super_message_mouse_interactor.dart new file mode 100644 index 0000000000..2eccf6a2c1 --- /dev/null +++ b/super_editor/lib/src/chat/super_message_mouse_interactor.dart @@ -0,0 +1,605 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/document_operations/selection_operations.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; +import 'package:super_editor/src/super_reader/read_only_document_mouse_interactor.dart' + show moveToNearestSelectableComponent, selectRegion; + +import '../core/document_composer.dart'; + +/// Governs mouse gesture interaction with a read-only document, such as scrolling +/// a document with a scroll wheel and tap-and-dragging to create an expanded selection. + +/// Document gesture interactor that's designed for read-only mouse input, +/// e.g., drag to select, and mouse wheel to scroll. +/// +/// - selects content on double, and triple taps +/// - selects content on drag, after single, double, or triple tap +/// - scrolls with the mouse wheel +/// - automatically scrolls up or down when the user drags near +/// a boundary +/// +/// The primary difference between a read-only mouse interactor, and an +/// editing mouse interactor, is that read-only documents don't support +/// collapsed selections, i.e., caret display. When the user taps on +/// a read-only document, nothing happens. The user must drag an expanded +/// selection, or double/triple tap to select content. +class SuperMessageMouseInteractor extends StatefulWidget { + const SuperMessageMouseInteractor({ + Key? key, + this.focusNode, + required this.messageContext, + this.contentTapHandlers = const [], + this.showDebugPaint = false, + required this.child, + }) : super(key: key); + + final FocusNode? focusNode; + + /// Service locator for document dependencies. + final DocumentContext messageContext; + + /// Optional list of handlers that respond to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List contentTapHandlers; + + /// Paints some extra visual ornamentation to help with + /// debugging, when `true`. + final bool showDebugPaint; + + /// The document to display within this [SuperMessageMouseInteractor]. + final Widget child; + + @override + State createState() => _SuperMessageMouseInteractorState(); +} + +class _SuperMessageMouseInteractorState extends State with SingleTickerProviderStateMixin { + final _documentWrapperKey = GlobalKey(); + + late FocusNode _focusNode; + + // Tracks user drag gestures for selection purposes. + SelectionType _selectionType = SelectionType.position; + Offset? _dragStartGlobal; + Offset? _dragEndGlobal; + bool _expandSelectionDuringDrag = false; + + /// Holds which kind of device started a pan gesture, e.g., a mouse or a trackpad. + PointerDeviceKind? _panGestureDevice; + + final _mouseCursor = ValueNotifier(SystemMouseCursors.text); + Offset? _lastHoverOffset; + + @override + void initState() { + super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + + for (final handler in widget.contentTapHandlers) { + handler.addListener(_updateMouseCursorAtLatestOffset); + } + } + + @override + void didUpdateWidget(SuperMessageMouseInteractor oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + _focusNode = widget.focusNode ?? FocusNode(); + } + + if (!const DeepCollectionEquality().equals(oldWidget.contentTapHandlers, widget.contentTapHandlers)) { + for (final handler in oldWidget.contentTapHandlers) { + handler.removeListener(_updateMouseCursorAtLatestOffset); + } + + for (final handler in widget.contentTapHandlers) { + handler.addListener(_updateMouseCursorAtLatestOffset); + } + } + } + + @override + void dispose() { + for (final handler in widget.contentTapHandlers) { + handler.removeListener(_updateMouseCursorAtLatestOffset); + } + + if (widget.focusNode == null) { + _focusNode.dispose(); + } + + super.dispose(); + } + + /// Returns the layout for the current document, which answers questions + /// about the locations and sizes of visual components within the layout. + DocumentLayout get _docLayout => widget.messageContext.documentLayout; + + Offset _getDocOffsetFromGlobalOffset(Offset globalOffset) { + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); + } + + bool get _isShiftPressed => (HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shift)); + + void _onMouseMove(PointerHoverEvent event) { + _updateMouseCursor(event.position); + _lastHoverOffset = event.position; + } + + void _updateMouseCursorAtLatestOffset() { + if (_lastHoverOffset == null) { + return; + } + _updateMouseCursor(_lastHoverOffset!); + } + + void _updateMouseCursor(Offset globalPosition) { + final docOffset = _getDocOffsetFromGlobalOffset(globalPosition); + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + if (docPosition == null) { + _mouseCursor.value = SystemMouseCursors.text; + return; + } + + for (final handler in widget.contentTapHandlers) { + final cursorForContent = handler.mouseCursorForContentHover(docPosition); + if (cursorForContent != null) { + _mouseCursor.value = cursorForContent; + return; + } + } + + _mouseCursor.value = SystemMouseCursors.text; + } + + void _onTapUp(TapUpDetails details) { + readerGesturesLog.info("Tap up on document"); + final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); + readerGesturesLog.fine(" - document offset: $docOffset"); + + _focusNode.requestFocus(); + + for (final handler in widget.contentTapHandlers) { + final result = handler.onTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + readerGesturesLog.fine(" - tapped document position: $docPosition"); + if (docPosition == null) { + readerGesturesLog.fine("No document content at ${details.globalPosition}."); + _clearSelection(); + return; + } + + final expandSelection = _isShiftPressed && widget.messageContext.composer.selection != null; + if (!expandSelection) { + // Read-only documents don't show carets. Therefore, we only care about + // a tap when we're expanding an existing selection. + _clearSelection(); + _selectionType = SelectionType.position; + return; + } + + final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; + if (!tappedComponent.isVisualSelectionSupported()) { + moveToNearestSelectableComponent( + widget.messageContext.editor, + widget.messageContext.documentLayout, + docPosition.nodeId, + tappedComponent, + ); + return; + } + + // The user tapped while pressing shift and there's an existing + // selection. Move the extent of the selection to where the user tapped. + _setSelection(widget.messageContext.composer.selection!.copyWith( + extent: docPosition, + )); + } + + void _onDoubleTapDown(TapDownDetails details) { + readerGesturesLog.info("Double tap down on document"); + final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); + readerGesturesLog.fine(" - document offset: $docOffset"); + + for (final handler in widget.contentTapHandlers) { + final result = handler.onDoubleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + readerGesturesLog.fine(" - tapped document position: $docPosition"); + + final tappedComponent = docPosition != null ? _docLayout.getComponentByNodeId(docPosition.nodeId)! : null; + if (tappedComponent != null && !tappedComponent.isVisualSelectionSupported()) { + // The user double tapped on a component that should never display itself + // as selected. Therefore, we ignore this double-tap. + return; + } + + _selectionType = SelectionType.word; + _clearSelection(); + + if (docPosition != null) { + final wordSelection = getWordSelection(docPosition: docPosition, docLayout: _docLayout); + var didSelectContent = wordSelection != null; + if (wordSelection != null) { + _setSelection(wordSelection); + didSelectContent = true; + } + + if (!didSelectContent) { + final blockSelection = getBlockSelection(docPosition); + if (blockSelection != null) { + _setSelection(blockSelection); + didSelectContent = true; + } + } + + if (!didSelectContent) { + // Place the document selection at the location where the + // user tapped. + _selectPosition(docPosition); + } + } + + _focusNode.requestFocus(); + } + + void _onDoubleTap() { + readerGesturesLog.info("Double tap up on document"); + _selectionType = SelectionType.position; + } + + void _onTripleTapDown(TapDownDetails details) { + readerGesturesLog.info("Triple down down on document"); + final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); + readerGesturesLog.fine(" - document offset: $docOffset"); + + for (final handler in widget.contentTapHandlers) { + final result = handler.onTripleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + readerGesturesLog.fine(" - tapped document position: $docPosition"); + if (docPosition != null) { + final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; + if (!tappedComponent.isVisualSelectionSupported()) { + return; + } + } + + _selectionType = SelectionType.paragraph; + _clearSelection(); + + if (docPosition != null) { + final paragraphSelection = getParagraphSelection(docPosition: docPosition, docLayout: _docLayout); + var didSelectParagraph = paragraphSelection != null; + if (paragraphSelection != null) { + _setSelection(paragraphSelection); + } + + if (!didSelectParagraph) { + // Place the document selection at the location where the + // user tapped. + _selectPosition(docPosition); + } + } + + _focusNode.requestFocus(); + } + + void _onTripleTap() { + readerGesturesLog.info("Triple tap up on document"); + _selectionType = SelectionType.position; + } + + void _selectPosition(DocumentPosition position) { + readerGesturesLog.fine("Setting document selection to $position"); + _setSelection(DocumentSelection.collapsed( + position: position, + )); + } + + void _onPanStart(DragStartDetails details) { + readerGesturesLog.info("Pan start on document, global offset: ${details.globalPosition}, device: ${details.kind}"); + + _panGestureDevice = details.kind; + + if (_panGestureDevice == PointerDeviceKind.trackpad) { + // After flutter 3.3, dragging with two fingers on a trackpad triggers a pan gesture. + // This gesture should scroll the document and keep the selection unchanged. + return; + } + + _dragStartGlobal = details.globalPosition; + + if (_isShiftPressed) { + _expandSelectionDuringDrag = true; + } + + if (!_isShiftPressed) { + // Only clear the selection if the user isn't pressing shift. Shift is + // used to expand the current selection, not replace it. + readerGesturesLog.fine("Shift isn't pressed. Clearing any existing selection before panning."); + _clearSelection(); + } + + _focusNode.requestFocus(); + } + + void _onPanUpdate(DragUpdateDetails details) { + readerGesturesLog + .info("Pan update on document, global offset: ${details.globalPosition}, device: $_panGestureDevice"); + + setState(() { + _dragEndGlobal = details.globalPosition; + + _updateDragSelection(); + }); + } + + void _onPanEnd(DragEndDetails details) { + readerGesturesLog.info("Pan end on document, device: $_panGestureDevice"); + _onDragEnd(); + } + + void _onPanCancel() { + readerGesturesLog.info("Pan cancel on document"); + _onDragEnd(); + } + + void _onDragEnd() { + setState(() { + _dragStartGlobal = null; + _dragEndGlobal = null; + _expandSelectionDuringDrag = false; + }); + } + + void _updateDragSelection() { + if (_dragEndGlobal == null) { + // User isn't dragging. No need to update drag selection. + return; + } + + final dragStartInDoc = _getDocOffsetFromGlobalOffset(_dragStartGlobal!); + final dragEndInDoc = _getDocOffsetFromGlobalOffset(_dragEndGlobal!); + readerGesturesLog.finest( + ''' +Updating drag selection: + - drag start in doc: $dragStartInDoc + - drag end in doc: $dragEndInDoc''', + ); + + selectRegion( + editor: widget.messageContext.editor, + documentLayout: _docLayout, + baseOffsetInDocument: dragStartInDoc, + extentOffsetInDocument: dragEndInDoc, + selectionType: _selectionType, + expandSelection: _expandSelectionDuringDrag, + ); + + if (widget.showDebugPaint) { + setState(() { + // Repaint the debug UI. + }); + } + } + + void _setSelection(DocumentSelection selection) { + widget.messageContext.editor.execute([ + ChangeSelectionRequest( + selection, + SelectionChangeType.clearSelection, + SelectionReason.userInteraction, + ), + ]); + } + + void _clearSelection() { + widget.messageContext.editor.execute([ + const ChangeSelectionRequest( + null, + SelectionChangeType.clearSelection, + SelectionReason.userInteraction, + ), + ]); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: Listener( + onPointerHover: _onMouseMove, + child: _buildCursorStyle( + child: _buildGestureInput( + child: _buildDocumentContainer( + document: const SizedBox(), + ), + ), + ), + ), + ), + widget.child, + ], + ); + } + + Widget _buildCursorStyle({ + required Widget child, + }) { + return ValueListenableBuilder( + valueListenable: _mouseCursor, + builder: (context, value, child) { + return MouseRegion( + cursor: _mouseCursor.value, + onExit: (_) => _lastHoverOffset = null, + child: child, + ); + }, + child: child, + ); + } + + Widget _buildGestureInput({ + required Widget child, + }) { + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapUp = _onTapUp + ..onDoubleTapDown = _onDoubleTapDown + ..onDoubleTap = _onDoubleTap + ..onTripleTapDown = _onTripleTapDown + ..onTripleTap = _onTripleTap + ..gestureSettings = gestureSettings; + }, + ), + PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(supportedDevices: { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + }), + (PanGestureRecognizer recognizer) { + recognizer + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + child: child, + ); + } + + Widget _buildDocumentContainer({ + required Widget document, + }) { + return Align( + alignment: Alignment.topCenter, + child: Stack( + children: [ + SizedBox( + key: _documentWrapperKey, + child: document, + ), + if (widget.showDebugPaint) // + ..._buildDebugPaintInDocSpace(), + ], + ), + ); + } + + List _buildDebugPaintInDocSpace() { + final dragStartInDoc = _dragStartGlobal != null ? _getDocOffsetFromGlobalOffset(_dragStartGlobal!) : null; + final dragEndInDoc = _dragEndGlobal != null ? _getDocOffsetFromGlobalOffset(_dragEndGlobal!) : null; + + return [ + if (dragStartInDoc != null) + Positioned( + left: dragStartInDoc.dx, + top: dragStartInDoc.dy, + child: FractionalTranslation( + translation: const Offset(-0.5, -0.5), + child: Container( + width: 16, + height: 16, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFF0088FF), + ), + ), + ), + ), + if (dragEndInDoc != null) + Positioned( + left: dragEndInDoc.dx, + top: dragEndInDoc.dy, + child: FractionalTranslation( + translation: const Offset(-0.5, -0.5), + child: Container( + width: 16, + height: 16, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFF0088FF), + ), + ), + ), + ), + if (dragStartInDoc != null && dragEndInDoc != null) + Positioned( + left: min(dragStartInDoc.dx, dragEndInDoc.dx), + top: min(dragStartInDoc.dy, dragEndInDoc.dy), + width: (dragEndInDoc.dx - dragStartInDoc.dx).abs(), + height: (dragEndInDoc.dy - dragStartInDoc.dy).abs(), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFF0088FF), width: 3), + ), + ), + ), + ]; + } +} diff --git a/super_editor/lib/src/core/document.dart b/super_editor/lib/src/core/document.dart index 8131752423..ce12ea4600 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -1,6 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; -import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_ai.dart'; /// A read-only document with styled text and multimedia elements. /// @@ -18,10 +18,22 @@ import 'package:super_editor/src/default_editor/text.dart'; /// content. /// /// To edit the content of a document, see [DocumentEditor]. -abstract class Document with ChangeNotifier { - /// Returns all of the content within the document as a list - /// of [DocumentNode]s. - List get nodes; +abstract class Document implements Iterable { + /// The number of [DocumentNode]s in this [Document]. + int get nodeCount; + + /// Returns `true` if this [Document] has zero nodes, or `false` if it + /// has `1+ nodes. + @override + bool get isEmpty; + + /// Returns the first [DocumentNode] in this [Document], or `null` if this + /// [Document] is empty. + DocumentNode? get firstOrNull; + + /// Returns the last [DocumentNode] in this [Document], or `null` if this + /// [Document] is empty. + DocumentNode? get lastOrNull; /// Returns the [DocumentNode] with the given [nodeId], or [null] /// if no such node exists. @@ -44,26 +56,32 @@ abstract class Document with ChangeNotifier { /// given [node] in this [Document], or null if the given [node] /// is the first node, or the given [node] does not exist in this /// [Document]. + @Deprecated("Use getNodeBeforeById() instead") DocumentNode? getNodeBefore(DocumentNode node); + /// Returns the [DocumentNode] that appears immediately before the + /// node with the given [nodeId] in this [Document], or `null` if + /// the matching node is the first node in the document, or no such + /// node exists. + DocumentNode? getNodeBeforeById(String nodeId); + /// Returns the [DocumentNode] that appears immediately after the /// given [node] in this [Document], or null if the given [node] /// is the last node, or the given [node] does not exist in this /// [Document]. + @Deprecated("Use getNodeAfterById() instead") DocumentNode? getNodeAfter(DocumentNode node); + /// Returns the [DocumentNode] that appears immediately after the + /// node with the given [nodeId] in this [Document], or `null` if + /// the matching node is the last node in the document, or no such + /// node exists. + DocumentNode? getNodeAfterById(String nodeId); + /// Returns the [DocumentNode] at the given [position], or [null] if /// no such node exists in this [Document]. DocumentNode? getNode(DocumentPosition position); - /// Returns a [DocumentRange] that ranges from [position1] to - /// [position2], including [position1] and [position2]. - // TODO: this method is misleading (#48) because if `position1` and - // `position2` are in the same node, they may be returned - // in the wrong order because the document doesn't know - // how to interpret positions within a node. - DocumentRange getRangeBetween(DocumentPosition position1, DocumentPosition position2); - /// Returns all [DocumentNode]s from [position1] to [position2], including /// the nodes at [position1] and [position2]. List getNodesInside(DocumentPosition position1, DocumentPosition position2); @@ -74,49 +92,158 @@ abstract class Document with ChangeNotifier { /// /// To compare [Document] equality, use the standard [==] operator. bool hasEquivalentContent(Document other); + + void addListener(DocumentChangeListener listener); + + void removeListener(DocumentChangeListener listener); } -/// A span within a [Document] that begins at [start] and -/// ends at [end]. +/// Listener that's notified when a document changes. /// -/// The [start] position must come before the [end] position in -/// the document. -class DocumentRange { - /// Creates a document range from its start and end positions. - /// - /// The [start] position must come before the [end] position in - /// the document. - DocumentRange({ - required this.start, - required this.end, +/// The [changeLog] includes an ordered list of all changes that were applied +/// to the [Document] since the last time this listener was notified. +typedef DocumentChangeListener = void Function(DocumentChangeLog changeLog); + +/// One or more document changes that occurred within a single edit transaction. +/// +/// A [DocumentChangeLog] can be used to rebuild only the parts of a document that changed. +class DocumentChangeLog { + DocumentChangeLog(this.changes); + + final List changes; + + /// Returns `true` if the [DocumentNode] with the given [nodeId] was altered in any way + /// by the events in this change log. + bool wasNodeChanged(String nodeId) { + for (final event in changes) { + if (event is NodeDocumentChange && event.nodeId == nodeId) { + return true; + } + } + return false; + } +} + +/// Marker interface for all document changes. +abstract class DocumentChange { + const DocumentChange(); + + /// Describes this change in a human-readable way. + String describe() => toString(); +} + +/// A [DocumentChange] that impacts a single, specified [DocumentNode] with [nodeId]. +abstract class NodeDocumentChange extends DocumentChange { + const NodeDocumentChange(); + + String get nodeId; +} + +/// A new [DocumentNode] was inserted in the [Document]. +class NodeInsertedEvent extends NodeDocumentChange { + const NodeInsertedEvent(this.nodeId, this.insertionIndex); + + @override + final String nodeId; + + final int insertionIndex; + + @override + String describe() => "Inserted node: $nodeId"; + + @override + String toString() => "NodeInsertedEvent ($nodeId)"; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NodeInsertedEvent && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + insertionIndex == other.insertionIndex; + + @override + int get hashCode => nodeId.hashCode ^ insertionIndex.hashCode; +} + +/// A [DocumentNode] was moved to a new index. +class NodeMovedEvent extends NodeDocumentChange { + const NodeMovedEvent({ + required this.nodeId, + required this.from, + required this.to, }); - /// The start position of the range represented by its position within the - /// document. - /// - /// The [start] position comes before the [end] position, or is equivalent to - /// the [end] position. - final DocumentPosition start; + @override + final String nodeId; + final int from; + final int to; - /// The end position of the range represented by its position within the - /// document. - /// - /// The [end] position comes after the [start] position, or is equivalent to - /// the [start] position. - final DocumentPosition end; + @override + String describe() => "Moved node ($nodeId): $from -> $to"; + + @override + String toString() => "NodeMovedEvent ($nodeId: $from -> $to)"; @override bool operator ==(Object other) => identical(this, other) || - other is DocumentRange && runtimeType == other.runtimeType && start == other.start && end == other.end; + other is NodeMovedEvent && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + from == other.from && + to == other.to; @override - int get hashCode => start.hashCode ^ end.hashCode; + int get hashCode => nodeId.hashCode ^ from.hashCode ^ to.hashCode; +} + +/// A [DocumentNode] was removed from the [Document]. +class NodeRemovedEvent extends NodeDocumentChange { + const NodeRemovedEvent(this.nodeId, this.removedNode); @override - String toString() { - return '[DocumentRange] - from: ($start), to: ($end)'; - } + final String nodeId; + + final DocumentNode removedNode; + + @override + String describe() => "Removed node: $nodeId"; + + @override + String toString() => "NodeRemovedEvent ($nodeId)"; + + @override + bool operator ==(Object other) => + identical(this, other) || other is NodeRemovedEvent && runtimeType == other.runtimeType && nodeId == other.nodeId; + + @override + int get hashCode => nodeId.hashCode; +} + +/// The content of a [DocumentNode] changed. +/// +/// A node change might signify a content change, such as text changing in a paragraph, or +/// it might signify a node changing its type of content, such as converting a paragraph +/// to an image. +class NodeChangeEvent extends NodeDocumentChange { + const NodeChangeEvent(this.nodeId); + + @override + final String nodeId; + + @override + String describe() => "Changed node: $nodeId"; + + @override + String toString() => "NodeChangeEvent ($nodeId)"; + + @override + bool operator ==(Object other) => + identical(this, other) || other is NodeChangeEvent && runtimeType == other.runtimeType && nodeId == other.nodeId; + + @override + int get hashCode => nodeId.hashCode; } /// A logical position within a [Document]. @@ -140,7 +267,7 @@ class DocumentPosition { /// /// ```dart /// final documentPosition = DocumentPosition( - /// nodeId: documentEditor.document.nodes.first.id, + /// nodeId: documentEditor.document.first.id, /// nodePosition: TextNodePosition(offset: 1), /// ); /// ``` @@ -157,6 +284,18 @@ class DocumentPosition { /// For example: a paragraph node might use a [TextNodePosition]. final NodePosition nodePosition; + /// Whether this position within the document is equivalent to the given + /// [other] [DocumentPosition]. + /// + /// Equivalency is determined by the [NodePosition]. For example, given two + /// [TextNodePosition]s, if both of them point to the same character, but one + /// has an upstream affinity and the other a downstream affinity, the two + /// [TextNodePosition]s are considered "non-equal", but they're considered + /// "equivalent" because both [TextNodePosition]s point to the same location + /// within the document. + bool isEquivalentTo(DocumentPosition other) => + nodeId == other.nodeId && nodePosition.isEquivalentTo(other.nodePosition); + @override bool operator ==(Object other) => identical(this, other) || @@ -184,10 +323,50 @@ class DocumentPosition { } /// A single content node within a [Document]. -abstract class DocumentNode implements ChangeNotifier { +@immutable +abstract class DocumentNode { + DocumentNode({ + Map? metadata, + }) { + // We construct a new map here, instead of directly assigning from the + // constructor, because we need to make sure that `_metadata` is mutable. + _metadata = { + if (metadata != null) // + ...metadata, + }; + } + + /// Adds [addedMetadata] to this nodes [metadata]. + /// + /// This protected method is intended to be used only during constructor + /// initialization by subclasses, so that subclasses can inject needed metadata + /// during construction time. This special method is provided because [DocumentNode]s + /// are otherwise immutable. + /// + /// For example, a `ParagraphNode` might need to ensure that its block type + /// metadata is set to `paragraphAttribution`: + /// + /// ParagraphNode({ + /// required super.id, + /// required super.text, + /// this.indent = 0, + /// super.metadata, + /// }) { + /// if (getMetadataValue("blockType") == null) { + /// initAddToMetadata({"blockType": paragraphAttribution}); + /// } + /// } + /// + @protected + void initAddToMetadata(Map addedMetadata) { + _metadata.addAll(addedMetadata); + } + /// ID that is unique within a [Document]. String get id; + bool get isDeletable => _metadata[NodeMetadata.isDeletable] != false; + /// Returns the [NodePosition] that corresponds to the beginning /// of content in this node. /// @@ -200,6 +379,11 @@ abstract class DocumentNode implements ChangeNotifier { /// For example, a [ParagraphNode] would return [TextNodePosition(offset: text.length)]. NodePosition get endPosition; + /// Returns `true` if this [DocumentNode] contains the given [position], or `false` + /// if the [position] doesn't sit within this node, or if the [position] type doesn't + /// apply to this [DocumentNode]. + bool containsPosition(Object position); + /// Inspects [position1] and [position2] and returns the one that's /// positioned further upstream in this [DocumentNode]. /// @@ -247,23 +431,9 @@ abstract class DocumentNode implements ChangeNotifier { } /// Returns all metadata attached to this [DocumentNode]. - Map get metadata => _metadata; - - final Map _metadata = {}; + Map get metadata => Map.from(_metadata); - /// Sets all metadata for this [DocumentNode], removing all - /// existing values. - set metadata(Map? newMetadata) { - if (const DeepCollectionEquality().equals(_metadata, newMetadata)) { - return; - } - - _metadata.clear(); - if (newMetadata != null) { - _metadata.addAll(newMetadata); - } - notifyListeners(); - } + late final Map _metadata; /// Returns `true` if this node has a non-null metadata value for /// the given metadata [key], and returns `false`, otherwise. @@ -272,16 +442,16 @@ abstract class DocumentNode implements ChangeNotifier { /// Returns this node's metadata value for the given [key]. dynamic getMetadataValue(String key) => _metadata[key]; - /// Sets this node's metadata value for the given [key] to the given - /// [value], and notifies node listeners that a change has occurred. - void putMetadataValue(String key, dynamic value) { - if (_metadata[key] == value) { - return; - } + /// Returns a copy of this [DocumentNode] with [newProperties] added to + /// the node's metadata. + /// + /// If [newProperties] contains keys that already exist in this node's + /// metadata, the existing properties are overwritten by [newProperties]. + DocumentNode copyWithAddedMetadata(Map newProperties); - _metadata[key] = value; - notifyListeners(); - } + /// Returns a copy of this [DocumentNode], replacing its existing + /// metadata with [newMetadata]. + DocumentNode copyAndReplaceMetadata(Map newMetadata); /// Returns a copy of this node's metadata. Map copyMetadata() => Map.from(_metadata); @@ -317,11 +487,44 @@ abstract class NodeSelection { // marker interface } -/// Marker interface for all node positions. -/// -/// A node position is a logical position within a [DocumentNode], -/// e.g., a [TextNodePosition] within a [ParagraphNode], or a [BinaryNodePosition] -/// within an [ImageNode]. +/// A logical position within a [DocumentNode], e.g., a [TextNodePosition] +/// within a [ParagraphNode], or a [BinaryNodePosition] within an [ImageNode]. abstract class NodePosition { - // marker interface + /// Whether this [NodePosition] is equivalent to the [other] [NodePosition]. + /// + /// Typically, [isEquivalentTo] should return the same value as [==], however, + /// some [NodePosition]s have properties that don't impact equivalency. For + /// example, a [TextNodePosition] has a concept of affinity (upstream/downstream), + /// which are used when making particular selection decisions, but affinity + /// doesn't impact equivalency. Two [TextNodePosition]s, which refer to the same + /// text offset, but have different affinities, returns `true` from [isEquivalentTo], + /// even though [==] returns `false`. + bool isEquivalentTo(NodePosition other); +} + +/// Keys to access metadata on a [DocumentNode]. +class NodeMetadata { + /// The specific type of node, when the node itself isn't self-explanatory. + /// + /// For example, a `ParagraphNode` can have a block type for a paragraph, + /// blockquote, header 1, header 2, etc. + static const String blockType = 'blockType'; + + /// A timestamp for the moment that a given node was created. + /// + /// The value should be a [CreatedAtAttribution], which allows the concept + /// of a timestamp to work at the node level (with this metadata), as well + /// as the text level (within an `AttributedText`). + static const String createdAt = 'createdAt'; + + /// Whether or not the node is deletable. + /// + /// A non-deletable node cannot be removed from the document by user + /// interaction. For exammple, selecting a non-deletable node and pressing + /// backspace has no effect. + /// + /// Apps can still remove non-deletable nodes by issuing a `DeleteNodeRequest`. + /// + /// If the node doesn't have this metadata, it is assumed to be deletable. + static const String isDeletable = 'isDeletable'; } diff --git a/super_editor/lib/src/core/document_composer.dart b/super_editor/lib/src/core/document_composer.dart index d4dc8de565..c2451e5b5b 100644 --- a/super_editor/lib/src/core/document_composer.dart +++ b/super_editor/lib/src/core/document_composer.dart @@ -1,25 +1,29 @@ +import 'dart:async'; +import 'dart:ui'; + import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/foundation.dart'; import 'package:super_editor/src/core/document.dart'; -import 'package:super_editor/src/default_editor/document_input_ime.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/pausable_value_notifier.dart'; +import '../default_editor/document_ime/document_input_ime.dart'; import 'document_selection.dart'; +import 'editor.dart'; /// Maintains a [DocumentSelection] within a [Document] and /// uses that selection to edit the document. -class DocumentComposer with ChangeNotifier { +abstract class DocumentComposer with ChangeNotifier { /// Constructs a [DocumentComposer] with the given [initialSelection]. /// /// The [initialSelection] may be omitted if no initial selection is /// desired. DocumentComposer({ DocumentSelection? initialSelection, - ImeConfiguration? imeConfiguration, - }) : imeConfiguration = ValueNotifier(imeConfiguration ?? const ImeConfiguration()), - _preferences = ComposerPreferences() { - selectionNotifier.value = initialSelection; - + SuperEditorImeConfiguration? imeConfiguration, + }) : _preferences = ComposerPreferences() { + _streamController = StreamController.broadcast(); + _selectionNotifier.value = initialSelection; _preferences.addListener(() { editorLog.fine("Composer preferences changed"); notifyListeners(); @@ -29,33 +33,149 @@ class DocumentComposer with ChangeNotifier { @override void dispose() { _preferences.dispose(); + _streamController.close(); super.dispose(); } /// Returns the current [DocumentSelection] for a [Document]. DocumentSelection? get selection => selectionNotifier.value; + /// Returns the reason for the most recent selection change in the composer. + /// + /// For example, a selection might change as a result of user interaction, or as + /// a result of another user editing content, or some other reason. + Object? get latestSelectionChangeReason => _latestSelectionChange?.reason; + + /// Returns the most recent selection change in the composer. + /// + /// The [DocumentSelectionChange] includes the most recent document selection, + /// along with the reason that the selection changed. + DocumentSelectionChange? get latestSelectionChange => _latestSelectionChange; + DocumentSelectionChange? _latestSelectionChange; + + /// A stream of document selection changes. + /// + /// Each new [DocumentSelectionChange] includes the most recent document selection, + /// along with the reason that the selection changed. + /// + /// Listen to this [Stream] when the selection reason is needed. Otherwise, use [selectionNotifier]. + Stream get selectionChanges => _streamController.stream; + late StreamController _streamController; + + /// Notifies whenever the current [DocumentSelection] changes. + /// + /// If the selection change reason is needed, use [selectionChanges] instead. + ValueListenable get selectionNotifier => _selectionNotifier; + final _selectionNotifier = PausableValueNotifier(null); + + /// The current composing region, which signifies spans of text + /// that the IME is thinking about changing. + /// + /// Only valid when editing a document with an IME input method + ValueListenable get composingRegion => _composingRegion; + final _composingRegion = PausableValueNotifier(null); + + /// Whether the editor should allow special user interactions with the + /// document content, such as clicking to open a link. + /// + /// Typically, this mode should be enabled and disabled with a special + /// keyboard key such as `cmd` or `ctrl`. + /// + /// On desktop, when using interaction mode to launch URLs, window focus + /// will jump from the Flutter app to the new browser window. This jump + /// prevents the `cmd` or `ctrl` key release from being processed by Flutter, + /// thereby locking the Flutter app in interaction mode. If this happens in + /// your app, consider using the `window_manager` plugin to find out when + /// your app window loses focus (called "blurring") and then set this value + /// to `false`. + ValueListenable get isInInteractionMode => _isInInteractionMode; + final _isInInteractionMode = PausableValueNotifier(false); + + final ComposerPreferences _preferences; + + /// Returns the composition preferences for this composer. + ComposerPreferences get preferences => _preferences; +} + +class MutableDocumentComposer extends DocumentComposer implements Editable { + MutableDocumentComposer({ + DocumentSelection? initialSelection, + SuperEditorImeConfiguration? imeConfiguration, + }) : super( + initialSelection: initialSelection, + imeConfiguration: imeConfiguration, + ); + + bool _isInTransaction = false; + bool _didChangeSelectionDuringTransaction = false; + bool _didReset = false; + /// Sets the current [selection] for a [Document]. - set selection(DocumentSelection? newSelection) { - if (newSelection != selectionNotifier.value) { - selectionNotifier.value = newSelection; - notifyListeners(); + /// + /// [reason] represents what caused the selection change to happen. + void setSelectionWithReason(DocumentSelection? newSelection, [Object reason = SelectionReason.userInteraction]) { + if (_isInTransaction && newSelection != _latestSelectionChange?.selection) { + _didChangeSelectionDuringTransaction = true; } - } - final selectionNotifier = ValueNotifier(null); + _latestSelectionChange = DocumentSelectionChange( + selection: newSelection, + reason: reason, + ); + + // Updates the selection, so both _latestSelectionChange and selectionNotifier are in sync. + _selectionNotifier.value = newSelection; + } /// Clears the current [selection]. void clearSelection() { - selection = null; + setSelectionWithReason(null, SelectionReason.userInteraction); } - final ValueNotifier imeConfiguration; + void setComposingRegion(DocumentRange? newComposingRegion) { + _composingRegion.value = newComposingRegion; + } - final ComposerPreferences _preferences; + void setIsInteractionMode(bool newValue) => _isInInteractionMode.value = newValue; - /// Returns the composition preferences for this composer. - ComposerPreferences get preferences => _preferences; + @override + void onTransactionStart() { + _selectionNotifier.pauseNotifications(); + _composingRegion.pauseNotifications(); + _isInInteractionMode.pauseNotifications(); + + _isInTransaction = true; + _didChangeSelectionDuringTransaction = false; + } + + @override + void onTransactionEnd(List edits) { + _isInTransaction = false; + + _selectionNotifier.resumeNotifications(); + if (_latestSelectionChange != null && _didChangeSelectionDuringTransaction) { + _streamController.sink.add(_latestSelectionChange!); + } + _composingRegion.resumeNotifications(); + _isInInteractionMode.resumeNotifications(); + + if (_didReset) { + // Our state was reset (possibly for to undo an operation). Anything may have changed. + // Force notify all listeners. + _didReset = false; + _selectionNotifier.notifyListeners(); + _composingRegion.notifyListeners(); + _isInInteractionMode.notifyListeners(); + } + } + + @override + void reset() { + _selectionNotifier.value = null; + _latestSelectionChange = null; + _composingRegion.value = null; + _didReset = true; + } } /// Holds preferences about user input, to be used for the @@ -123,3 +243,368 @@ class ComposerPreferences with ChangeNotifier { notifyListeners(); } } + +/// A [ChangeSelectionRequest] that represents a user's desire to push the caret upstream +/// or downstream, such as when pressing LEFT or RIGHT. +/// +/// It's useful to capture the user's desire to push the caret because sometimes the caret +/// needs to jump past a piece of content that doesn't allow partial selection, such as a +/// user tag. In the case of pushing the caret, we know which direction to jump over that +/// content. +class PushCaretRequest extends ChangeSelectionRequest { + PushCaretRequest( + DocumentPosition newPosition, + this.direction, + ) : super(DocumentSelection.collapsed(position: newPosition), SelectionChangeType.pushCaret, + SelectionReason.userInteraction); + + final TextAffinity direction; + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && other is PushCaretRequest && runtimeType == other.runtimeType && direction == other.direction; + + @override + int get hashCode => super.hashCode ^ direction.hashCode; +} + +/// A [ChangeSelectionRequest] that represents a user's desire to expand an existing selection +/// further upstream or downstream, such as when pressing SHIFT+LEFT or SHIFT+RIGHT. +/// +/// It's useful to capture the user's desire to expand the current selection because sometimes +/// the selection needs to jump past a piece of content that doesn't allow partial selection, +/// such as a user tag. In the case of expanding the selection, we know which direction to jump +/// over that content. +class ExpandSelectionRequest extends ChangeSelectionRequest { + const ExpandSelectionRequest( + DocumentSelection newSelection, + ) : super(newSelection, SelectionChangeType.expandSelection, SelectionReason.userInteraction); +} + +/// A [ChangeSelectionRequest] that represents a user's desire to collapse an existing selection +/// further upstream or downstream, such as when pressing SHIFT+LEFT or SHIFT+RIGHT. +/// +/// It's useful to capture the user's desire to expand the current selection because sometimes +/// the selection needs to jump past a piece of content that doesn't allow partial selection, +/// such as a user tag. In the case of expanding the selection, we know which direction to jump +/// over that content. +class CollapseSelectionRequest extends ChangeSelectionRequest { + CollapseSelectionRequest( + DocumentPosition newPosition, + ) : super( + DocumentSelection.collapsed(position: newPosition), + SelectionChangeType.collapseSelection, + SelectionReason.userInteraction, + ); +} + +class ClearSelectionRequest implements EditRequest { + const ClearSelectionRequest(); +} + +/// [EditRequest] that changes the [DocumentSelection] to the given [newSelection]. +class ChangeSelectionRequest implements EditRequest { + const ChangeSelectionRequest( + this.newSelection, + this.changeType, + this.reason, { + this.notifyListeners = true, + }); + + final DocumentSelection? newSelection; + + /// Whether to notify [DocumentComposer] listeners when the selection is changed. + // TODO: configure the composer so it plugs into the editor in way that this is unnecessary. + final bool notifyListeners; + + final SelectionChangeType changeType; + + /// The reason that the selection changed, such as "user interaction". + final String reason; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ChangeSelectionRequest && + runtimeType == other.runtimeType && + newSelection == other.newSelection && + notifyListeners == other.notifyListeners && + changeType == other.changeType && + reason == other.reason; + + @override + int get hashCode => newSelection.hashCode ^ notifyListeners.hashCode ^ changeType.hashCode ^ reason.hashCode; +} + +/// An [EditCommand] that changes the [DocumentSelection] in the [DocumentComposer] +/// to the [newSelection]. +class ChangeSelectionCommand extends EditCommand { + const ChangeSelectionCommand( + this.newSelection, + this.changeType, + this.reason, { + this.notifyListeners = true, + }); + + final DocumentSelection? newSelection; + + /// Whether to notify [DocumentComposer] listeners when the selection is changed. + // TODO: configure the composer so it plugs into the editor in way that this is unnecessary. + final bool notifyListeners; + + final SelectionChangeType changeType; + + final String reason; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + String describe() => "Change selection ($changeType): $newSelection"; + + @override + void execute(EditContext context, CommandExecutor executor) { + final composer = context.find(Editor.composerKey); + final initialSelection = composer.selection; + if (initialSelection == newSelection) { + // Selection is already where it should be. + return; + } + + composer.setSelectionWithReason(newSelection, reason); + + executor.logChanges([ + SelectionChangeEvent( + oldSelection: initialSelection, + newSelection: newSelection, + changeType: changeType, + reason: reason, + ) + ]); + } +} + +/// A [EditEvent] that represents a change to the user's selection within a document. +class SelectionChangeEvent extends EditEvent { + const SelectionChangeEvent({ + required this.oldSelection, + required this.newSelection, + required this.changeType, + required this.reason, + }); + + final DocumentSelection? oldSelection; + final DocumentSelection? newSelection; + final SelectionChangeType changeType; + // TODO: can we replace the concept of a `reason` with `changeType` + final String reason; + + @override + String describe() { + final buffer = StringBuffer("Selection - ${changeType.name}, $reason"); + if (newSelection == null) { + buffer.write(" (SELECTION REMOVED)"); + return buffer.toString(); + } + + if (newSelection!.isCollapsed) { + buffer.write(" (at ${newSelection!.extent.nodeId} - ${newSelection!.extent.nodePosition}"); + return buffer.toString(); + } + + buffer + ..writeln("") + ..writeln(" - from: ${newSelection!.base.nodeId} - ${newSelection!.base.nodePosition}") + ..write(" - to: ${newSelection!.extent.nodeId} - ${newSelection!.extent.nodePosition}"); + return buffer.toString(); + } + + @override + String toString() => "[SelectionChangeEvent] - New selection: $newSelection, change type: $changeType"; +} + +/// A [EditEvent] that represents a change to the user's composing region within a document. +class ComposingRegionChangeEvent extends EditEvent { + const ComposingRegionChangeEvent({ + required this.oldComposingRegion, + required this.newComposingRegion, + }); + + final DocumentRange? oldComposingRegion; + final DocumentRange? newComposingRegion; + + @override + String describe() => "Composing - ${newComposingRegion ?? "empty"}"; + + @override + String toString() => "[ComposingRegionChangeEvent] - New composing region: $newComposingRegion"; +} + +/// Represents a change of a [DocumentSelection]. +/// +/// The [reason] represents what cause the selection to change. +/// For example, [SelectionReason.userInteraction] represents +/// a selection change caused by the user interacting with the editor. +class DocumentSelectionChange { + DocumentSelectionChange({ + this.selection, + required this.reason, + }); + + final DocumentSelection? selection; + final Object reason; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DocumentSelectionChange && selection == other.selection && reason == other.reason; + + @override + int get hashCode => (selection?.hashCode ?? 0) ^ reason.hashCode; +} + +/// Holds common reasons for selection changes. +/// Developers aren't limited to these selection change reasons. Any object can be passed as +/// a reason for a selection change. However, some Super Editor behavior is based on [userInteraction]. +class SelectionReason { + /// Represents a change caused by an user interaction. + static const userInteraction = "userInteraction"; + + /// Represents a changed caused by an event which was not initiated by the user. + static const contentChange = "contentChange"; +} + +enum SelectionChangeType { + /// Place the caret, or an expanded selection, somewhere in the document, with no relationship to the previous selection. + placeCaret, + + /// Expand/contract a selection by placing the extent at a new location, such as by pressing and + /// dragging with the mouse. + placeExtent, + + /// Place the caret based on a desire to move the previous caret position upstream or downstream. + pushCaret, + + /// Expand/contract a selection by pushing the extent upstream or downstream, such as by pressing + /// SHIFT + LEFT ARROW. + pushExtent, + + /// Expand a caret to an expanded selection, or move the base or extent of an already expanded selection. + expandSelection, + + /// Collapse an expanded selection down to a caret. + collapseSelection, + + /// Change the selection as the result of inserting content, e.g., typing a character, pasting content. + insertContent, + + /// Change the selection as the result of a content modification without explicit intervention + /// by the user, e.g., Markdown "**bold**|" serialized to "bold|" + alteredContent, + + /// Change the selection by deleting content, e.g., pressing backspace or delete. + deleteContent, + + /// Clears the document selection, such as when a user taps in a textfield outside the editor. + clearSelection, +} + +class ChangeComposingRegionRequest implements EditRequest { + ChangeComposingRegionRequest(this.composingRegion); + + final DocumentRange? composingRegion; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ChangeComposingRegionRequest && + runtimeType == other.runtimeType && + composingRegion == other.composingRegion; + + @override + int get hashCode => composingRegion.hashCode; +} + +class ChangeComposingRegionCommand extends EditCommand { + ChangeComposingRegionCommand(this.composingRegion); + + final DocumentRange? composingRegion; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final composer = context.find(Editor.composerKey); + final initialComposingRegion = composer.composingRegion.value; + + composer._composingRegion.value = composingRegion; + + executor.logChanges([ + ComposingRegionChangeEvent( + oldComposingRegion: initialComposingRegion, + newComposingRegion: composingRegion, + ) + ]); + } +} + +class ClearComposingRegionRequest implements EditRequest { + const ClearComposingRegionRequest(); +} + +class ChangeInteractionModeRequest implements EditRequest { + const ChangeInteractionModeRequest({ + required this.isInteractionModeDesired, + }); + + final bool isInteractionModeDesired; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ChangeInteractionModeRequest && + runtimeType == other.runtimeType && + isInteractionModeDesired == other.isInteractionModeDesired; + + @override + int get hashCode => isInteractionModeDesired.hashCode; +} + +class ChangeInteractionModeCommand extends EditCommand { + ChangeInteractionModeCommand({ + required this.isInteractionModeDesired, + }); + + final bool isInteractionModeDesired; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.nonHistorical; + + @override + void execute(EditContext context, CommandExecutor executor) { + context.find(Editor.composerKey).setIsInteractionMode(isInteractionModeDesired); + } +} + +class RemoveComposerPreferenceStylesRequest implements EditRequest { + const RemoveComposerPreferenceStylesRequest(this.stylesToRemove); + + final Set stylesToRemove; +} + +class RemoveComposerPreferenceStylesCommand extends EditCommand { + RemoveComposerPreferenceStylesCommand(this._stylesToRemove); + + final Set _stylesToRemove; + + @override + final historyBehavior = HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final composer = context.find(Editor.composerKey); + composer.preferences.removeStyles(_stylesToRemove); + } +} diff --git a/super_editor/lib/src/core/document_editor.dart b/super_editor/lib/src/core/document_editor.dart deleted file mode 100644 index 3696cdc595..0000000000 --- a/super_editor/lib/src/core/document_editor.dart +++ /dev/null @@ -1,412 +0,0 @@ -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:uuid/uuid.dart'; - -import 'document.dart'; - -/// Editor for a [Document]. -/// -/// A [DocumentEditor] executes commands that alter the structure -/// of a [Document]. Commands are used so that document changes -/// can be event-sourced, allowing for undo/redo behavior. -// TODO: design and implement comprehensive event-sourced editing API (#49) -class DocumentEditor { - static const Uuid _uuid = Uuid(); - - /// Generates a new ID for a [DocumentNode]. - /// - /// Each generated node ID is universally unique. - static String createNodeId() => _uuid.v4(); - - /// Constructs a [DocumentEditor] that makes changes to the given - /// [MutableDocument]. - DocumentEditor({ - required MutableDocument document, - }) : _document = document; - - final MutableDocument _document; - - /// Returns a read-only version of the [Document] that this editor - /// is editing. - Document get document => _document; - - /// Executes the given [command] to alter the [Document] that is tied - /// to this [DocumentEditor]. - void executeCommand(EditorCommand command) { - command.execute(_document, DocumentEditorTransaction._(_document)); - } -} - -/// A command that alters a [Document] by applying changes in a -/// [DocumentEditorTransaction]. -abstract class EditorCommand { - /// Executes this command against the given [document], with changes - /// applied to the given [transaction]. - /// - /// The [document] is provided in case this command needs to query - /// the current content of the [document] to make appropriate changes. - void execute(Document document, DocumentEditorTransaction transaction); -} - -/// Functional version of an [EditorCommand] for commands that -/// don't require variables or private functions. -class EditorCommandFunction implements EditorCommand { - /// Creates a functional editor command given the [EditorCommand.execute] - /// function to be stored for execution. - EditorCommandFunction(this._execute); - - final void Function(Document, DocumentEditorTransaction) _execute; - - @override - void execute(Document document, DocumentEditorTransaction transaction) { - _execute(document, transaction); - } -} - -/// Accumulates changes to a document to facilitate editing actions. -class DocumentEditorTransaction { - DocumentEditorTransaction._( - MutableDocument document, - ) : _document = document; - - final MutableDocument _document; - - /// Inserts the given [node] into the [Document] at the given [index]. - void insertNodeAt(int index, DocumentNode node) { - _document.insertNodeAt(index, node); - } - - /// Inserts [newNode] immediately before the given [existingNode]. - void insertNodeBefore({ - required DocumentNode existingNode, - required DocumentNode newNode, - }) { - _document.insertNodeBefore(existingNode: existingNode, newNode: newNode); - } - - /// Inserts [newNode] immediately after the given [existingNode]. - void insertNodeAfter({ - required DocumentNode existingNode, - required DocumentNode newNode, - }) { - _document.insertNodeAfter(existingNode: existingNode, newNode: newNode); - } - - /// Deletes the node at the given [index]. - void deleteNodeAt(int index) { - _document.deleteNodeAt(index); - } - - /// Moves a [DocumentNode] matching the given [nodeId] from its current index - /// in the [Document] to the given [targetIndex]. - /// - /// If none of the nodes in this document match [nodeId], throws an error. - void moveNode({required String nodeId, required int targetIndex}) { - _document.moveNode(nodeId: nodeId, targetIndex: targetIndex); - } - - /// Replaces the given [oldNode] with the given [newNode] - void replaceNode({ - required DocumentNode oldNode, - required DocumentNode newNode, - }) { - _document.replaceNode(oldNode: oldNode, newNode: newNode); - } - - /// Deletes the given [node] from the [Document]. - bool deleteNode(DocumentNode node) { - return _document.deleteNode(node); - } -} - -/// An in-memory, mutable [Document]. -class MutableDocument with ChangeNotifier implements Document { - /// Creates an in-memory, mutable version of a [Document]. - /// - /// Initializes the content of this [MutableDocument] with the given [nodes], - /// if provided, or empty content otherwise. - MutableDocument({ - List? nodes, - }) : _nodes = nodes ?? [] { - // Register listeners for all initial nodes and populates the node maps. - for (int i = 0; i < _nodes.length; i++) { - final node = _nodes[i]; - node.addListener(_forwardNodeChange); - _nodeIndicesById[node.id] = i; - _nodesById[node.id] = node; - } - } - - final List _nodes; - - @override - List get nodes => UnmodifiableListView(_nodes); - - /// Maps a node id to its index in the node list. - final Map _nodeIndicesById = {}; - - /// Maps a node id to its node. - final Map _nodesById = {}; - - @override - DocumentNode? getNodeById(String nodeId) { - return _nodesById[nodeId]; - } - - @override - DocumentNode? getNodeAt(int index) { - if (index < 0 || index >= _nodes.length) { - return null; - } - - return _nodes[index]; - } - - @override - @Deprecated("Use getNodeIndexById() instead") - int getNodeIndex(DocumentNode node) { - final index = _nodeIndicesById[node.id] ?? -1; - if (index < 0) { - return -1; - } - - if (_nodes[index] != node) { - // We found a node by id, but it wasn't the node we expected. Therefore, we couldn't find the requested node. - return -1; - } - - return index; - } - - @override - int getNodeIndexById(String nodeId) { - return _nodeIndicesById[nodeId] ?? -1; - } - - @override - DocumentNode? getNodeBefore(DocumentNode node) { - final nodeIndex = getNodeIndexById(node.id); - return nodeIndex > 0 ? getNodeAt(nodeIndex - 1) : null; - } - - @override - DocumentNode? getNodeAfter(DocumentNode node) { - final nodeIndex = getNodeIndexById(node.id); - return nodeIndex >= 0 && nodeIndex < _nodes.length - 1 ? getNodeAt(nodeIndex + 1) : null; - } - - @override - DocumentNode? getNode(DocumentPosition position) => getNodeById(position.nodeId); - - @override - DocumentRange getRangeBetween(DocumentPosition position1, DocumentPosition position2) { - late TextAffinity affinity = getAffinityBetween(base: position1, extent: position2); - return DocumentRange( - start: affinity == TextAffinity.downstream ? position1 : position2, - end: affinity == TextAffinity.downstream ? position2 : position1, - ); - } - - @override - List getNodesInside(DocumentPosition position1, DocumentPosition position2) { - final node1 = getNode(position1); - if (node1 == null) { - throw Exception('No such position in document: $position1'); - } - final index1 = getNodeIndexById(node1.id); - - final node2 = getNode(position2); - if (node2 == null) { - throw Exception('No such position in document: $position2'); - } - final index2 = getNodeIndexById(node2.id); - - final from = min(index1, index2); - final to = max(index1, index2); - - return _nodes.sublist(from, to + 1); - } - - /// Inserts the given [node] into the [Document] at the given [index]. - void insertNodeAt(int index, DocumentNode node) { - if (index <= _nodes.length) { - _nodes.insert(index, node); - node.addListener(_forwardNodeChange); - - // The node list changed, we need to update the map to consider the new indices. - _refreshNodeIdCaches(); - - notifyListeners(); - } - } - - /// Inserts [newNode] immediately before the given [existingNode]. - void insertNodeBefore({ - required DocumentNode existingNode, - required DocumentNode newNode, - }) { - final nodeIndex = getNodeIndexById(existingNode.id); - _nodes.insert(nodeIndex, newNode); - newNode.addListener(_forwardNodeChange); - - // The node list changed, we need to update the map to consider the new indices. - _refreshNodeIdCaches(); - - notifyListeners(); - } - - /// Inserts [newNode] immediately after the given [existingNode]. - void insertNodeAfter({ - required DocumentNode existingNode, - required DocumentNode newNode, - }) { - final nodeIndex = getNodeIndexById(existingNode.id); - if (nodeIndex >= 0 && nodeIndex < _nodes.length) { - _nodes.insert(nodeIndex + 1, newNode); - newNode.addListener(_forwardNodeChange); - - // The node list changed, we need to update the map to consider the new indices. - _refreshNodeIdCaches(); - - notifyListeners(); - } - } - - /// Adds [node] to the end of the document. - void add(DocumentNode node) { - _nodes.insert(_nodes.length, node); - node.addListener(_forwardNodeChange); - - // The node list changed, we need to update the map to consider the new indices. - _refreshNodeIdCaches(); - - notifyListeners(); - } - - /// Deletes the node at the given [index]. - void deleteNodeAt(int index) { - if (index >= 0 && index < _nodes.length) { - final removedNode = _nodes.removeAt(index); - removedNode.removeListener(_forwardNodeChange); - - // The node list changed, we need to update the map to consider the new indices. - _refreshNodeIdCaches(); - - notifyListeners(); - } else { - editorDocLog.warning('Could not delete node. Index out of range: $index'); - } - } - - /// Deletes the given [node] from the [Document]. - bool deleteNode(DocumentNode node) { - bool isRemoved = false; - - node.removeListener(_forwardNodeChange); - - isRemoved = _nodes.remove(node); - - // The node list changed, we need to update the map to consider the new indices. - _refreshNodeIdCaches(); - - notifyListeners(); - - return isRemoved; - } - - /// Moves a [DocumentNode] matching the given [nodeId] from its current index - /// in the [Document] to the given [targetIndex]. - /// - /// If none of the nodes in this document match [nodeId], throws an error. - void moveNode({required String nodeId, required int targetIndex}) { - final node = getNodeById(nodeId); - if (node == null) { - throw Exception('Could not find node with nodeId: $nodeId'); - } - - if (_nodes.remove(node)) { - _nodes.insert(targetIndex, node); - - // An existing node's index changed. Update our Node ID -> Node Index cache. - _refreshNodeIdCaches(); - - notifyListeners(); - } - } - - /// Replaces the given [oldNode] with the given [newNode] - void replaceNode({ - required DocumentNode oldNode, - required DocumentNode newNode, - }) { - final index = _nodes.indexOf(oldNode); - - if (index != -1) { - oldNode.removeListener(_forwardNodeChange); - _nodes.removeAt(index); - - newNode.addListener(_forwardNodeChange); - _nodes.insert(index, newNode); - - // An existing node's index changed. Update our Node ID -> Node Index cache. - _refreshNodeIdCaches(); - - notifyListeners(); - } else { - throw Exception('Could not find oldNode: ${oldNode.id}'); - } - } - - void _forwardNodeChange() { - notifyListeners(); - } - - /// Returns [true] if the content of the [other] [Document] is equivalent - /// to the content of this [Document]. - /// - /// Content equivalency compares types of content nodes, and the content - /// within them, like the text of a paragraph, but ignores node IDs and - /// ignores the runtime type of the [Document], itself. - @override - bool hasEquivalentContent(Document other) { - final otherNodes = other.nodes; - if (_nodes.length != otherNodes.length) { - return false; - } - - for (int i = 0; i < _nodes.length; ++i) { - if (!_nodes[i].hasEquivalentContent(otherNodes[i])) { - return false; - } - } - - return true; - } - - /// Updates all the maps which use the node id as the key. - /// - /// All the maps are cleared and re-populated. - void _refreshNodeIdCaches() { - _nodeIndicesById.clear(); - _nodesById.clear(); - for (int i = 0; i < _nodes.length; i++) { - final node = _nodes[i]; - _nodeIndicesById[node.id] = i; - _nodesById[node.id] = node; - } - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is MutableDocument && - runtimeType == other.runtimeType && - const DeepCollectionEquality().equals(_nodes, other.nodes); - - @override - int get hashCode => _nodes.hashCode; -} diff --git a/super_editor/lib/src/core/document_interaction.dart b/super_editor/lib/src/core/document_interaction.dart index 65b45b054c..94a918b9dc 100644 --- a/super_editor/lib/src/core/document_interaction.dart +++ b/super_editor/lib/src/core/document_interaction.dart @@ -1,9 +1,3 @@ -/// The mode of user text input. -enum DocumentInputSource { - keyboard, - ime, -} - /// The mode of user gesture input. enum DocumentGestureMode { mouse, diff --git a/super_editor/lib/src/core/document_layout.dart b/super_editor/lib/src/core/document_layout.dart index 5f78ad5d8a..a28c57fd65 100644 --- a/super_editor/lib/src/core/document_layout.dart +++ b/super_editor/lib/src/core/document_layout.dart @@ -1,7 +1,8 @@ import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/editor.dart'; -import 'document_selection.dart'; import 'document.dart'; +import 'document_selection.dart'; /// Obtains a [DocumentLayout]. /// @@ -12,6 +13,26 @@ import 'document.dart'; /// [DocumentLayout]. typedef DocumentLayoutResolver = DocumentLayout Function(); +/// An [Editable] that provides access to a [DocumentLayout] so that +/// [EditCommand]s can make decisions based on the layout of the +/// document in an editor. +class DocumentLayoutEditable implements Editable { + const DocumentLayoutEditable(this._documentLayoutResolver); + + final DocumentLayoutResolver _documentLayoutResolver; + + DocumentLayout get documentLayout => _documentLayoutResolver(); + + @override + void onTransactionStart() {} + + @override + void onTransactionEnd(List edits) {} + + @override + void reset() {} +} + /// Abstract representation of a document layout. /// /// Regardless of how a document is displayed, a [DocumentLayout] needs @@ -36,9 +57,22 @@ abstract class DocumentLayout { /// [DocumentPosition] for the first character within the paragraph. DocumentPosition? getDocumentPositionNearestToOffset(Offset layoutOffset); - /// Returns the bounding box of the component that renders the given - /// [position], or [null] if no corresponding component can be found, or + /// Returns the upstream edge or downstream edge of the content at the given + /// [position]. + /// + /// The edge is defined by a zero-width [Rect] whose offset and height is determined + /// by the offset and height of the content at the given [position]. + /// + /// The edge of a piece of content is helpful for sizing and positioning a caret. + Rect? getEdgeForPosition(DocumentPosition position); + + /// Returns the bounding box around the given [position], within the associated + /// component, or `null` if no corresponding component can be found, or /// the corresponding component has not yet been laid out. + /// + /// For example, given a document layout that contains a text component that + /// says "Hello, world", calling `getRectForPosition()` for the third character + /// in that text component would return a bounding box for the character "l". Rect? getRectForPosition(DocumentPosition position); /// Returns a [Rect] that bounds the content selected between @@ -103,6 +137,15 @@ mixin DocumentComponent on State { /// node positions. Offset getOffsetForPosition(NodePosition nodePosition); + /// Returns the upstream edge or downstream edge of the content at the given + /// [position]. + /// + /// The edge is defined by a zero-width [Rect] whose offset and height is determined + /// by the offset and height of the content at the given [position]. + /// + /// The edge of a piece of content is helpful for sizing and positioning a caret. + Rect getEdgeForPosition(NodePosition nodePosition); + /// Returns a [Rect] for the given [nodePosition], or throws /// an exception if the given [nodePosition] is not compatible /// with this component's node type. @@ -111,6 +154,10 @@ mixin DocumentComponent on State { /// offset rather than a [Rect], a [Rect] with zero width and /// height may be returned. /// + /// For example, requesting the rect for position `3` in a text component + /// that says "Hello, world" would return a rectangle that bounds the + /// character "l". + /// /// See [Document] for more information about [DocumentNode]s and /// node positions. Rect getRectForPosition(NodePosition nodePosition); @@ -272,78 +319,116 @@ mixin DocumentComponent on State { /// to provide is [childDocumentComponentKey], which is a `GlobalKey` that provides /// access to the child [DocumentComponent]. mixin ProxyDocumentComponent implements DocumentComponent { + @protected GlobalKey get childDocumentComponentKey; - DocumentComponent get childDocumentComponent => childDocumentComponentKey.currentState as DocumentComponent; + DocumentComponent get _childDocumentComponent => childDocumentComponentKey.currentState as DocumentComponent; + + Offset _getChildOffset(Offset myOffset) { + final myBox = context.findRenderObject() as RenderBox; + final childBox = childDocumentComponentKey.currentContext!.findRenderObject() as RenderBox; + return childBox.globalToLocal(myOffset, ancestor: myBox); + } + + Offset _getOffsetFromChild(Offset childOffset) { + final myBox = context.findRenderObject() as RenderBox; + final childBox = childDocumentComponentKey.currentContext!.findRenderObject() as RenderBox; + return childBox.localToGlobal(childOffset, ancestor: myBox); + } + + Rect _getRectFromChild(Rect childRect) { + return Rect.fromPoints( + _getOffsetFromChild(childRect.topLeft), + _getOffsetFromChild(childRect.bottomRight), + ); + } @override NodePosition? getPositionAtOffset(Offset localOffset) { - return childDocumentComponent.getPositionAtOffset(localOffset); + return _childDocumentComponent.getPositionAtOffset(_getChildOffset(localOffset)); } @override Offset getOffsetForPosition(NodePosition nodePosition) { - return childDocumentComponent.getOffsetForPosition(nodePosition); + // In addition to the standard `getOffsetForPosition` of the child component, the proxy + // also calls `_getOffsetFromChild`, which returns the offset from the top-left of this + // proxy box, to the top-left of the child. Some proxy components, such as a task, + // add content that shifts the child component, like adding a checkbox. Any such + // shift of the child component must be accounted for when reporting a content offset. + return _getOffsetFromChild( + _childDocumentComponent.getOffsetForPosition(nodePosition), + ); + } + + @override + Rect getEdgeForPosition(NodePosition nodePosition) { + final childEdge = _childDocumentComponent.getEdgeForPosition(nodePosition); + return _getRectFromChild(childEdge); } @override Rect getRectForPosition(NodePosition nodePosition) { - return childDocumentComponent.getRectForPosition(nodePosition); + final childRect = _childDocumentComponent.getRectForPosition(nodePosition); + return _getRectFromChild(childRect); } @override Rect getRectForSelection(NodePosition baseNodePosition, NodePosition extentNodePosition) { - return childDocumentComponent.getRectForSelection(baseNodePosition, extentNodePosition); + final childRect = _childDocumentComponent.getRectForSelection(baseNodePosition, extentNodePosition); + return _getRectFromChild(childRect); } @override NodePosition getBeginningPosition() { - return childDocumentComponent.getBeginningPosition(); + return _childDocumentComponent.getBeginningPosition(); } @override NodePosition getBeginningPositionNearX(double x) { - return childDocumentComponent.getBeginningPositionNearX(x); + return _childDocumentComponent.getBeginningPositionNearX(_getChildOffset(Offset(x, 0)).dx); } @override NodePosition? movePositionLeft(NodePosition currentPosition, [MovementModifier? movementModifier]) { - return childDocumentComponent.movePositionLeft(currentPosition, movementModifier); + return _childDocumentComponent.movePositionLeft(currentPosition, movementModifier); } @override NodePosition? movePositionRight(NodePosition currentPosition, [MovementModifier? movementModifier]) { - return childDocumentComponent.movePositionRight(currentPosition, movementModifier); + return _childDocumentComponent.movePositionRight(currentPosition, movementModifier); } @override NodePosition? movePositionUp(NodePosition currentPosition) { - return childDocumentComponent.movePositionUp(currentPosition); + return _childDocumentComponent.movePositionUp(currentPosition); } @override NodePosition? movePositionDown(NodePosition currentPosition) { - return childDocumentComponent.movePositionDown(currentPosition); + return _childDocumentComponent.movePositionDown(currentPosition); } @override NodePosition getEndPosition() { - return childDocumentComponent.getEndPosition(); + return _childDocumentComponent.getEndPosition(); } @override NodePosition getEndPositionNearX(double x) { - return childDocumentComponent.getEndPositionNearX(x); + return _childDocumentComponent.getEndPositionNearX(_getChildOffset(Offset(x, 0)).dx); } @override NodeSelection? getSelectionInRange(Offset localBaseOffset, Offset localExtentOffset) { - return childDocumentComponent.getSelectionInRange(localBaseOffset, localExtentOffset); + return _childDocumentComponent.getSelectionInRange( + _getChildOffset(localBaseOffset), + _getChildOffset(localExtentOffset), + ); } @override NodeSelection getCollapsedSelectionAt(NodePosition nodePosition) { - return childDocumentComponent.getCollapsedSelectionAt(nodePosition); + return _childDocumentComponent.getCollapsedSelectionAt(nodePosition); } @override @@ -351,20 +436,20 @@ mixin ProxyDocumentComponent implements DocumentCompon required NodePosition basePosition, required NodePosition extentPosition, }) { - return childDocumentComponent.getSelectionBetween(basePosition: basePosition, extentPosition: extentPosition); + return _childDocumentComponent.getSelectionBetween(basePosition: basePosition, extentPosition: extentPosition); } @override NodeSelection getSelectionOfEverything() { - return childDocumentComponent.getSelectionOfEverything(); + return _childDocumentComponent.getSelectionOfEverything(); } @override - bool isVisualSelectionSupported() => childDocumentComponent.isVisualSelectionSupported(); + bool isVisualSelectionSupported() => _childDocumentComponent.isVisualSelectionSupported(); @override MouseCursor? getDesiredCursorAtOffset(Offset localOffset) { - return childDocumentComponent.getDesiredCursorAtOffset(localOffset); + return _childDocumentComponent.getDesiredCursorAtOffset(_getChildOffset(localOffset)); } } @@ -388,6 +473,7 @@ class MovementModifier { /// See also: /// /// * [line], which moves text selection line-by-line. + /// * [paragraph], which moves text selection paragraph-by-paragraph. static const word = MovementModifier('word'); /// Move text selection line-by-line. @@ -395,8 +481,17 @@ class MovementModifier { /// See also: /// /// * [word], which moves text selection word-by-word. + /// * [paragraph], which moves text selection paragraph-by-paragraph. static const line = MovementModifier('line'); + /// Move text selection paragraph-by-paragraph. + /// + /// See also: + /// + /// * [word], which moves text selection word-by-word. + /// * [line], which moves text selection line-by-line. + static const paragraph = MovementModifier('paragraph'); + /// Creates a movement modifier that is globally uniquely identified by the /// provided [id]. const MovementModifier(this.id); diff --git a/super_editor/lib/src/core/document_selection.dart b/super_editor/lib/src/core/document_selection.dart index 336a928c05..281183d15d 100644 --- a/super_editor/lib/src/core/document_selection.dart +++ b/super_editor/lib/src/core/document_selection.dart @@ -19,7 +19,7 @@ import 'document.dart'; /// to locate nodes between [base] and [extent], and to identify /// partial content that is selected within the [base] and [extent] /// nodes within the document. -class DocumentSelection { +class DocumentSelection extends DocumentRange { /// Creates a collapsed selection at the given [position] within the document. /// /// See also: @@ -29,14 +29,15 @@ class DocumentSelection { const DocumentSelection.collapsed({ required DocumentPosition position, }) : base = position, - extent = position; + extent = position, + super(start: position, end: position); /// Creates a selection from the [base] position to the [extent] position /// within the document. const DocumentSelection({ required this.base, required this.extent, - }); + }) : super(start: base, end: extent); /// The base position of the selection within the document. /// @@ -64,12 +65,72 @@ class DocumentSelection { /// selection is expanded. /// /// A [DocumentSelection] is "collapsed" when its [base] and [extent] are - /// equal ([DocumentPosition.==]). Otherwise, the [DocumentSelection] is - /// "expanded". - bool get isCollapsed => base == extent; + /// equivalent. Otherwise, the [DocumentSelection] is "expanded". + @override + bool get isCollapsed => base.nodeId == extent.nodeId && base.nodePosition.isEquivalentTo(extent.nodePosition); + + /// Returns the affinity (direction) for this selection - downstream refers to a selection + /// that starts at earlier content and ends at later content, upstream refers to a selection + /// that starts at later content and ends at earlier content. + /// + /// Calculating the selection affinity requires a [Document] because only the [Document] knows the + /// relative position of various [DocumentPosition]s. + TextAffinity calculateAffinity(Document document) => document.getAffinityBetween(base: base, extent: extent); + + /// Returns `true` if this selection has an affinity of [TextAffinity.downstream]. + /// + /// See [calculateAffinity] for more info. + bool hasDownstreamAffinity(Document document) => calculateAffinity(document) == TextAffinity.downstream; + + /// Returns `true` if this selection has an affinity of [TextAffinity.upstream]. + /// + /// See [calculateAffinity] for more info. + bool hasUpstreamAffinity(Document document) => calculateAffinity(document) == TextAffinity.upstream; + + /// Returns `true` if the given [position] sits within this [DocumentSelection]. + bool containsPosition(Document document, DocumentPosition position) { + final normalizedSelection = normalize(document); + + final startToPositionAffinity = document.getAffinityBetween(base: normalizedSelection.start, extent: position); + if (position == normalizedSelection.start) { + // The given position is the same as the selection start, so the selection contains the position. + return true; + } + if (startToPositionAffinity == TextAffinity.upstream) { + // The given `position` comes BEFORE the start of the selection, so its not in the selection. + return false; + } + + final positionToEndAffinity = document.getAffinityBetween(base: position, extent: normalizedSelection.end); + if (position == normalizedSelection.end) { + // The given position is the same as the selection end, so the selection contains the position. + return true; + } + if (positionToEndAffinity == TextAffinity.upstream) { + // The given `position` comes AFTER the end of the selection, so its not in the selection. + return false; + } + + // The given `position` comes after the selection start, and before the selection end, so its in the selection. + return true; + } @override String toString() { + if (base.nodeId == extent.nodeId) { + final basePosition = base.nodePosition; + final extentPosition = extent.nodePosition; + if (basePosition is TextNodePosition && extentPosition is TextNodePosition) { + if (basePosition.offset == extentPosition.offset) { + return "[Selection] - ${base.nodeId}: ${extentPosition.offset}"; + } + + return "[Selection] - ${base.nodeId}: [${basePosition.offset}, ${extentPosition.offset}]"; + } + + return "[Selection] - ${base.nodeId}: [${base.nodePosition}, ${extent.nodePosition}]"; + } + return '[DocumentSelection] - \n base: ($base),\n extent: ($extent)'; } @@ -118,21 +179,8 @@ class DocumentSelection { return this; } - final baseNode = document.getNodeById(base.nodeId)!; - final extentNode = document.getNodeById(extent.nodeId)!; - - if (baseNode == extentNode) { - // The selection is expanded, but it sits within a single node. - final upstreamNodePosition = extentNode.selectUpstreamPosition( - base.nodePosition, - extent.nodePosition, - ); - return DocumentSelection.collapsed( - position: extent.copyWith(nodePosition: upstreamNodePosition), - ); - } - - return document.getNodeIndexById(baseNode.id) < document.getNodeIndexById(extentNode.id) + final selectionAffinity = document.getAffinityForSelection(this); + return selectionAffinity == TextAffinity.downstream // ? DocumentSelection.collapsed(position: base) : DocumentSelection.collapsed(position: extent); } @@ -157,23 +205,10 @@ class DocumentSelection { return this; } - final baseNode = document.getNodeById(base.nodeId)!; - final extentNode = document.getNodeById(extent.nodeId)!; - - if (baseNode == extentNode) { - // The selection is expanded, but it sits within a single node. - final downstreamNodePosition = extentNode.selectDownstreamPosition( - base.nodePosition, - extent.nodePosition, - ); - return DocumentSelection.collapsed( - position: extent.copyWith(nodePosition: downstreamNodePosition), - ); - } - - return document.getNodeIndexById(baseNode.id) > document.getNodeIndexById(extentNode.id) - ? DocumentSelection.collapsed(position: base) - : DocumentSelection.collapsed(position: extent); + final selectionAffinity = document.getAffinityForSelection(this); + return selectionAffinity == TextAffinity.downstream // + ? DocumentSelection.collapsed(position: extent) + : DocumentSelection.collapsed(position: base); } @override @@ -208,11 +243,111 @@ class DocumentSelection { } } +/// A span within a [Document] with one side bounded at [start] and the other +/// side bounded at [end]. +/// +/// A [DocumentRange] is considered "normalized" if [start] comes before [end]. +/// A [DocumentRange] is NOT "normalized" if [end] comes before [start]. +/// +/// To check if a [DocumentRange] is normalized, call [isNormalized] with +/// a [Document]. +/// +/// Use [normalize] to create a version of this [DocumentRange] that's guaranteed +/// to be normalized for the given [Document]. +/// +/// Determining normalization requires a [Document] because a [Document] is the +/// source of truth for [DocumentNode] content order. +class DocumentRange { + /// Creates a document range between [start] and [end]. + const DocumentRange({ + required this.start, + required this.end, + }); + + /// The bounding position of one side of a [DocumentRange]. + /// + /// {@template start_and_end} + /// If this [DocumentRange] is normalized then [start] comes before [end], otherwise + /// [end] comes before [start]. + /// {@endtemplate} + final DocumentPosition start; + + /// The bounding position of the other side of a [DocumentRange]. + /// + /// {@macro start_and_end} + final DocumentPosition end; + + /// Returns `true` if this range is collapsed, e.g., it starts and ends + /// at the sample place. + bool get isCollapsed => start == end; + + /// Returns `true` if [start] appears at, or before [end], or `false` otherwise. + bool isNormalized(Document document) => document.getAffinityForRange(this) == TextAffinity.downstream; + + /// Returns a version of this [DocumentRange] that's normalized. + /// + /// See [isNormalized] for a definition of normalized. + DocumentRange normalize(Document document) { + if (isNormalized(document)) { + return this; + } + + // We're not normalized. To return a normalized version, reverse our bounds. + return DocumentRange(start: end, end: start); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DocumentRange && runtimeType == other.runtimeType && start == other.start && end == other.end; + + @override + int get hashCode => start.hashCode ^ end.hashCode; + + @override + String toString() { + if (start.nodeId == end.nodeId) { + final startPosition = start.nodePosition; + final endPosition = end.nodePosition; + if (startPosition is TextNodePosition && endPosition is TextNodePosition) { + if (startPosition.offset == endPosition.offset) { + return "[Range] - ${start.nodeId}: ${endPosition.offset}"; + } + + return "[Range] - ${start.nodeId}: [${startPosition.offset}, ${endPosition.offset}]"; + } + + return "[Range] - ${start.nodeId}: [${start.nodePosition}, ${end.nodePosition}]"; + } + + return '[Range] - \n start: ($start),\n end: ($end)'; + } +} + extension InspectDocumentAffinity on Document { TextAffinity getAffinityForSelection(DocumentSelection selection) { return getAffinityBetween(base: selection.base, extent: selection.extent); } + TextAffinity getAffinityForRange(DocumentRange range) { + return getAffinityBetween(base: range.start, extent: range.end); + } + + TextAffinity getAffinityBetweenNodes(DocumentNode base, DocumentNode extent) { + return getAffinityForSelection( + DocumentSelection( + base: DocumentPosition( + nodeId: base.id, + nodePosition: base.beginningPosition, + ), + extent: DocumentPosition( + nodeId: extent.id, + nodePosition: extent.beginningPosition, + ), + ), + ); + } + /// Returns the affinity direction implied by the given [base] and [extent]. // TODO: Replace TextAffinity with a DocumentAffinity to avoid confusion. TextAffinity getAffinityBetween({ @@ -223,19 +358,17 @@ extension InspectDocumentAffinity on Document { if (baseNode == null) { throw Exception('No such position in document: $base'); } - final baseIndex = getNodeIndexById(baseNode.id); final extentNode = getNode(extent); if (extentNode == null) { throw Exception('No such position in document: $extent'); } - final extentIndex = getNodeIndexById(extentNode.id); late TextAffinity affinity; - if (extentIndex > baseIndex) { - affinity = TextAffinity.downstream; - } else if (extentIndex < baseIndex) { - affinity = TextAffinity.upstream; + if (base.nodeId != extent.nodeId) { + affinity = getNodeIndexById(base.nodeId) < getNodeIndexById(extent.nodeId) + ? TextAffinity.downstream + : TextAffinity.upstream; } else { // The selection is within the same node. Ask the node which position // comes first. @@ -246,35 +379,41 @@ extension InspectDocumentAffinity on Document { } } +extension InspectDocumentRange on Document { + /// Returns a [DocumentRange] that ranges from [position1] to [position2], + /// including [position1] and [position2]. + DocumentRange getRangeBetween(DocumentPosition position1, DocumentPosition position2) { + late TextAffinity affinity = getAffinityBetween(base: position1, extent: position2); + return DocumentRange( + start: affinity == TextAffinity.downstream ? position1 : position2, + end: affinity == TextAffinity.downstream ? position2 : position1, + ); + } +} + extension InspectDocumentSelection on Document { /// Returns a list of all the `DocumentNodes` within the given [selection], ordered /// from upstream to downstream. List getNodesInContentOrder(DocumentSelection selection) { final upstreamPosition = selectUpstreamPosition(selection.base, selection.extent); - final upstreamIndex = getNodeIndexById(upstreamPosition.nodeId); final downstreamPosition = selectDownstreamPosition(selection.base, selection.extent); - final downstreamIndex = getNodeIndexById(downstreamPosition.nodeId); - return nodes.sublist(upstreamIndex, downstreamIndex + 1); + return getNodesInside(upstreamPosition, downstreamPosition); } /// Given [docPosition1] and [docPosition2], returns the `DocumentPosition` that /// appears first in the document. DocumentPosition selectUpstreamPosition(DocumentPosition docPosition1, DocumentPosition docPosition2) { - final docPosition1Node = getNodeById(docPosition1.nodeId)!; - final docPosition1NodeIndex = getNodeIndexById(docPosition1Node.id); - final docPosition2Node = getNodeById(docPosition2.nodeId)!; - final docPosition2NodeIndex = getNodeIndexById(docPosition2Node.id); - - if (docPosition1NodeIndex < docPosition2NodeIndex) { - return docPosition1; - } else if (docPosition2NodeIndex < docPosition1NodeIndex) { - return docPosition2; + if (docPosition1.nodeId != docPosition2.nodeId) { + final affinity = getAffinityBetween(base: docPosition1, extent: docPosition2); + return affinity == TextAffinity.downstream // + ? docPosition1 + : docPosition2; } // Both document positions are in the same node. Figure out which // node position comes first. - final theNode = docPosition1Node; + final theNode = getNodeById(docPosition1.nodeId)!; return theNode.selectUpstreamPosition(docPosition1.nodePosition, docPosition2.nodePosition) == docPosition1.nodePosition ? docPosition1 @@ -295,62 +434,18 @@ extension InspectDocumentSelection on Document { return false; } - final baseNode = getNodeById(selection.base.nodeId)!; - final baseNodeIndex = getNodeIndexById(baseNode.id); - final extentNode = getNodeById(selection.extent.nodeId)!; - final extentNodeIndex = getNodeIndexById(extentNode.id); - - final upstreamNode = baseNodeIndex < extentNodeIndex ? baseNode : extentNode; - final upstreamNodeIndex = baseNodeIndex < extentNodeIndex ? baseNodeIndex : extentNodeIndex; - final downstreamNode = baseNodeIndex < extentNodeIndex ? extentNode : baseNode; - final downstreamNodeIndex = baseNodeIndex < extentNodeIndex ? extentNodeIndex : baseNodeIndex; - - final positionNodeIndex = getNodeIndexById(position.nodeId); - - if (upstreamNodeIndex < positionNodeIndex && positionNodeIndex < downstreamNodeIndex) { - // The given position is sandwiched between two other nodes that form - // the bounds of the selection. Therefore, the position is definitely within - // the selection. - return true; - } - - if (positionNodeIndex == upstreamNodeIndex) { - final upstreamPosition = selectUpstreamPosition(selection.base, selection.extent); - final downstreamPosition = upstreamPosition == selection.base ? selection.extent : selection.base; - - // This is the furthest a position could sit in the upstream node - // and still contain the given position. Keep in mind that the - // upstream position, downstream position, and given position may - // all reside in the same node (in fact, they probably do). - final downstreamCap = - upstreamNodeIndex == downstreamNodeIndex ? downstreamPosition.nodePosition : upstreamNode.endPosition; - - // If and only if the given position comes after the upstream position, - // and before the downstream cap, then the position is within the selection. - return upstreamNode.selectDownstreamPosition(upstreamPosition.nodePosition, position.nodePosition) == - upstreamNode.selectUpstreamPosition(position.nodePosition, downstreamCap); - } - - if (positionNodeIndex == downstreamNodeIndex) { - final upstreamPosition = selectUpstreamPosition(selection.base, selection.extent); - final downstreamPosition = upstreamPosition == selection.base ? selection.extent : selection.base; - - // This is the furthest upstream that a position could sit in the - // downstream node and still contain the given position. Keep in - // mind that the upstream position, downstream position, and given - // position may all reside in the same node (in fact, they probably do). - final upstreamCap = - downstreamNodeIndex == upstreamNodeIndex ? upstreamPosition.nodePosition : downstreamNode.beginningPosition; - - // If and only if the given position comes before the downstream position, - // and after the upstream cap, then the position is within the selection. - return downstreamNode.selectDownstreamPosition(upstreamCap, position.nodePosition) == - downstreamNode.selectUpstreamPosition(position.nodePosition, downstreamPosition.nodePosition); - } - - // If we got here, then the position is either before the upstream - // selection boundary, or after the downstream selection boundary. - // Either way, the position is not in the selection. - return false; + final selectionAffinity = getAffinityForSelection(selection); + final upstreamPosition = selectionAffinity == TextAffinity.downstream ? selection.base : selection.extent; + final downstreamPosition = selectionAffinity == TextAffinity.downstream ? selection.extent : selection.base; + + // The selection contains the position if the ordering is as follows: + // + // selection start <= position <= selection end + // + // Another way of stating this relationship is that there's a downstream + // affinity from selection start to the position, and from the position to + // the selection end. + return getAffinityBetween(base: upstreamPosition, extent: position) == TextAffinity.downstream && + getAffinityBetween(base: position, extent: downstreamPosition) == TextAffinity.downstream; } } diff --git a/super_editor/lib/src/core/edit_context.dart b/super_editor/lib/src/core/edit_context.dart index 8ba1785165..77cb03ff77 100644 --- a/super_editor/lib/src/core/edit_context.dart +++ b/super_editor/lib/src/core/edit_context.dart @@ -1,32 +1,41 @@ -import 'package:super_editor/src/core/document.dart'; +import 'package:flutter/widgets.dart'; import 'package:super_editor/src/default_editor/common_editor_operations.dart'; +import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; +import 'document.dart'; import 'document_composer.dart'; -import 'document_editor.dart'; import 'document_layout.dart'; +import 'editor.dart'; /// Collection of core artifacts used to edit a document. /// /// In particular, the context contains the [DocumentEditor], -/// [DocumentComposer], and [DocumentLayout]. +/// [DocumentComposer], [DocumentScroller] and [DocumentLayout]. /// In addition, [commonOps] are available for directly applying common, complex /// changes to the document using the artifacts. -class EditContext { +class SuperEditorContext { /// Creates an edit context that makes up a collection of core artifacts for /// editing a document. /// /// The [documentLayout] is passed as a [getDocumentLayout] callback that /// should return the current layout as it might change. - EditContext({ + SuperEditorContext({ + required this.editorFocusNode, required this.editor, + required this.document, required DocumentLayout Function() getDocumentLayout, required this.composer, + required this.scroller, required this.commonOps, }) : _getDocumentLayout = getDocumentLayout; + final FocusNode editorFocusNode; + /// The editor of the [Document] that allows executing commands that alter the /// structure of the document. - final DocumentEditor editor; + final Editor editor; + + final Document document; /// The document layout that is a visual representation of the document. /// @@ -38,6 +47,10 @@ class EditContext { /// in conjunction with the [editor] to apply changes to the document. final DocumentComposer composer; + /// The [DocumentScroller] that provides status and control over [SuperEditor] + /// scrolling. + final DocumentScroller scroller; + /// Common operations that can be executed to apply common, complex changes to /// the document. final CommonEditorOperations commonOps; diff --git a/super_editor/lib/src/core/editor.dart b/super_editor/lib/src/core/editor.dart new file mode 100644 index 0000000000..59067773a2 --- /dev/null +++ b/super_editor/lib/src/core/editor.dart @@ -0,0 +1,1442 @@ +import 'dart:math'; + +import 'package:attributed_text/attributed_text.dart'; +import 'package:clock/clock.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:uuid/uuid.dart'; + +/// Editor for a document editing experience. +/// +/// An [Editor] is the entry point for all mutations within a document editing experience. +/// Such changes might impact a [Document], [DocumentComposer], and any other relevant objects +/// or data structures associated with a document editing experience. +/// +/// The following artifacts are involved with making changes to pieces of a document editing +/// experience: +/// +/// - [EditRequest] - a desired change. +/// - [EditCommand] - mutates [Editable]s to achieve a change. +/// - [EditEvent] - describes a change that was made. +/// - [EditReaction] - (optionally) requests more changes after some original change. +/// - [EditListener] - is notified of all changes made by an [Editor]. +class Editor implements RequestDispatcher { + static const Uuid _uuid = Uuid(); + + /// Service locator key to obtain a [Document] from [find], if a [Document] + /// is available in the [EditContext]. + static const documentKey = "document"; + + /// Service locator key to obtain a [DocumentComposer] from [find], if a + /// [DocumentComposer] is available in the [EditContext]. + static const composerKey = "composer"; + + /// Service locator key to obtain a [DocumentLayoutEditable] from [find], if + /// a [DocumentLayoutEditable] is available in the [EditContext]. + static const layoutKey = "layout"; + + /// Generates a new ID for a [DocumentNode]. + /// + /// Each generated node ID is universally unique. + static String createNodeId() => _uuid.v4(); + + /// Constructs an [Editor] with: + /// - [editables], which contains all artifacts that will be mutated by [EditCommand]s, such + /// as a [Document] and [DocumentComposer]. + /// - [requestHandlers], which map each [EditRequest] to an [EditCommand]. + /// - [reactionPipeline], which contains all possible [EditReaction]s in the order that they will + /// react. + /// - [listeners], which contains an initial set of [EditListener]s. + Editor({ + required Map editables, + List? requestHandlers, + this.historyGroupingPolicy = neverMergePolicy, + List? reactionPipeline, + List? listeners, + this.isHistoryEnabled = false, + }) : requestHandlers = requestHandlers ?? [], + reactionPipeline = reactionPipeline ?? [], + _changeListeners = listeners ?? [] { + context = EditContext(editables); + _commandExecutor = _DocumentEditorCommandExecutor(context); + } + + void dispose() { + reactionPipeline.clear(); + _changeListeners.clear(); + } + + /// Chain of Responsibility that maps a given [EditRequest] to an [EditCommand]. + final List requestHandlers; + + /// Service Locator that provides all resources that are relevant for document editing. + late final EditContext context; + + /// Whether history tracking (and undo/redo) are enabled. + /// + /// When [isHistoryEnabled] is `false`, undo/redo is disabled, calling [undo] and [redo] + /// will have no effect, and [history] and [future] are always empty. + final bool isHistoryEnabled; + + /// Policies that determine when a new transaction of changes should be combined with the + /// previous transaction, impacting what is undone by undo. + final HistoryGroupingPolicy historyGroupingPolicy; + + /// Executes [EditCommand]s and collects a list of changes. + late final _DocumentEditorCommandExecutor _commandExecutor; + + /// A list of editor transactions that were run previously, leading to the current + /// state of the document, and other editables. + List get history => List.from(_history); + final _history = []; + + /// A list of editor transactions that were undone since the last time a change was + /// made. + /// + /// As soon as a new change is made through the editor, the [future] list is cleared + /// out, because the editor no longer knows if the [future] changes can be applied + /// to the document and other editables. + List get future => List.from(_future); + final _future = []; + + /// A pipeline of objects that receive change-lists from command execution + /// and get the first opportunity to spawn additional commands before the + /// change list is dispatched to regular listeners. + final List reactionPipeline; + + /// Listeners that are notified of changes in the form of a change list + /// after all pending [EditCommand]s are executed, and all members of + /// the reaction pipeline are done reacting. + final List _changeListeners; + + /// Adds a [listener], which is notified of each series of [EditEvent]s + /// after a batch of [EditCommand]s complete. + /// + /// Listeners are held and called as a list because some listeners might need + /// to be notified ahead of others. Generally, you should avoid that complexity, + /// if possible, but sometimes its relevant. For example, by default, the + /// [Document] is the highest priority listener that's registered with this + /// [Editor]. That's because document structure is central to everything + /// else, and therefore, we don't want other parts of the system being notified + /// about changes, before the [Document], itself. + void addListener(EditListener listener, {int? index}) { + if (index != null) { + _changeListeners.insert(index, listener); + } else { + _changeListeners.add(listener); + } + } + + /// Removes a [listener] from the set of change listeners. + void removeListener(EditListener listener) { + _changeListeners.remove(listener); + } + + /// An accumulation of changes during the current execution stack. + /// + /// This list is tracked in local state to facilitate reactions. When Reaction 1 runs, + /// it might submit another request, which adds more changes. When Reaction 2 runs, that + /// reaction needs to know about the original change list, plus all changes caused by + /// Reaction 1. This list lives across multiple request executions to make that possible. + List? _activeChangeList; + + /// Tracks the number of request executions that are in the process of running. + int _activeCommandCount = 0; + + bool _isInTransaction = false; + bool _isImplicitTransaction = false; + CommandTransaction? _transaction; + + /// Whether the editor is currently running reactions for the current transaction. + bool _isReacting = false; + + /// Starts a transaction that runs across multiple calls to [execute], until [endTransaction] + /// is called. + /// + /// Typically, a transaction only includes the [EditRequest]s that are passed to a single + /// call to [execute]. That's useful in the nominal case where editing code knows everything + /// that needs to execute at one time. However, sometimes the later [EditRequest] within a + /// single transaction can't be configured until the editing code inspects the [Document] + /// after some earlier [EditRequest]. In this situation, the editing code needs to be able + /// to run [execute] multiple times while having all [EditRequest]s still considered to be + /// part of the same transaction. + /// + /// Does nothing if a transaction is already in-progress. + void startTransaction() { + if (_isInTransaction) { + return; + } + + editorEditsLog.info("Starting transaction"); + _isInTransaction = true; + _activeChangeList = []; + _transaction = CommandTransaction([], clock.now()); + + _onTransactionStart(); + } + + /// Ends a transaction that was started with a call to [startTransaction]. + /// + /// Does nothing if a transaction is not in-progress. + void endTransaction() { + if (!_isInTransaction) { + return; + } + + assert( + _activeChangeList != null, + "Tried to end a transaction but the active change list is null. It should never be null during a transaction.", + ); + + if (_transaction!.commands.isNotEmpty && isHistoryEnabled) { + if (_history.isEmpty) { + // Add this transaction onto the history stack. + _history.add(_transaction!); + } else { + final mergeChoice = historyGroupingPolicy.shouldMergeLatestTransaction(_transaction!, _history.last); + switch (mergeChoice) { + case TransactionMerge.noOpinion: + case TransactionMerge.doNotMerge: + // Don't alter the transaction history, just add the new transaction to the history. + _history.add(_transaction!); + case TransactionMerge.mergeOnTop: + // Merge this transaction with the transaction just before it. This is used, for example, + // to group repeated text input into a single undoable transaction. + _history.last + ..commands.addAll(_transaction!.commands) + ..changes.addAll(_transaction!.changes) + ..lastChangeTime = clock.now(); + case TransactionMerge.replacePrevious: + // Replaces the most recent transaction with the new transaction. This is used, for example, + // to throw away unnecessary history about selection and composing region changes, for which + // only the most recent value is relevant. + _history + ..removeLast() + ..add(_transaction!); + } + } + } + + // Now that an atomic set of changes have completed, let the reactions followup + // with more changes, such as auto-correction, tagging, etc. + _reactToChanges(); + + _isInTransaction = false; + _isImplicitTransaction = false; + _transaction = null; + _activeCommandCount = 0; + + // Note: We need to pass the changes to the `Editable`s when we report the end of + // a transaction, but we also need to null out the active change list before we + // do that, because the transaction is officially over. We hold on to the active + // list locally, and then null out the shared active list. + final changeList = _activeChangeList!; + _activeChangeList = null; + + for (final editable in context._resources.values) { + editable.onTransactionEnd(changeList); + } + + editorEditsLog.info("Finished transaction"); + } + + /// Executes the given [requests]. + /// + /// Any changes that result from the given [requests] are reported to listeners as a series + /// of [EditEvent]s. + @override + void execute(List requests) { + if (requests.isEmpty) { + // No changes were requested. Don't waste time starting and ending transactions, etc. + editorEditsLog.warning("Tried to execute requests without providing any requests"); + return; + } + + editorEditsLog.finer("Executing requests:"); + for (final request in requests) { + editorEditsLog.finer(" - ${request.runtimeType}"); + } + + if (_activeCommandCount == 0 && !_isInTransaction) { + // No transaction was explicitly requested, but all changes exist in a transaction. + // Automatically start one, and then end the transaction after the current changes. + _isImplicitTransaction = true; + startTransaction(); + } + + _activeCommandCount += 1; + + final undoableCommands = []; + for (final request in requests) { + // Execute the given request. + final command = _findCommandForRequest(request); + final commandChanges = _executeCommand(command); + _activeChangeList!.addAll(commandChanges); + + if (command.historyBehavior == HistoryBehavior.undoable) { + undoableCommands.add(command); + _transaction!.changes.addAll(List.from(commandChanges)); + } + } + + // Log the time at the end of the actions in this transaction. + _transaction!.lastChangeTime = clock.now(); + + if (undoableCommands.isNotEmpty) { + _transaction!.commands.addAll(undoableCommands); + } + + if (_activeCommandCount == 1 && _isImplicitTransaction && !_isReacting) { + endTransaction(); + } else { + _activeCommandCount -= 1; + } + } + + EditCommand _findCommandForRequest(EditRequest request) { + EditCommand? command; + for (final handler in requestHandlers) { + command = handler(this, request); + if (command != null) { + return command; + } + } + + throw Exception( + "Could not handle EditorRequest. DocumentEditor doesn't have a handler that recognizes the request: $request"); + } + + List _executeCommand(EditCommand command) { + // Execute the given command, and any other commands that it spawns. + _commandExecutor.executeCommand(command); + + // Collect all the changes from the executed commands. + // + // We make a copy of the change-list so that asynchronous listeners + // don't lose the contents when we clear it. + final changeList = _commandExecutor.copyChangeList(); + + // TODO: we could run the reactions here. Do we give them all a single chance + // to respond? Or do we keep running them until there aren't any further + // changes? + + // Reset the command executor so that it's ready for the next command + // that comes in. + _commandExecutor.reset(); + + return changeList; + } + + void _onTransactionStart() { + for (final editable in context._resources.values) { + editable.onTransactionStart(); + } + } + + void _reactToChanges() { + if (_activeChangeList!.isEmpty) { + return; + } + + _isReacting = true; + + // First, let reactions modify the content of the active transaction. + for (final reaction in reactionPipeline) { + // Note: we pass the active change list because reactions will cause more + // changes to be added to that list. + reaction.modifyContent(context, this, _activeChangeList!); + } + + // Second, start a new transaction and let reactions add separate changes. + // ignore: prefer_const_constructors + _transaction = CommandTransaction([], clock.now()); + for (final reaction in reactionPipeline) { + // Note: we pass the active change list because reactions will cause more + // changes to be added to that list. + reaction.react(context, this, _activeChangeList!); + } + + if (_transaction!.commands.isNotEmpty && isHistoryEnabled) { + _history.add(_transaction!); + } + + // FIXME: try removing this notify listeners + // Notify all listeners that care about changes, but won't spawn additional requests. + _notifyListeners(List.from(_activeChangeList!, growable: false)); + + _isReacting = false; + } + + void undo() { + if (!isHistoryEnabled) { + // History is disabled, therefore undo/redo are disabled. + return; + } + + editorEditsLog.info("Running undo"); + if (_history.isEmpty) { + return; + } + + editorEditsLog.finer("History before undo:"); + for (final transaction in _history) { + editorEditsLog.finer(" - transaction"); + for (final command in transaction.commands) { + editorEditsLog.finer(" - ${command.runtimeType}: ${command.describe()}"); + } + } + editorEditsLog.finer("---"); + + // Move the latest command from the history to the future. + final transactionToUndo = _history.removeLast(); + _future.add(transactionToUndo); + editorEditsLog.finer("The commands being undone are:"); + for (final command in transactionToUndo.commands) { + editorEditsLog.finer(" - ${command.runtimeType}: ${command.describe()}"); + } + + editorEditsLog.finer("Resetting all editables to their last checkpoint..."); + for (final editable in context._resources.values) { + // Don't let editables notify listeners during undo. + editable.onTransactionStart(); + + // Revert all editables to the last snapshot. + editable.reset(); + } + + // Replay all history except for the most recent command transaction. + editorEditsLog.finer("Replaying all command history except for the most recent transaction..."); + final changeEvents = []; + for (final commandTransaction in _history) { + for (final command in commandTransaction.commands) { + // We re-run the commands without tracking changes and without running reactions + // because any relevant reactions should have run the first time around, and already + // submitted their commands. + final commandChanges = _executeCommand(command); + changeEvents.addAll(commandChanges); + } + } + + editorEditsLog.info("Finished undo"); + + editorEditsLog.finer("Ending transaction on all editables"); + for (final editable in context._resources.values) { + // Let editables start notifying listeners again. + editable.onTransactionEnd(changeEvents); + } + + // TODO: find out why this is needed. If it's not, remove it. + _notifyListeners([]); + } + + void redo() { + if (!isHistoryEnabled) { + // History is disabled, therefore undo/redo are disabled. + return; + } + + editorEditsLog.info("Running redo"); + if (_future.isEmpty) { + return; + } + + editorEditsLog.finer("Future transaction:"); + for (final command in _future.last.commands) { + editorEditsLog.finer(" - ${command.runtimeType}"); + } + + for (final editable in context._resources.values) { + // Don't let editables notify listeners during redo. + editable.onTransactionStart(); + } + + final commandTransaction = _future.removeLast(); + final edits = []; + for (final command in commandTransaction.commands) { + final commandEdits = _executeCommand(command); + edits.addAll(commandEdits); + } + _history.add(commandTransaction); + + editorEditsLog.info("Finished redo"); + + editorEditsLog.finer("Ending transaction on all editables"); + for (final editable in context._resources.values) { + // Let editables start notifying listeners again. + editable.onTransactionEnd(edits); + } + + // TODO: find out why this is needed. If it's not, remove it. + _notifyListeners([]); + } + + void _notifyListeners(List changeList) { + for (final listener in _changeListeners) { + // Note: we pass a given copy of the change list, because listeners should + // never cause additional editor changes. + listener.onEdit(changeList); + } + } +} + +/// The merge policies that are used in the standard [Editor] construction. +const defaultMergePolicy = HistoryGroupingPolicyList( + [ + mergeRepeatSelectionChangesPolicy, + mergeRapidTextInputPolicy, + ], +); + +abstract interface class HistoryGroupingPolicy { + TransactionMerge shouldMergeLatestTransaction( + CommandTransaction newTransaction, + CommandTransaction previousTransaction, + ); +} + +enum TransactionMerge { + noOpinion, + doNotMerge, + mergeOnTop, + replacePrevious; + + static TransactionMerge chooseMoreConservative(TransactionMerge a, TransactionMerge b) { + if (a == b) { + // They're the same. It doesn't matter. + return a; + } + + switch (a) { + case TransactionMerge.noOpinion: + // No opinion has no particular conservative vs liberal metric. Return the other one. + return b; + case TransactionMerge.doNotMerge: + // Explicitly not merging is the most conservative. Return this one. + return a; + case TransactionMerge.mergeOnTop: + if (b == TransactionMerge.doNotMerge) { + // doNotMerge is the only more conservative choice than merging on top. + return b; + } + + return a; + case TransactionMerge.replacePrevious: + if (b == TransactionMerge.noOpinion) { + return a; + } + + // replacePrevious is the lease conservative option. The other one always wins. + return b; + } + } +} + +/// A [HistoryGroupingPolicy] that defers to a list of other individual policies. +/// +/// For most applications, an [Editor]'s transaction grouping policy should probably be +/// a [HistoryGroupingPolicyList] because most applications will want a number of different +/// heuristics that decide when to merge transactions. +/// +/// You can change the way the list of policies make a decision by way of the [choice] +/// property. You can either merge transactions when *any* of the policies want to merge +/// ([HistoryGroupingPolicyListChoice.anyPass]), or you can merge transactions when *all* +/// of the policies want to merge ([HistoryGroupingPolicyListChoice.allPass]). +class HistoryGroupingPolicyList implements HistoryGroupingPolicy { + const HistoryGroupingPolicyList(this.policies); + + final List policies; + + @override + TransactionMerge shouldMergeLatestTransaction( + CommandTransaction newTransaction, + CommandTransaction previousTransaction, + ) { + TransactionMerge mostConservativeChoice = TransactionMerge.noOpinion; + + for (final policy in policies) { + final newMergeChoice = policy.shouldMergeLatestTransaction(newTransaction, previousTransaction); + if (newMergeChoice == TransactionMerge.doNotMerge) { + // A policy has explicitly requested not to merge. Don't merge. + return TransactionMerge.doNotMerge; + } + + mostConservativeChoice = TransactionMerge.chooseMoreConservative(mostConservativeChoice, newMergeChoice); + } + + return mostConservativeChoice; + } +} + +const neverMergePolicy = _NeverMergePolicy(); + +class _NeverMergePolicy implements HistoryGroupingPolicy { + const _NeverMergePolicy(); + + @override + TransactionMerge shouldMergeLatestTransaction( + CommandTransaction newTransaction, CommandTransaction previousTransaction) => + TransactionMerge.doNotMerge; +} + +const mergeRepeatSelectionChangesPolicy = MergeRepeatSelectionChangesPolicy(); + +class MergeRepeatSelectionChangesPolicy implements HistoryGroupingPolicy { + const MergeRepeatSelectionChangesPolicy(); + + @override + TransactionMerge shouldMergeLatestTransaction( + CommandTransaction newTransaction, CommandTransaction previousTransaction) { + final isNewTransactionAllSelectionAndComposing = newTransaction.changes + .where((change) => change is! SelectionChangeEvent && change is! ComposingRegionChangeEvent) + .isEmpty; + + if (!isNewTransactionAllSelectionAndComposing) { + // The new transaction contains meaningful changes. Let other policies decide + // what to do. + return TransactionMerge.noOpinion; + } + + final isPreviousTransactionAllSelectionAndComposing = previousTransaction.changes + .where((change) => change is! SelectionChangeEvent && change is! ComposingRegionChangeEvent) + .isEmpty; + + if (!isPreviousTransactionAllSelectionAndComposing) { + // The previous transaction contains meaningful changes. Add the new selection/composing + // changes on top so that they're undone with the previous content change. + return TransactionMerge.mergeOnTop; + } + + // The previous and new transactions are all selection and composing changes. We don't + // care about this history. Replaces the previous transaction with the new transaction. + return TransactionMerge.replacePrevious; + } +} + +/// A sane default configuration of a [MergeRapidTextInputPolicy]. +/// +/// To customize the merge time, create a [MergeRapidTextInputPolicy] with the desired merge time. +const mergeRapidTextInputPolicy = MergeRapidTextInputPolicy(); + +class MergeRapidTextInputPolicy implements HistoryGroupingPolicy { + const MergeRapidTextInputPolicy([this._maxMergeTime = const Duration(milliseconds: 100)]); + + final Duration _maxMergeTime; + + @override + TransactionMerge shouldMergeLatestTransaction( + CommandTransaction newTransaction, CommandTransaction previousTransaction) { + final newContentEvents = newTransaction.changes + .where((change) => change is! SelectionChangeEvent && change is! ComposingRegionChangeEvent) + .toList(); + if (newContentEvents.isEmpty) { + return TransactionMerge.noOpinion; + } + final newTextInsertionEvents = + newContentEvents.where((change) => change is DocumentEdit && change.change is TextInsertionEvent).toList(); + if (newTextInsertionEvents.length != newContentEvents.length) { + // There were 1+ new content changes that weren't text input. Don't merge transactions. + return TransactionMerge.noOpinion; + } + + // At this point we know that all new content changes were text input. + + // Check that the previous transaction was also all text input. + final previousContentEvents = previousTransaction.changes + .where((change) => change is! SelectionChangeEvent && change is! ComposingRegionChangeEvent) + .toList(); + if (previousContentEvents.isEmpty) { + return TransactionMerge.noOpinion; + } + final previousTextInsertionEvents = + previousContentEvents.where((change) => change is DocumentEdit && change.change is TextInsertionEvent).toList(); + if (previousTextInsertionEvents.length != previousContentEvents.length) { + // There were 1+ new content changes that weren't text input. Don't merge transactions. + return TransactionMerge.noOpinion; + } + + if (newTransaction.firstChangeTime.difference(previousTransaction.lastChangeTime) > _maxMergeTime) { + // The text insertions were far enough apart in time that we don't want to merge them. + return TransactionMerge.noOpinion; + } + + // The new and previous transactions were entirely text input. They happened quickly. + // Merge them together. + return TransactionMerge.mergeOnTop; + } +} + +class CommandTransaction { + CommandTransaction(this.commands, this.firstChangeTime) + : changes = [], + lastChangeTime = firstChangeTime; + + final List commands; + final List changes; + + final DateTime firstChangeTime; + DateTime lastChangeTime; +} + +/// An implementation of [CommandExecutor], designed for [Editor]. +class _DocumentEditorCommandExecutor implements CommandExecutor { + _DocumentEditorCommandExecutor(this._context); + + final EditContext _context; + + final _commandsBeingProcessed = EditorCommandQueue(); + + final _changeList = []; + List copyChangeList() => List.from(_changeList); + + @override + void executeCommand(EditCommand command) { + _commandsBeingProcessed.append(command); + + // Run the given command, and any other commands that it spawns. + while (_commandsBeingProcessed.hasCommands) { + _commandsBeingProcessed.prepareForExecution(); + + final command = _commandsBeingProcessed.activeCommand!; + command.execute(_context, this); + + _commandsBeingProcessed.onCommandExecutionComplete(); + } + } + + @override + void prependCommand(EditCommand command) { + _commandsBeingProcessed.prepend(command); + } + + @override + void appendCommand(EditCommand command) { + _commandsBeingProcessed.append(command); + } + + @override + void logChanges(List changes) { + _changeList.addAll(changes); + } + + void reset() { + _changeList.clear(); + } +} + +/// An artifact that might be mutated during a request to a [Editor]. +abstract mixin class Editable { + /// A [Editor] transaction just started, this [Editable] should avoid notifying + /// any listeners of changes until the transaction ends. + void onTransactionStart() {} + + /// A transaction that was previously started with [onTransactionStart] has now ended, this + /// [Editable] should notify interested parties of changes. + void onTransactionEnd(List edits) {} + + // /// Creates and returns a snapshot of this [Editable]'s current state, or `null` if + // /// this [Editable] is in its initial state. + // /// + // /// The returned snapshot must be a deep copy of any relevant information. It must not + // /// hold any references to data outside the snapshot. + // Object? createSnapshot(); + // + // /// Updates the state of this [Editable] to match the given [snapshot]. + // void restoreSnapshot(Object snapshot); + + /// Resets this [Editable] to its initial state. + void reset() {} +} + +/// An object that processes [EditRequest]s. +abstract class RequestDispatcher { + /// Pushes the given [requests] through a [Editor] pipeline. + void execute(List requests); +} + +/// A command that alters something in a [Editor]. +abstract class EditCommand { + const EditCommand(); + + /// Executes this command and logs all changes with the [executor]. + void execute(EditContext context, CommandExecutor executor); + + /// The desired "undo" behavior of this command. + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + String describe() => toString(); +} + +/// The way a command interacts with the history ledger, AKA "undo". +enum HistoryBehavior { + /// The command can be undone and redone. + /// + /// For example: inserting text into a paragraph. + undoable, + + /// The command has no impact on history. + /// + /// For example: entering and exiting interaction mode, (possibly) activating and + /// deactivating bold/italics in the composer. + nonHistorical, +} + +/// All resources that are available when executing [EditCommand]s, such as a document, +/// composer, etc. +class EditContext { + EditContext(this._resources); + + final Map _resources; + + /// Finds an object of type [T] within this [EditContext], which is identified by the given [id]. + T find(String id) { + if (!_resources.containsKey(id)) { + editorLog.shout("Tried to find an editor resource for the ID '$id', but there's no resource with that ID."); + throw Exception("Tried to find an editor resource for the ID '$id', but there's no resource with that ID."); + } + if (_resources[id] is! T) { + editorLog.shout( + "Tried to find an editor resource of type '$T' for ID '$id', but the resource with that ID is of type '${_resources[id].runtimeType}"); + throw Exception( + "Tried to find an editor resource of type '$T' for ID '$id', but the resource with that ID is of type '${_resources[id].runtimeType}"); + } + + return _resources[id] as T; + } + + /// Finds an object of type [T] within this [EditContext], which is identified by the given [id], or + /// returns `null` if no such object is in this [EditContext]. + T? findMaybe(String id) { + if (_resources[id] == null) { + return null; + } + + if (_resources[id] is! T) { + editorLog.shout( + "Tried to find an editor resource of type '$T' for ID '$id', but the resource with that ID is of type '${_resources[id].runtimeType}"); + throw Exception( + "Tried to find an editor resource of type '$T' for ID '$id', but the resource with that ID is of type '${_resources[id].runtimeType}"); + } + + return _resources[id] as T; + } + + /// Makes the given [editable] available as a resource under the given [id]. + void put(String id, Editable editable) => _resources[id] = editable; + + /// Removes the given [editable] resource in this context with the given [id]. + /// + /// The specific [editable] is needed so that plugins which are attached and detached + /// in quick succession, typically due to widget subtree replacements, don't accidentally + /// remove an [Editable] that was just added by a newly attached version of the same plugin. + /// + /// As of Nov, 2025, the [editable] is optional so as to avoid a wide-spread breaking + /// change. Eventually this [editable] will be required. This method prints a warning + /// if the [editable] isn't provided. Eventually we'll switch the warning to an `assert()`, + /// and then finally we'll make the [editable] a requirement. + void remove(String id, [Editable? editable]) { + if (editable == null) { + if (kDebugMode) { + print( + "WARNING: A change was made to EditContext.remove(). You should now pass the Editable you want to remove. This warning is here to help with migration before we make a breaking change. Update this now!"); + print("${StackTrace.current}"); + + // Eventually we'll use this assert. + // assert(editable != null, "WARNING: A change was made to EditContext.remove(). You should now pass the Editable you want to remove. This warning is here to help with migration before we make a breaking change. Update this now!"); + } + + _resources.remove(id); + } + + if (_resources[id] != editable) { + // The resource at this `id` isn't the expected `editable`. Fizzle. + return; + } + + // The resource at this `id` IS the expected `editable`. Remove it. + _resources.remove(id); + } +} + +/// Executes [EditCommand]s in the order in which they're queued. +/// +/// Each [EditCommand] is given access to this [CommandExecutor] during +/// the command's execution. Each [EditCommand] is expected to [logChanges] +/// with the given [CommandExecutor]. +abstract class CommandExecutor { + /// Immediately executes the given [command]. + /// + /// Client's can use this method to run an initial command, or to run + /// a sub-command in the middle of an active command. + void executeCommand(EditCommand command); + + /// Adds the given [command] to the beginning of the command queue, but + /// after any set of commands that are currently executing. + void prependCommand(EditCommand command); + + /// Adds the given [command] to the end of the command queue. + void appendCommand(EditCommand command); + + /// Log a series of document changes that were just made by the active command. + void logChanges(List changes); +} + +class EditorCommandQueue { + /// A command that's in the process of being executed. + EditCommand? _activeCommand; + + /// The command that's currently being executed, along with any commands + /// that the active command adds during execution. + final _activeCommandExpansionQueue = []; + + /// All commands waiting to be executed after [_activeCommandExpansionQueue]. + final _commandBacklog = []; + + bool get hasCommands => _commandBacklog.isNotEmpty; + + void prepareForExecution() { + assert(_activeCommandExpansionQueue.isEmpty, + "Tried to prepare for command execution but there are already commands in the active queue. Did you forget to call onCommandExecutionComplete?"); + + // Set the active command to the next command in the backlog. + _activeCommand = _commandBacklog.removeAt(0); + } + + EditCommand? get activeCommand => _activeCommand; + + void expandActiveCommand(List additionalCommands) { + _activeCommandExpansionQueue.addAll(additionalCommands); + } + + void onCommandExecutionComplete() { + // Now that the active command is done, move any expansion commands + // to the primary backlog. + _commandBacklog.insertAll(0, _activeCommandExpansionQueue); + _activeCommandExpansionQueue.clear(); + + // Clear the active command, now that its complete. + _activeCommand = null; + } + + /// Prepends the given [command] at the front of the execution queue. + void prepend(EditCommand command) { + _commandBacklog.insert(0, command); + } + + /// Appends the given [command] to the end of the execution queue. + void append(EditCommand command) { + _commandBacklog.add(command); + } +} + +/// Factory method that creates and returns an [EditCommand] that can handle +/// the given [EditRequest], or `null` if this handler doesn't apply to the given +/// [EditRequest]. +typedef EditRequestHandler = EditCommand? Function(Editor editor, EditRequest request); + +/// An action that a [Editor] should execute. +abstract class EditRequest { + // Marker interface for all editor request types. +} + +/// A change that took place within a [Editor]. +abstract class EditEvent { + const EditEvent(); + + /// Describes this change in a human-readable way. + String describe() => toString(); +} + +/// An [EditEvent] that altered a [Document]. +/// +/// The specific [Document] change is available in [change]. +class DocumentEdit extends EditEvent { + DocumentEdit(this.change); + + final DocumentChange change; + + @override + String describe() => change.describe(); + + @override + String toString() => "DocumentEdit -> $change"; + + @override + bool operator ==(Object other) => + identical(this, other) || other is DocumentEdit && runtimeType == other.runtimeType && change == other.change; + + @override + int get hashCode => change.hashCode; +} + +/// An object that's notified with a change list from one or more commands that were just +/// executed. +abstract class EditReaction { + const EditReaction(); + + /// Executes additional [modifications] within the current editor transaction. + /// + /// If undo is run, the recent changes AND the [modifications] will be undone, together. + /// This is useful, for example, for a reaction such as spell-check, whose reaction is + /// tied directly to the content and shouldn't stand on its own. + /// + /// To execute actions that are undone on their own, use [react]. + void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) {} + + /// Executes additional [actions] in a new standalone transaction. + /// + /// If undo is run, these changes will be undone, but the changes leading up to this + /// call to [react] will not be undone by that undo call. + /// + /// To execute additional actions that are undone at the same time as the preceding + /// changes, use [modifyContent]. + void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) {} +} + +/// An [EditReaction] that delegates its reaction to a given callback function. +class FunctionalEditReaction extends EditReaction { + FunctionalEditReaction({ + Reaction? modifyContent, + Reaction? react, + }) : _modifyContent = modifyContent, + _react = react, + assert(modifyContent != null || react != null); + + final Reaction? _modifyContent; + final Reaction? _react; + + @override + void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) => + _modifyContent?.call(editorContext, requestDispatcher, changeList); + + @override + void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) => + _react?.call(editorContext, requestDispatcher, changeList); +} + +typedef Reaction = void Function( + EditContext editorContext, RequestDispatcher requestDispatcher, List changeList); + +/// An object that's notified with a change list from one or more +/// commands that were just executed within a [Editor]. +/// +/// An [EditListener] can propagate secondary effects that are based on +/// editor changes. However, an [EditListener] shouldn't spawn additional +/// editor behaviors. This can result in infinite loops, back-and-forth changes, +/// and other undesirable effects. To spawn new [EditCommand]s based on a +/// [changeList], register an [EditReaction]. +abstract class EditListener { + void onEdit(List changeList); +} + +/// An [EditListener] that delegates to a callback function. +class FunctionalEditListener implements EditListener { + FunctionalEditListener(this._onEdit); + + final void Function(List changeList) _onEdit; + + @override + void onEdit(List changeList) => _onEdit(changeList); +} + +/// Extensions that provide direct, type-safe access to [Editable]s that are +/// expected to exist in all [Editor]s. +/// +/// This extension is similar to [StandardEditablesInContext], except this extension +/// operates on an [Editor] and the other operates on [EditContext]s. Both exist +/// for convenience. +extension StandardEditables on Editor { + /// Finds and returns the [MutableDocument] within the [Editor]. + MutableDocument get document => context.find(Editor.documentKey); + + /// Finds and returns the [MutableDocument] within the [Editor], or `null` if no [MutableDocument] + /// is in the [Editor]. + MutableDocument? get maybeDocument => context.findMaybe(Editor.documentKey); + + /// Finds and returns the [MutableDocumentComposer] within the [Editor]. + MutableDocumentComposer get composer => context.find(Editor.composerKey); + + /// Finds and returns the [MutableDocumentComposer] within the [Editor], or `null` if no + /// [MutableDocumentComposer] is in the [Editor]. + MutableDocumentComposer? get maybeComposer => context.findMaybe(Editor.composerKey); +} + +/// Extensions that provide direct, type-safe access to [Editable]s that are +/// expected to exist in all [EditContext]s. +/// +/// This extension is similar to [StandardEditables], except this extension +/// operates on an [EditContext] and the other operates on [Editor]s. Both exist +/// for convenience. +extension StandardEditablesInContext on EditContext { + /// Finds and returns the [MutableDocument] within the [EditContext]. + MutableDocument get document => find(Editor.documentKey); + + /// Finds and returns the [MutableDocument] within the [EditContext], or `null` if no [MutableDocument] + /// is in the [EditContext]. + MutableDocument? get maybeDocument => findMaybe(Editor.documentKey); + + /// Finds and returns the [MutableDocumentComposer] within the [EditContext]. + MutableDocumentComposer get composer => find(Editor.composerKey); + + /// Finds and returns the [MutableDocumentComposer] within the [EditContext], or `null` if no + /// [MutableDocumentComposer] is in the [EditContext]. + MutableDocumentComposer? get maybeComposer => findMaybe(Editor.composerKey); +} + +/// An in-memory, mutable [Document]. +class MutableDocument with Iterable implements Document, Editable { + /// Creates an in-memory, mutable version of a [Document]. + /// + /// Initializes the content of this [MutableDocument] with the given [nodes], + /// if provided, or empty content otherwise. + MutableDocument({ + List? nodes, + }) : _nodes = nodes ?? [] { + _refreshNodeIdCaches(); + + _latestNodesSnapshot = List.from(_nodes); + } + + /// Creates an [Document] with a single [ParagraphNode]. + /// + /// Optionally, takes in a [nodeId] for the [ParagraphNode]. + factory MutableDocument.empty([String? nodeId]) { + return MutableDocument( + nodes: [ + ParagraphNode( + id: nodeId ?? Editor.createNodeId(), + text: AttributedText(), + ), + ], + ); + } + + void dispose() { + _listeners.clear(); + } + + late final List _latestNodesSnapshot; + bool _didReset = false; + + final List _nodes; + + @override + int get nodeCount => _nodes.length; + + @override + bool get isEmpty => _nodes.isEmpty; + + /// Maps a node id to its index in the node list. + final Map _nodeIndicesById = {}; + + /// Maps a node id to its node. + final Map _nodesById = {}; + + final _listeners = []; + + @override + Iterator get iterator => _nodes.iterator; + + @override + DocumentNode? get firstOrNull => _nodes.firstOrNull; + + @override + DocumentNode? get lastOrNull => _nodes.lastOrNull; + + @override + DocumentNode? getNodeById(String nodeId) { + return _nodesById[nodeId]; + } + + @override + DocumentNode? getNodeAt(int index) { + if (index < 0 || index >= _nodes.length) { + return null; + } + + return _nodes[index]; + } + + @override + @Deprecated("Use getNodeIndexById() instead") + int getNodeIndex(DocumentNode node) { + final index = _nodeIndicesById[node.id] ?? -1; + if (index < 0) { + return -1; + } + + if (_nodes[index] != node) { + // We found a node by id, but it wasn't the node we expected. Therefore, we couldn't find the requested node. + return -1; + } + + return index; + } + + @override + int getNodeIndexById(String nodeId) { + return _nodeIndicesById[nodeId] ?? -1; + } + + @override + DocumentNode? getNodeBefore(DocumentNode node) { + return getNodeBeforeById(node.id); + } + + @override + DocumentNode? getNodeBeforeById(String nodeId) { + final nodeIndex = getNodeIndexById(nodeId); + return nodeIndex > 0 ? getNodeAt(nodeIndex - 1) : null; + } + + @override + DocumentNode? getNodeAfter(DocumentNode node) { + return getNodeAfterById(node.id); + } + + @override + DocumentNode? getNodeAfterById(String nodeId) { + final nodeIndex = getNodeIndexById(nodeId); + return nodeIndex >= 0 && nodeIndex < _nodes.length - 1 ? getNodeAt(nodeIndex + 1) : null; + } + + @override + DocumentNode? getNode(DocumentPosition position) => getNodeById(position.nodeId); + + @override + List getNodesInside(DocumentPosition position1, DocumentPosition position2) { + final node1 = getNode(position1); + if (node1 == null) { + throw Exception('No such position in document: $position1'); + } + final index1 = getNodeIndexById(node1.id); + + final node2 = getNode(position2); + if (node2 == null) { + throw Exception('No such position in document: $position2'); + } + final index2 = getNodeIndexById(node2.id); + + final from = min(index1, index2); + final to = max(index1, index2); + + return _nodes.sublist(from, to + 1); + } + + /// Inserts the given [node] into the [Document] at the given [index]. + void insertNodeAt(int index, DocumentNode node) { + if (index <= _nodes.length) { + _nodes.insert(index, node); + _refreshNodeIdCaches(); + } + } + + /// Inserts [newNode] immediately before the given [existingNode]. + void insertNodeBefore({ + required String existingNodeId, + required DocumentNode newNode, + }) { + final nodeIndex = getNodeIndexById(existingNodeId); + _nodes.insert(nodeIndex, newNode); + _refreshNodeIdCaches(); + } + + /// Inserts [newNode] immediately after the given [existingNode]. + void insertNodeAfter({ + required String existingNodeId, + required DocumentNode newNode, + }) { + final nodeIndex = getNodeIndexById(existingNodeId); + if (nodeIndex >= 0 && nodeIndex < _nodes.length) { + _nodes.insert(nodeIndex + 1, newNode); + _refreshNodeIdCaches(); + } + } + + /// Adds [node] to the end of the document. + void add(DocumentNode node) { + _nodes.insert(_nodes.length, node); + + // The node list changed, we need to update the map to consider the new indices. + _refreshNodeIdCaches(); + } + + /// Deletes the node at the given [index]. + void deleteNodeAt(int index) { + if (index >= 0 && index < _nodes.length) { + _nodes.removeAt(index); + _refreshNodeIdCaches(); + } else { + editorDocLog.warning('Could not delete node. Index out of range: $index'); + } + } + + /// Deletes the given [node] from the [Document]. + bool deleteNode(String nodeId) { + bool isRemoved = false; + + final index = getNodeIndexById(nodeId); + if (index < 0) { + return false; + } + + _nodes.removeAt(index); + _refreshNodeIdCaches(); + + return isRemoved; + } + + /// Deletes all nodes from the [Document]. + void clear() { + _nodes.clear(); + _refreshNodeIdCaches(); + } + + /// Moves a [DocumentNode] matching the given [nodeId] from its current index + /// in the [Document] to the given [targetIndex]. + /// + /// If none of the nodes in this document match [nodeId], throws an error. + void moveNode({required String nodeId, required int targetIndex}) { + final node = getNodeById(nodeId); + if (node == null) { + throw Exception('Could not find node with nodeId: $nodeId'); + } + + if (_nodes.remove(node)) { + _nodes.insert(targetIndex, node); + _refreshNodeIdCaches(); + } + } + + /// Replaces the given [oldNode] with the given [newNode] + @Deprecated("Use replaceNodeById() instead") + void replaceNode({ + required DocumentNode oldNode, + required DocumentNode newNode, + }) { + final index = _nodes.indexOf(oldNode); + + if (index >= 0) { + _nodes.removeAt(index); + _nodes.insert(index, newNode); + _refreshNodeIdCaches(); + } else { + throw Exception('Could not find oldNode: ${oldNode.id}'); + } + } + + /// Replaces the node with the given [nodeId] with the given [newNode]. + /// + /// Throws an exception if no node exists with the given [nodeId]. + void replaceNodeById( + String nodeId, + DocumentNode newNode, + ) { + final index = getNodeIndexById(nodeId); + + if (index >= 0) { + _nodes.removeAt(index); + _nodes.insert(index, newNode); + _refreshNodeIdCaches(); + } else { + throw Exception('Could not find node with ID: $nodeId'); + } + } + + /// Returns [true] if the content of the [other] [Document] is equivalent + /// to the content of this [Document]. + /// + /// Content equivalency compares types of content nodes, and the content + /// within them, like the text of a paragraph, but ignores node IDs and + /// ignores the runtime type of the [Document], itself. + @override + bool hasEquivalentContent(Document other) { + if (_nodes.length != other.nodeCount) { + return false; + } + if (isEmpty) { + // Both documents are empty, and therefore are equivalent. + return true; + } + + DocumentNode? thisDocNode = first; + DocumentNode? otherDocNode = other.first; + + while (thisDocNode != null && otherDocNode != null) { + if (!thisDocNode.hasEquivalentContent(otherDocNode)) { + return false; + } + + thisDocNode = getNodeAfter(thisDocNode); + otherDocNode = other.getNodeAfter(otherDocNode); + } + + return true; + } + + @override + void addListener(DocumentChangeListener listener) { + _listeners.add(listener); + } + + @override + void removeListener(DocumentChangeListener listener) { + _listeners.remove(listener); + } + + @override + void onTransactionStart() { + // no-op + } + + @override + void onTransactionEnd(List edits) { + final documentChanges = edits.whereType().map((edit) => edit.change).toList(); + if (documentChanges.isEmpty && !_didReset) { + return; + } + _didReset = false; + + final changeLog = DocumentChangeLog(documentChanges); + for (final listener in _listeners) { + listener(changeLog); + } + } + + @override + void reset() { + _nodes + ..clear() + ..addAll(_latestNodesSnapshot); + _refreshNodeIdCaches(); + + _didReset = true; + } + + /// Updates all the maps which use the node id as the key. + /// + /// All the maps are cleared and re-populated. + void _refreshNodeIdCaches() { + _nodeIndicesById.clear(); + _nodesById.clear(); + for (int i = 0; i < _nodes.length; i++) { + final node = _nodes[i]; + _nodeIndicesById[node.id] = i; + _nodesById[node.id] = node; + } + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MutableDocument && + runtimeType == other.runtimeType && + const DeepCollectionEquality().equals(_nodes, other._nodes); + + @override + int get hashCode => _nodes.hashCode; +} diff --git a/super_editor/lib/src/core/styles.dart b/super_editor/lib/src/core/styles.dart index c6a932ef9d..f6f88db56e 100644 --- a/super_editor/lib/src/core/styles.dart +++ b/super_editor/lib/src/core/styles.dart @@ -1,5 +1,7 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/painting.dart'; +import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; +import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'document.dart'; @@ -13,6 +15,8 @@ class Stylesheet { this.documentPadding, required this.rules, required this.inlineTextStyler, + this.inlineWidgetBuilders = const [], + this.selectedTextColorStrategy, }); /// Padding applied around the interior edge of the document. @@ -25,12 +29,21 @@ class Stylesheet { /// Styles all in-line text in the document. final AttributionStyleAdjuster inlineTextStyler; + /// Chain of inline widget builders, used to map [AttributedText] placeholders + /// to inline widgets. + final InlineWidgetBuilderChain inlineWidgetBuilders; + + /// The strategy that chooses the color for selected text. + final SelectedTextColorStrategy? selectedTextColorStrategy; + /// Priority-order list of style rules. final List rules; Stylesheet copyWith({ EdgeInsets? documentPadding, AttributionStyleAdjuster? inlineTextStyler, + InlineWidgetBuilderChain? inlineWidgetBuilders, + SelectedTextColorStrategy? selectedTextColorStrategy, List addRulesBefore = const [], List? rules, List addRulesAfter = const [], @@ -38,6 +51,8 @@ class Stylesheet { return Stylesheet( documentPadding: documentPadding ?? this.documentPadding, inlineTextStyler: inlineTextStyler ?? this.inlineTextStyler, + inlineWidgetBuilders: inlineWidgetBuilders ?? this.inlineWidgetBuilders, + selectedTextColorStrategy: selectedTextColorStrategy ?? this.selectedTextColorStrategy, rules: [ ...addRulesBefore, ...(rules ?? this.rules), @@ -47,6 +62,22 @@ class Stylesheet { } } +/// Default [SelectedTextColorStrategy], which retains the original text color, +/// regardless of selection color. +Color defaultSelectedTextColorStrategy({ + required Color originalTextColor, + required Color selectionHighlightColor, +}) { + return originalTextColor; +} + +/// Returns the [Color] that should be used for selected text, possibly based +/// on the [originalTextColor]. +typedef SelectedTextColorStrategy = Color Function({ + required Color originalTextColor, + required Color selectionHighlightColor, +}); + /// Adjusts the given [existingStyle] based on the given [attributions]. typedef AttributionStyleAdjuster = TextStyle Function(Set attributions, TextStyle existingStyle); @@ -70,6 +101,8 @@ class StyleRule { } /// Generates style metadata for the given [DocumentNode] within the [Document]. +/// +/// See [Styles] for the list of available keys. typedef Styler = Map Function(Document, DocumentNode); /// Selects blocks in a document that matches a given rule. @@ -183,7 +216,7 @@ class _FirstBlockMatcher implements _BlockMatcher { @override bool matches(Document document, DocumentNode node) { - return document.getNodeIndexById(node.id) == 0; + return document.getNodeById(node.id) == document.firstOrNull; } } @@ -192,7 +225,7 @@ class _LastBlockMatcher implements _BlockMatcher { @override bool matches(Document document, DocumentNode node) { - return document.getNodeIndexById(node.id) == document.nodes.length - 1; + return document.getNodeById(node.id) == document.lastOrNull; } } @@ -294,3 +327,78 @@ class SelectionStyles { @override int get hashCode => selectionColor.hashCode ^ highlightEmptyTextBlocks.hashCode; } + +/// The keys to the style metadata used by a [StyleRule]. +class Styles { + /// Applies a [TextStyle] to the content. + static const String textStyle = 'textStyle'; + + /// Applies a [CascadingPadding] around the content. + static const String padding = 'padding'; + + /// Applies an opacity to an entire node. + static const String opacity = 'opacity'; + + /// Key to a `double` that defines the maximum width of the node. + static const String maxWidth = 'maxWidth'; + + /// Applies a background [Color] to a blockquote. + static const String backgroundColor = 'backgroundColor'; + + /// Applies a [BorderRadius] to a blockquote. + static const String borderRadius = 'borderRadius'; + + /// Applies a [TextAlign] to a text component. + static const String textAlign = 'textAlign'; + + /// Applies a max line count to a text component, or no limit if `null`. + static const String maxLines = 'maxLines'; + + /// Applies a text overflow effect to a text component, e.g., clip, ellipsis, fade, show. + static const String overflow = 'overflow'; + + /// Defines the visual style for all custom underlines rendered by all + /// text that matches the style rule selector. + /// + /// The value should be a [CustomUnderlineStyles]. + static const String customUnderlineStyles = "customUnderlineStyles"; + + /// Applies an [UnderlineStyle] to the composing region, e.g., the word + /// the user is currently editing on mobile. + static const String composingRegionUnderlineStyle = 'composingRegionUnderlineStyle'; + + /// Whether to show an underline beneath the text that is currently in + /// the composing region. + /// + /// It's common for Android to show an underline beneath the composing region. + /// Showing an underline may not be expected on desktop. With this property app + /// developers can make that choice for themselves. + static const String showComposingRegionUnderline = 'showComposingRegionUnderline'; + + /// Applies an [UnderlineStyle] to all spelling errors in a text node. + static const String spellingErrorUnderlineStyle = 'spellingErrorUnderlineStyle'; + + /// Applies an [UnderlineStyle] to all grammar errors in a text node. + static const String grammarErrorUnderlineStyle = 'grammarErrorUnderlineStyle'; + + /// Applies a [AttributionStyleAdjuster] to a text node. + static const String inlineTextStyler = 'inlineTextStyler'; + + /// Applies a [InlineWidgetBuilderChain] to text-based components. + static const String inlineWidgetBuilders = 'inlineWidgetBuilders'; + + /// Applies a [Color] to the dot of an unordered list item. + static const String dotColor = 'dotColor'; + + /// Applies a [BoxShape] to the dot of an unordered list item. + static const String dotShape = 'dotShape'; + + /// Applies a [Size] to the dot of an unordered list item. + /// + /// This is a [Size] instead of a radius because the dot can be rendered + /// as a rectangle. + static const String dotSize = 'dotSize'; + + /// Applies a [OrderedListNumeralStyle] to an ordered list item. + static const String listNumeralStyle = 'listNumeralStyle'; +} diff --git a/super_editor/lib/src/default_editor/ai/content_fading.dart b/super_editor/lib/src/default_editor/ai/content_fading.dart new file mode 100644 index 0000000000..701ff771d1 --- /dev/null +++ b/super_editor/lib/src/default_editor/ai/content_fading.dart @@ -0,0 +1,150 @@ +import 'dart:ui'; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_ai.dart'; + +/// A style phase, which controls the opacity of content, so that content +/// fades in over time. +/// +/// The opacity of the content, at a given moment, is determined by two +/// things: the creation time, and the given [blockNodeFadeInDuration] and +/// [textSnippetFadeInDuration]. +/// +/// Any content that needs to fade-in must be attributed with a [CreatedAtAttribution], +/// with a timestamp that represents when the content was first inserted/created. +/// Based on the "created at" timestamp, and the total fade-in duration, this styler +/// sets content opacity on every frame. +class FadeInStyler extends SingleColumnLayoutStylePhase { + FadeInStyler( + TickerProvider tickerProvider, { + this.blockNodeFadeInDuration = const Duration(milliseconds: 1500), + this.textSnippetFadeInDuration = const Duration(milliseconds: 250), + this.fadeCurve = Curves.easeInOut, + }) { + _ticker = tickerProvider.createTicker(_onTick); + } + + final Duration blockNodeFadeInDuration; + final Duration textSnippetFadeInDuration; + final Curve fadeCurve; + + @override + void dispose() { + _ticker.stop(); + super.dispose(); + } + + late final Ticker _ticker; + + bool _isFading = false; + + @override + SingleColumnLayoutViewModel style(Document document, SingleColumnLayoutViewModel viewModel) { + _isFading = false; + + final newViewModel = SingleColumnLayoutViewModel( + padding: viewModel.padding, + componentViewModels: [ + for (final viewModel in viewModel.componentViewModels) // + _updateViewModelAnimation(viewModel), + ], + ); + + if (!_isFading && _ticker.isActive) { + // No fading is required. Stop ticking. + _ticker.stop(); + } + + return newViewModel; + } + + /// If the given [viewModel] isn't animated, this method returns the [viewModel] + /// unchanged, otherwise, if it is animating, then this method copies the [viewModel], + /// updates the copy's time to the current time, and returns the copy. + SingleColumnLayoutComponentViewModel _updateViewModelAnimation(SingleColumnLayoutComponentViewModel viewModel) { + if (viewModel is! TextComponentViewModel) { + final createdAt = viewModel.createdAt; + if (createdAt == null) { + return viewModel; + } + final deltaTime = DateTime.now().difference(createdAt); + if (deltaTime > blockNodeFadeInDuration) { + return viewModel; + } + + final opacity = fadeCurve.transform(lerpDouble( + 0, + 1, + deltaTime.inMilliseconds / blockNodeFadeInDuration.inMilliseconds, + )! + .clamp(0, 1)); + + // An animation is ongoing. We need to schedule another frame to continue + // updating the view model, which will then cause the component widget to + // re-render and show the animation. + _isFading = true; + _scheduleAnotherFrame(); + + return viewModel.copy()..opacity = opacity; + } + + final fadeIns = viewModel.text.getAttributionSpansByFilter((a) => a is CreatedAtAttribution).toList(); + final fadeInAttributions = fadeIns.map((s) => s.attribution).toList().cast(); + if (fadeInAttributions.isEmpty) { + return viewModel; + } + final isFading = fadeInAttributions.fold(false, (isFading, fadeIn) => isFading || _isTextFading(fadeIn.start)); + if (!isFading) { + return viewModel; + } + + // An animation is ongoing. We need to schedule another frame to continue + // updating the view model, which will then cause the component widget to + // re-render and show the animation. + _isFading = true; + _scheduleAnotherFrame(); + + // We know we're fading something. Create a copy of the view model so we can + // change it. + final textViewModel = viewModel.copy() as TextComponentViewModel; + + // Add opacity attributions based on created-at timestamps. + for (final span in fadeIns) { + final fadeInAttribution = span.attribution as CreatedAtAttribution; + final deltaTime = DateTime.now().difference(fadeInAttribution.start); + final opacity = deltaTime > textSnippetFadeInDuration + ? 1.0 + : fadeCurve.transform(deltaTime.inMilliseconds / textSnippetFadeInDuration.inMilliseconds); + if (opacity < 1) { + textViewModel.text.addAttribution( + OpacityAttribution(opacity), + span.range, + ); + } + } + + return textViewModel; + } + + bool _isTextFading(DateTime startTime) { + return (DateTime.now().difference(startTime) < textSnippetFadeInDuration); + } + + void _scheduleAnotherFrame() { + if (_ticker.isActive) { + return; + } + + _ticker.start(); + } + + void _onTick(Duration elapsedTime) { + // Fade-in status changed somewhere in the document. Run the styler again. + markDirty(); + } +} diff --git a/super_editor/lib/src/default_editor/attributions.dart b/super_editor/lib/src/default_editor/attributions.dart index b542e79b60..77b0e833f1 100644 --- a/super_editor/lib/src/default_editor/attributions.dart +++ b/super_editor/lib/src/default_editor/attributions.dart @@ -1,23 +1,28 @@ +import 'dart:ui'; + import 'package:attributed_text/attributed_text.dart'; -/// Header 1 style attribution. +/// Header 1 style block attribution. const header1Attribution = NamedAttribution('header1'); -/// Header 2 style attribution. +/// Header 2 style block attribution. const header2Attribution = NamedAttribution('header2'); -/// Header 3 style attribution. +/// Header 3 style block attribution. const header3Attribution = NamedAttribution('header3'); -/// Header 4 style attribution. +/// Header 4 style block attribution. const header4Attribution = NamedAttribution('header4'); -/// Header 5 style attribution. +/// Header 5 style block attribution. const header5Attribution = NamedAttribution('header5'); -/// Header 6 style attribution. +/// Header 6 style block attribution. const header6Attribution = NamedAttribution('header6'); +/// Plain paragraph block attribution. +const paragraphAttribution = NamedAttribution('paragraph'); + /// Blockquote attribution const blockquoteAttribution = NamedAttribution('blockquote'); @@ -33,12 +38,277 @@ const underlineAttribution = NamedAttribution('underline'); /// Strikethrough style attribution. const strikethroughAttribution = NamedAttribution('strikethrough'); +/// Superscript style attribution. +const superscriptAttribution = ScriptAttribution.superscript(); + +/// Subscript style attribution. +const subscriptAttribution = ScriptAttribution.subscript(); + /// Code style attribution. const codeAttribution = NamedAttribution('code'); +/// Spelling error attribution. +const spellingErrorAttribution = NamedAttribution('spelling-error'); + +/// Grammar error attribution. +const grammarErrorAttribution = NamedAttribution('grammar-error'); + +/// An attribution for superscript and subscript text. +class ScriptAttribution implements Attribution { + static const typeSuper = "superscript"; + static const typeSub = "subscript"; + + const ScriptAttribution.superscript() : type = typeSuper; + + const ScriptAttribution.subscript() : type = typeSub; + + @override + String get id => "script"; + + final String type; + + @override + bool canMergeWith(Attribution other) { + return other is ScriptAttribution && type == other.type; + } +} + +/// Attribution to be used within [AttributedText] to +/// represent an inline span of a text color change. +/// +/// Every [ColorAttribution] is considered equivalent so +/// that [AttributedText] prevents multiple [ColorAttribution]s +/// from overlapping. +class ColorAttribution implements Attribution { + const ColorAttribution(this.color); + + @override + String get id => 'color'; + + final Color color; + + @override + bool canMergeWith(Attribution other) { + return this == other; + } + + @override + bool operator ==(Object other) => + identical(this, other) || other is ColorAttribution && runtimeType == other.runtimeType && color == other.color; + + @override + int get hashCode => color.hashCode; + + @override + String toString() { + return '[ColorAttribution]: $color'; + } +} + +/// Attribution to be used within [AttributedText] to +/// represent an inline span of a background color change. +/// +/// Every [BackgroundColorAttribution] is considered equivalent so +/// that [AttributedText] prevents multiple [BackgroundColorAttribution]s +/// from overlapping. +class BackgroundColorAttribution implements Attribution { + const BackgroundColorAttribution(this.color); + + @override + String get id => 'background_color'; + + final Color color; + + @override + bool canMergeWith(Attribution other) { + return this == other; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BackgroundColorAttribution && runtimeType == other.runtimeType && color == other.color; + + @override + int get hashCode => color.hashCode; + + @override + String toString() { + return '[BackgroundColorAttribution]: $color'; + } +} + +/// Attribution to be used within [AttributedText] to mark text that should be painted +/// with a custom underline. +/// +/// A custom underline is an underline that's painted by Super Editor, rather than +/// painted by the text layout package, inside of the Flutter engine. Flutter's standard +/// text underline doesn't allow for any stylistic configuration. It always has the +/// same thickness, the same end-caps, sits the same distance from the text, and has +/// the same color as the text. This is insufficient for real world document editing +/// use-cases. +/// +/// A [CustomUnderlineAttribution] tells Super Editor that a user wants to paint a +/// custom underline beneath a span of text. From there, various pieces of the Super Editor +/// styling system process the attribution, and paint the desired underline. +/// +/// ## Other Approaches to Underlines +/// [CustomUnderlineAttribution]s refer to visual style choices, similar to bold, italics, +/// and strikethrough. In other words, this attribution is for painting underlines in situations +/// where the spans of text don't represent some other semantic meaning. +/// +/// Super Editor includes other underlined content that does include semantic meaning. +/// Therefore, those underlines don't use [CustomUnderlineAttribution]s. +/// +/// One example is the user's composing region. Super Editor underlines the composing region, +/// but that region doesn't have a [CustomUnderlineAttribution] applied to it. Instead, +/// Super Editor explicitly tracks the user's composing region in a variable. +/// +/// Another example is spelling and grammar errors. These, too, display underlines. +/// However, the placement of spelling and grammar error spans is managed by the +/// spelling and grammar check system. These spans don't simply represent a stylistic +/// underline, they carry semantic meaning. In this case that meaning is a misspelled +/// word, or a grammatically incorrect structure. +/// +/// [CustomUnderlineAttribution] is provided for situations where the underline doesn't +/// mean anything more than an underline. +class CustomUnderlineAttribution implements Attribution { + static const standard = "standard"; + + const CustomUnderlineAttribution([this.type = standard]); + + @override + String get id => 'custom_underline'; + + /// The type of underline that should be applied to the attributed text. + /// + /// The type can be anything. The meaning of the term is enforced by the developer's + /// styling system. Super Editor ships with some pre-defined terms for obvious + /// use-cases, e.g., [standard]. + final String type; + + @override + bool canMergeWith(Attribution other) { + return this == other; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CustomUnderlineAttribution && runtimeType == other.runtimeType && type == other.type; + + @override + int get hashCode => type.hashCode; + + @override + String toString() { + return '[CustomUnderlineAttribution]: $type'; + } +} + +/// Attribution to be used within [AttributedText] to apply a given [opacity] +/// to a span of text. +class OpacityAttribution implements Attribution { + const OpacityAttribution(this.opacity); + + @override + String get id => 'opacity'; + + final double opacity; + + @override + bool canMergeWith(Attribution other) { + return this == other; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OpacityAttribution && runtimeType == other.runtimeType && opacity == other.opacity; + + @override + int get hashCode => opacity.hashCode; + + @override + String toString() { + return '[Opacity]: $opacity'; + } +} + +/// Attribution to be used within [AttributedText] to +/// represent an inline span of a font size change. +/// +/// Every [FontSizeAttribution] is considered equivalent so +/// that [AttributedText] prevents multiple [FontSizeAttribution]s +/// from overlapping. +class FontSizeAttribution implements Attribution { + const FontSizeAttribution(this.fontSize); + + @override + String get id => 'font_size'; + + final double fontSize; + + @override + bool canMergeWith(Attribution other) { + return this == other; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FontSizeAttribution && runtimeType == other.runtimeType && fontSize == other.fontSize; + + @override + int get hashCode => fontSize.hashCode; + + @override + String toString() { + return '[FontSizeAttribution]: $fontSize'; + } +} + +/// Attribution that says the text within it should use the given +/// [fontFamily]. +/// +/// Every [FontFamilyAttribution] is considered equivalent so +/// that [AttributedText] prevents multiple [FontFamilyAttribution]s +/// from overlapping. +class FontFamilyAttribution implements Attribution { + const FontFamilyAttribution(this.fontFamily); + + @override + String get id => 'font_family'; + + final String fontFamily; + + @override + bool canMergeWith(Attribution other) { + return this == other; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FontFamilyAttribution && runtimeType == other.runtimeType && fontFamily == other.fontFamily; + + @override + int get hashCode => fontFamily.hashCode; + + @override + String toString() { + return '[FontFamilyAttribution]: $fontFamily'; + } +} + /// Attribution to be used within [AttributedText] to /// represent a link. /// +/// A link might be a URL or a URI. URLs are a subset of URIs. +/// A URL begins with a scheme and "://", e.g., "https://" or +/// "obsidian://". A URI begins with a scheme and a ":", e.g., +/// "mailto:" or "spotify:". +/// /// Every [LinkAttribution] is considered equivalent so /// that [AttributedText] prevents multiple [LinkAttribution]s /// from overlapping. @@ -48,14 +318,89 @@ const codeAttribution = NamedAttribution('code'); /// within [AttributedText]. This class doesn't have a special /// relationship with [AttributedText]. class LinkAttribution implements Attribution { - LinkAttribution({ - required this.url, - }); + /// Creates a [LinkAttribution] from a structured [URI] (instead of plain text). + /// + /// The [plainTextUri] for the returned [LinkAttribution] is set to + /// the [uri]'s `toString()` value. + factory LinkAttribution.fromUri(Uri uri) { + if (!uri.hasScheme) { + // Without a scheme, a URI is fairly useless. We can't be sure + // that any other part of the URI was parsed correctly if it + // didn't begin with a scheme. Fallback to a plain text-only + // attribution. + return LinkAttribution(uri.toString()); + } + + return LinkAttribution(uri.toString(), uri); + } + + /// Create a [LinkAttribution] based on a given [email] address. + /// + /// This factory is equivalent to calling [LinkAttribution.fromUri] + /// with a [Uri] whose `scheme` is "mailto" and whose `path` is [email]. + factory LinkAttribution.fromEmail(String email) { + return LinkAttribution.fromUri( + Uri( + scheme: "mailto", + path: email, + ), + ); + } + + /// Creates a [LinkAttribution] whose plain-text URI is [plainTextUri], and + /// which (optionally) includes a structured [Uri] version of the + /// same URI. + /// + /// [LinkAttribution] allows for text only creation because there may + /// be situations where apps must apply link attributions to invalid + /// URIs, such as when loading documents created elsewhere. + const LinkAttribution(this.plainTextUri, [this.uri]); @override String get id => 'link'; - final Uri url; + @Deprecated("Use plainTextUri instead. The term 'url' was a lie - it could always have been a URI.") + String get url => plainTextUri; + + /// The URI associated with the attributed text, as a `String`. + final String plainTextUri; + + /// Returns `true` if this [LinkAttribution] has [uri], which is + /// a structured representation of the associated URI. + bool get hasStructuredUri => uri != null; + + /// The structured [Uri] associated with this attribution's [plainTextUri]. + /// + /// In the nominal case, this [uri] has the same value as the [plainTextUri]. + /// However, in some cases, linkified text may have a [plainTextUri] that isn't + /// a valid [Uri]. This can happen when an app creates or loads documents from + /// other sources - one wants to retain link attributions, even if they're invalid. + final Uri? uri; + + /// Returns a best-guess version of this URI that an operating system can launch. + /// + /// In the nominal case, this value is the same as [uri] and [plainTextUri]. + /// + /// When no [uri] is available, this property either returns [plainTextUri] as-is, + /// or inserts a best-guess scheme. + Uri get launchableUri { + if (hasStructuredUri) { + return uri!; + } + + if (plainTextUri.contains("://")) { + // It looks like the plain text URI has URL scheme. Return it as-is. + return Uri.parse(plainTextUri); + } + + if (plainTextUri.contains("@")) { + // Our best guess is that this is a URL. + return Uri.parse("mailto:$plainTextUri"); + } + + // Our best guess is that this is a web URL. + return Uri.parse("https://$plainTextUri"); + } @override bool canMergeWith(Attribution other) { @@ -64,13 +409,14 @@ class LinkAttribution implements Attribution { @override bool operator ==(Object other) => - identical(this, other) || other is LinkAttribution && runtimeType == other.runtimeType && url == other.url; + identical(this, other) || + other is LinkAttribution && runtimeType == other.runtimeType && plainTextUri == other.plainTextUri; @override - int get hashCode => url.hashCode; + int get hashCode => plainTextUri.hashCode; @override String toString() { - return '[LinkAttribution]: $url'; + return '[LinkAttribution]: $plainTextUri${hasStructuredUri ? ' ($uri)' : ''}'; } } diff --git a/super_editor/lib/src/default_editor/blockquote.dart b/super_editor/lib/src/default_editor/blockquote.dart index 59b397b0b1..ac1512dbd7 100644 --- a/super_editor/lib/src/default_editor/blockquote.dart +++ b/super_editor/lib/src/default_editor/blockquote.dart @@ -1,14 +1,13 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/styles.dart'; import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/blocks/indentation.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'package:super_editor/src/infrastructure/keyboard.dart'; +import 'package:super_text_layout/super_text_layout.dart'; import '../core/document.dart'; -import '../core/document_editor.dart'; import 'layout_single_column/layout_single_column.dart'; import 'paragraph.dart'; import 'text.dart'; @@ -29,7 +28,7 @@ class BlockquoteComponentBuilder implements ComponentBuilder { return null; } - final textDirection = getParagraphDirection(node.text.text); + final textDirection = getParagraphDirection(node.text.toPlainText()); TextAlign textAlign = (textDirection == TextDirection.ltr) ? TextAlign.left : TextAlign.right; final textAlignName = node.getMetadataValue('textAlign'); @@ -50,8 +49,10 @@ class BlockquoteComponentBuilder implements ComponentBuilder { return BlockquoteComponentViewModel( nodeId: node.id, + createdAt: node.metadata["createdAt"], text: node.text, textStyleBuilder: noStyleBuilder, + indent: node.indent, backgroundColor: const Color(0x00000000), borderRadius: BorderRadius.zero, textDirection: textDirection, @@ -71,39 +72,74 @@ class BlockquoteComponentBuilder implements ComponentBuilder { textKey: componentContext.componentKey, text: componentViewModel.text, styleBuilder: componentViewModel.textStyleBuilder, + indent: componentViewModel.indent, + indentCalculator: componentViewModel.indentCalculator, backgroundColor: componentViewModel.backgroundColor, borderRadius: componentViewModel.borderRadius, textSelection: componentViewModel.selection, selectionColor: componentViewModel.selectionColor, highlightWhenEmpty: componentViewModel.highlightWhenEmpty, + underlines: componentViewModel.createUnderlines(), ); } } class BlockquoteComponentViewModel extends SingleColumnLayoutComponentViewModel with TextComponentViewModel { BlockquoteComponentViewModel({ - required String nodeId, - double? maxWidth, - EdgeInsetsGeometry padding = EdgeInsets.zero, + required super.nodeId, + super.createdAt, + super.maxWidth, + super.padding = EdgeInsets.zero, + super.opacity = 1.0, required this.text, required this.textStyleBuilder, + this.inlineWidgetBuilders = const [], this.textDirection = TextDirection.ltr, this.textAlignment = TextAlign.left, + this.maxLines, + this.overflow = TextOverflow.clip, + this.indent = 0, + this.indentCalculator = defaultParagraphIndentCalculator, required this.backgroundColor, required this.borderRadius, this.selection, required this.selectionColor, this.highlightWhenEmpty = false, - }) : super(nodeId: nodeId, maxWidth: maxWidth, padding: padding); + TextRange? composingRegion, + bool showComposingRegionUnderline = false, + UnderlineStyle spellingErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Color(0xFFFF0000)), + List spellingErrors = const [], + UnderlineStyle grammarErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.blue), + List grammarErrors = const [], + }) { + this.composingRegion = composingRegion; + this.showComposingRegionUnderline = showComposingRegionUnderline; + + this.spellingErrorUnderlineStyle = spellingErrorUnderlineStyle; + this.spellingErrors = spellingErrors; + + this.grammarErrorUnderlineStyle = grammarErrorUnderlineStyle; + this.grammarErrors = grammarErrors; + } + @override AttributedText text; - @override AttributionStyleBuilder textStyleBuilder; @override + InlineWidgetBuilderChain inlineWidgetBuilders; + @override TextDirection textDirection; @override TextAlign textAlignment; + @override + int? maxLines; + @override + TextOverflow overflow; + + int indent; + TextBlockIndentCalculator indentCalculator; + @override TextSelection? selection; @override @@ -117,56 +153,53 @@ class BlockquoteComponentViewModel extends SingleColumnLayoutComponentViewModel @override void applyStyles(Map styles) { super.applyStyles(styles); - backgroundColor = styles["backgroundColor"] ?? Colors.transparent; - borderRadius = styles["borderRadius"] ?? BorderRadius.zero; + backgroundColor = styles[Styles.backgroundColor] ?? Colors.transparent; + borderRadius = styles[Styles.borderRadius] ?? BorderRadius.zero; } @override BlockquoteComponentViewModel copy() { - return BlockquoteComponentViewModel( - nodeId: nodeId, - maxWidth: maxWidth, - padding: padding, - text: text, - textStyleBuilder: textStyleBuilder, - textDirection: textDirection, - textAlignment: textAlignment, - backgroundColor: backgroundColor, - borderRadius: borderRadius, - selection: selection, - selectionColor: selectionColor, - highlightWhenEmpty: highlightWhenEmpty, + return internalCopy( + BlockquoteComponentViewModel( + nodeId: nodeId, + createdAt: createdAt, + text: text.copy(), + textStyleBuilder: textStyleBuilder, + opacity: opacity, + selectionColor: selectionColor, + backgroundColor: backgroundColor, + borderRadius: borderRadius, + ), ); } + @override + BlockquoteComponentViewModel internalCopy(BlockquoteComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as BlockquoteComponentViewModel; + + copy + ..indent = indent + ..indentCalculator = indentCalculator + ..backgroundColor = backgroundColor + ..borderRadius = borderRadius; + + return copy; + } + @override bool operator ==(Object other) => identical(this, other) || super == other && other is BlockquoteComponentViewModel && runtimeType == other.runtimeType && - nodeId == other.nodeId && - text == other.text && - textDirection == other.textDirection && - textAlignment == other.textAlignment && + textViewModelEquals(other) && + indent == other.indent && backgroundColor == other.backgroundColor && - borderRadius == other.borderRadius && - selection == other.selection && - selectionColor == other.selectionColor && - highlightWhenEmpty == other.highlightWhenEmpty; + borderRadius == other.borderRadius; @override int get hashCode => - super.hashCode ^ - nodeId.hashCode ^ - text.hashCode ^ - textDirection.hashCode ^ - textAlignment.hashCode ^ - backgroundColor.hashCode ^ - borderRadius.hashCode ^ - selection.hashCode ^ - selectionColor.hashCode ^ - highlightWhenEmpty.hashCode; + super.hashCode ^ textViewModelHashCode ^ indent.hashCode ^ backgroundColor.hashCode ^ borderRadius.hashCode; } /// Displays a blockquote in a document. @@ -175,23 +208,35 @@ class BlockquoteComponent extends StatelessWidget { Key? key, required this.textKey, required this.text, + this.maxLines, + this.overflow = TextOverflow.clip, required this.styleBuilder, + this.inlineWidgetBuilders = const [], this.textSelection, + this.indent = 0, + this.indentCalculator = defaultParagraphIndentCalculator, this.selectionColor = Colors.lightBlueAccent, required this.backgroundColor, required this.borderRadius, - this.showDebugPaint = false, this.highlightWhenEmpty = false, + this.underlines = const [], + this.showDebugPaint = false, }) : super(key: key); final GlobalKey textKey; final AttributedText text; + final int? maxLines; + final TextOverflow overflow; final AttributionStyleBuilder styleBuilder; + final InlineWidgetBuilderChain inlineWidgetBuilders; final TextSelection? textSelection; + final int indent; + final TextBlockIndentCalculator indentCalculator; final Color selectionColor; final Color backgroundColor; final BorderRadius borderRadius; final bool highlightWhenEmpty; + final List underlines; final bool showDebugPaint; @override @@ -203,136 +248,34 @@ class BlockquoteComponent extends StatelessWidget { borderRadius: borderRadius, color: backgroundColor, ), - child: TextComponent( - key: textKey, - text: text, - textStyleBuilder: styleBuilder, - textSelection: textSelection, - selectionColor: selectionColor, - highlightWhenEmpty: highlightWhenEmpty, - showDebugPaint: showDebugPaint, + child: Row( + children: [ + // Indent spacing on left. + SizedBox( + width: indentCalculator( + styleBuilder({}), + indent, + ), + ), + // The actual paragraph UI. + Expanded( + child: TextComponent( + key: textKey, + text: text, + maxLines: maxLines, + overflow: overflow, + textStyleBuilder: styleBuilder, + inlineWidgetBuilders: inlineWidgetBuilders, + textSelection: textSelection, + selectionColor: selectionColor, + highlightWhenEmpty: highlightWhenEmpty, + underlines: underlines, + showDebugPaint: showDebugPaint, + ), + ), + ], ), ), ); } } - -class ConvertBlockquoteToParagraphCommand implements EditorCommand { - ConvertBlockquoteToParagraphCommand({ - required this.nodeId, - }); - - final String nodeId; - - @override - void execute(Document document, DocumentEditorTransaction transaction) { - final node = document.getNodeById(nodeId); - final blockquote = node as ParagraphNode; - final newParagraphNode = ParagraphNode( - id: blockquote.id, - text: blockquote.text, - ); - transaction.replaceNode(oldNode: blockquote, newNode: newParagraphNode); - } -} - -ExecutionInstruction insertNewlineInBlockquote({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (keyEvent.logicalKey != LogicalKeyboardKey.enter) { - return ExecutionInstruction.continueExecution; - } - - if (!keyEvent.isShiftPressed) { - return ExecutionInstruction.continueExecution; - } - - if (editContext.composer.selection == null) { - return ExecutionInstruction.continueExecution; - } - - final baseNode = editContext.editor.document.getNodeById(editContext.composer.selection!.base.nodeId)!; - final extentNode = editContext.editor.document.getNodeById(editContext.composer.selection!.extent.nodeId)!; - if (baseNode.id != extentNode.id) { - return ExecutionInstruction.continueExecution; - } - if (extentNode is! ParagraphNode) { - return ExecutionInstruction.continueExecution; - } - if (extentNode.getMetadataValue('blockType') != blockquoteAttribution) { - return ExecutionInstruction.continueExecution; - } - - final didInsertNewline = editContext.commonOps.insertPlainText('\n'); - return didInsertNewline ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; -} - -ExecutionInstruction splitBlockquoteWhenEnterPressed({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (keyEvent.logicalKey != LogicalKeyboardKey.enter) { - return ExecutionInstruction.continueExecution; - } - - if (editContext.composer.selection == null) { - return ExecutionInstruction.continueExecution; - } - - final baseNode = editContext.editor.document.getNodeById(editContext.composer.selection!.base.nodeId)!; - final extentNode = editContext.editor.document.getNodeById(editContext.composer.selection!.extent.nodeId)!; - if (baseNode.id != extentNode.id) { - return ExecutionInstruction.continueExecution; - } - if (extentNode is! ParagraphNode) { - return ExecutionInstruction.continueExecution; - } - if (extentNode.getMetadataValue('blockType') != blockquoteAttribution) { - return ExecutionInstruction.continueExecution; - } - - final didSplit = editContext.commonOps.insertBlockLevelNewline(); - return didSplit ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; -} - -class SplitBlockquoteCommand implements EditorCommand { - SplitBlockquoteCommand({ - required this.nodeId, - required this.splitPosition, - required this.newNodeId, - }); - - final String nodeId; - final TextPosition splitPosition; - final String newNodeId; - - @override - void execute(Document document, DocumentEditorTransaction transaction) { - final node = document.getNodeById(nodeId); - final blockquote = node as ParagraphNode; - final text = blockquote.text; - final startText = text.copyText(0, splitPosition.offset); - final endText = splitPosition.offset < text.text.length ? text.copyText(splitPosition.offset) : AttributedText(); - - // Change the current node's content to just the text before the caret. - // TODO: figure out how node changes should work in terms of - // a DocumentEditorTransaction (#67) - blockquote.text = startText; - - // Create a new node that will follow the current node. Set its text - // to the text that was removed from the current node. - final isNewNodeABlockquote = endText.text.isNotEmpty; - final newNode = ParagraphNode( - id: newNodeId, - text: endText, - metadata: isNewNodeABlockquote ? {'blockType': blockquoteAttribution} : {}, - ); - - // Insert the new node after the current node. - transaction.insertNodeAfter( - existingNode: node, - newNode: newNode, - ); - } -} diff --git a/super_editor/lib/src/default_editor/blocks/indentation.dart b/super_editor/lib/src/default_editor/blocks/indentation.dart new file mode 100644 index 0000000000..9f8878f889 --- /dev/null +++ b/super_editor/lib/src/default_editor/blocks/indentation.dart @@ -0,0 +1,7 @@ +import 'package:flutter/painting.dart'; + +/// A function that calculates the pixels to indent a text block in a document, given +/// the [blockTextStyle], and the [indent] level. +/// +/// A text block is a document block that contains text, e.g., paragraph, list item, task. +typedef TextBlockIndentCalculator = double Function(TextStyle blockTextStyle, int indent); diff --git a/super_editor/lib/src/default_editor/box_component.dart b/super_editor/lib/src/default_editor/box_component.dart index 8ffe1016b2..0a11b64b35 100644 --- a/super_editor/lib/src/default_editor/box_component.dart +++ b/super_editor/lib/src/default_editor/box_component.dart @@ -1,7 +1,13 @@ import 'package:flutter/material.dart'; import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/flutter/geometry.dart'; import '../core/document_layout.dart'; @@ -9,13 +15,21 @@ import '../core/document_layout.dart'; final _log = Logger(scope: 'box_component.dart'); /// Base implementation for a [DocumentNode] that only supports [UpstreamDownstreamNodeSelection]s. +@immutable abstract class BlockNode extends DocumentNode { + BlockNode({ + Map? metadata, + }) : super(metadata: metadata); + @override UpstreamDownstreamNodePosition get beginningPosition => const UpstreamDownstreamNodePosition.upstream(); @override UpstreamDownstreamNodePosition get endPosition => const UpstreamDownstreamNodePosition.downstream(); + @override + bool containsPosition(Object position) => position is UpstreamDownstreamNodePosition; + @override UpstreamDownstreamNodePosition selectUpstreamPosition(NodePosition position1, NodePosition position2) { if (position1 is! UpstreamDownstreamNodePosition) { @@ -46,16 +60,16 @@ abstract class BlockNode extends DocumentNode { } if (position1.affinity == TextAffinity.downstream || position2.affinity == TextAffinity.downstream) { - return const UpstreamDownstreamNodePosition.upstream(); - } else { return const UpstreamDownstreamNodePosition.downstream(); + } else { + return const UpstreamDownstreamNodePosition.upstream(); } } @override UpstreamDownstreamNodeSelection computeSelection({ - @required dynamic base, - @required dynamic extent, + required NodePosition base, + required NodePosition extent, }) { if (base is! UpstreamDownstreamNodePosition) { throw Exception('Expected a UpstreamDownstreamNodePosition for base but received a ${base.runtimeType}'); @@ -75,10 +89,12 @@ class BoxComponent extends StatefulWidget { const BoxComponent({ Key? key, this.isVisuallySelectable = true, + this.opacity = 1.0, required this.child, }) : super(key: key); final bool isVisuallySelectable; + final double opacity; final Widget child; @override @@ -101,7 +117,7 @@ class _BoxComponentState extends State with DocumentComponent { } @override - UpstreamDownstreamNodePosition? movePositionLeft(dynamic currentPosition, [MovementModifier? movementModifier]) { + UpstreamDownstreamNodePosition? movePositionLeft(NodePosition currentPosition, [MovementModifier? movementModifier]) { if (currentPosition == const UpstreamDownstreamNodePosition.upstream()) { // Can't move any further left. return null; @@ -111,7 +127,8 @@ class _BoxComponentState extends State with DocumentComponent { } @override - UpstreamDownstreamNodePosition? movePositionRight(dynamic currentPosition, [MovementModifier? movementModifier]) { + UpstreamDownstreamNodePosition? movePositionRight(NodePosition currentPosition, + [MovementModifier? movementModifier]) { if (currentPosition == const UpstreamDownstreamNodePosition.downstream()) { // Can't move any further right. return null; @@ -121,13 +138,13 @@ class _BoxComponentState extends State with DocumentComponent { } @override - UpstreamDownstreamNodePosition? movePositionUp(dynamic currentPosition) { + UpstreamDownstreamNodePosition? movePositionUp(NodePosition currentPosition) { // BoxComponents don't support vertical movement. return null; } @override - UpstreamDownstreamNodePosition? movePositionDown(dynamic currentPosition) { + UpstreamDownstreamNodePosition? movePositionDown(NodePosition currentPosition) { // BoxComponents don't support vertical movement. return null; } @@ -161,7 +178,7 @@ class _BoxComponentState extends State with DocumentComponent { } @override - Offset getOffsetForPosition(nodePosition) { + Offset getOffsetForPosition(NodePosition nodePosition) { if (nodePosition is! UpstreamDownstreamNodePosition) { throw Exception('Expected nodePosition of type UpstreamDownstreamNodePosition but received: $nodePosition'); } @@ -182,24 +199,34 @@ class _BoxComponentState extends State with DocumentComponent { } @override - Rect getRectForPosition(dynamic nodePosition) { + Rect getEdgeForPosition(NodePosition nodePosition) { + final boundingBox = getRectForPosition(nodePosition); + + final boxPosition = nodePosition as UpstreamDownstreamNodePosition; + if (boxPosition.affinity == TextAffinity.upstream) { + return boundingBox.leftEdge; + } else { + return boundingBox.rightEdge; + } + } + + /// Returns a [Rect] that bounds this entire box component. + /// + /// The behavior of this method is the same, regardless of whether the given + /// [nodePosition] is `upstream` or `downstream`. + @override + Rect getRectForPosition(NodePosition nodePosition) { if (nodePosition is! UpstreamDownstreamNodePosition) { throw Exception('Expected nodePosition of type UpstreamDownstreamNodePosition but received: $nodePosition'); } final myBox = context.findRenderObject() as RenderBox; - if (nodePosition.affinity == TextAffinity.upstream) { - // Vertical line to the left of the component. - return Rect.fromLTWH(-1, 0, 1, myBox.size.height); - } else { - // Vertical line to the right of the component. - return Rect.fromLTWH(myBox.size.width, 0, 1, myBox.size.height); - } + return Rect.fromLTWH(0, 0, myBox.size.width, myBox.size.height); } @override - Rect getRectForSelection(dynamic basePosition, dynamic extentPosition) { + Rect getRectForSelection(NodePosition basePosition, NodePosition extentPosition) { if (basePosition is! UpstreamDownstreamNodePosition) { throw Exception('Expected nodePosition of type UpstreamDownstreamNodePosition but received: $basePosition'); } @@ -258,7 +285,10 @@ class _BoxComponentState extends State with DocumentComponent { @override Widget build(BuildContext context) { - return widget.child; + return Opacity( + opacity: widget.opacity, + child: widget.child, + ); } } @@ -283,7 +313,7 @@ class SelectableBox extends StatelessWidget { child: IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( - color: isSelected ? selectionColor.withOpacity(0.5) : Colors.transparent, + color: isSelected ? selectionColor.withValues(alpha: 0.5) : Colors.transparent, ), position: DecorationPosition.foreground, child: child, @@ -292,3 +322,92 @@ class SelectableBox extends StatelessWidget { ); } } + +class DeleteUpstreamAtBeginningOfBlockNodeCommand extends EditCommand { + DeleteUpstreamAtBeginningOfBlockNodeCommand(this.node); + + final DocumentNode node; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.find(Editor.composerKey); + final documentLayoutEditable = context.find(Editor.layoutKey); + + final deletionPosition = DocumentPosition(nodeId: node.id, nodePosition: node.beginningPosition); + + final nodePosition = deletionPosition.nodePosition as UpstreamDownstreamNodePosition; + if (nodePosition.affinity == TextAffinity.downstream) { + // The caret is sitting on the downstream edge of block-level content. Delete the + // whole block by replacing it with an empty paragraph. + executor.executeCommand( + ReplaceNodeWithEmptyParagraphWithCaretCommand(nodeId: deletionPosition.nodeId), + ); + return; + } + + // The caret is sitting on the upstream edge of block-level content and + // the user is trying to delete upstream. + // * If the node above is an empty paragraph, delete it. + // * If the node above is non-selectable, delete it. + // * Otherwise, move the caret up to the node above. + final nodeBefore = document.getNodeBefore(node); + if (nodeBefore == null) { + return; + } + + if (nodeBefore is TextNode && nodeBefore.text.isEmpty) { + executor.executeCommand( + DeleteNodeCommand(nodeId: nodeBefore.id), + ); + return; + } + + final componentBefore = documentLayoutEditable.documentLayout.getComponentByNodeId(nodeBefore.id)!; + if (!componentBefore.isVisualSelectionSupported()) { + // The node/component above is not selectable. Delete it. + executor.executeCommand( + DeleteNodeCommand(nodeId: nodeBefore.id), + ); + return; + } + + moveSelectionToEndOfPrecedingNode(executor, document, composer); + } + + void moveSelectionToEndOfPrecedingNode( + CommandExecutor executor, + MutableDocument document, + MutableDocumentComposer composer, + ) { + if (composer.selection == null) { + return; + } + + final node = document.getNodeById(composer.selection!.extent.nodeId); + if (node == null) { + return; + } + + final nodeBefore = document.getNodeBefore(node); + if (nodeBefore == null) { + return; + } + + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeBefore.id, + nodePosition: nodeBefore.endPosition, + ), + ), + SelectionChangeType.collapseSelection, + SelectionReason.userInteraction, + ), + ); + } +} diff --git a/super_editor/lib/src/default_editor/common_editor_operations.dart b/super_editor/lib/src/default_editor/common_editor_operations.dart index 49fd7fc62f..94264f7bed 100644 --- a/super_editor/lib/src/default_editor/common_editor_operations.dart +++ b/super_editor/lib/src/default_editor/common_editor_operations.dart @@ -1,32 +1,29 @@ -import 'dart:io'; import 'dart:math'; import 'dart:ui'; import 'package:attributed_text/attributed_text.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:http/http.dart' as http; -import 'package:linkify/linkify.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_composer.dart'; -import 'package:super_editor/src/core/document_editor.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/box_component.dart'; +import 'package:super_editor/src/default_editor/default_document_editor_reactions.dart'; import 'package:super_editor/src/default_editor/list_items.dart'; import 'package:super_editor/src/default_editor/paragraph.dart'; import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'attributions.dart'; -import 'horizontal_rule.dart'; -import 'image.dart'; -import 'list_items.dart'; -import 'multi_node_editing.dart'; -import 'text.dart'; -import 'text_tools.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/horizontal_rule.dart'; +import 'package:super_editor/src/default_editor/image.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; /// Performs common, high-level editing and composition tasks /// with a simplified API. @@ -45,6 +42,7 @@ import 'text_tools.dart'; /// implemented within [CommonEditorOperations]. class CommonEditorOperations { CommonEditorOperations({ + required this.document, required this.editor, required this.composer, required this.documentLayoutResolver, @@ -52,7 +50,10 @@ class CommonEditorOperations { // Marked as protected for extension methods and subclasses @protected - final DocumentEditor editor; + final Document document; + // Marked as protected for extension methods and subclasses + @protected + final Editor editor; // Marked as protected for extension methods and subclasses @protected final DocumentComposer composer; @@ -67,11 +68,18 @@ class CommonEditorOperations { /// or [false] if the given [documentPosition] could not be /// resolved to a location within the [Document]. bool insertCaretAtPosition(DocumentPosition documentPosition) { - if (editor.document.getNodeById(documentPosition.nodeId) == null) { + if (document.getNodeById(documentPosition.nodeId) == null) { return false; } - composer.selection = DocumentSelection.collapsed(position: documentPosition); + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed(position: documentPosition), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + return true; } @@ -98,7 +106,14 @@ class CommonEditorOperations { } if (position != null) { - composer.selection = DocumentSelection.collapsed(position: position); + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed(position: position), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + return true; } else { return false; @@ -117,17 +132,23 @@ class CommonEditorOperations { required DocumentPosition baseDocumentPosition, required DocumentPosition extentDocumentPosition, }) { - if (editor.document.getNodeById(baseDocumentPosition.nodeId) == null) { + if (document.getNodeById(baseDocumentPosition.nodeId) == null) { return false; } - if (editor.document.getNodeById(extentDocumentPosition.nodeId) == null) { + if (document.getNodeById(extentDocumentPosition.nodeId) == null) { return false; } - composer.selection = DocumentSelection( - base: baseDocumentPosition, - extent: extentDocumentPosition, - ); + editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: baseDocumentPosition, + extent: extentDocumentPosition, + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); return true; } @@ -147,7 +168,7 @@ class CommonEditorOperations { return false; } - final selectedNode = editor.document.getNodeById(composer.selection!.extent.nodeId); + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId); if (selectedNode is! TextNode) { return false; } @@ -157,7 +178,7 @@ class CommonEditorOperations { baseOffset: (docSelection.base.nodePosition as TextNodePosition).offset, extentOffset: (docSelection.extent.nodePosition as TextNodePosition).offset, ); - final selectedText = currentSelection.textInside(selectedNode.text.text); + final selectedText = currentSelection.textInside(selectedNode.text.toPlainText()); if (selectedText.contains(' ')) { // The selection already spans multiple paragraphs. Nothing to do. @@ -165,21 +186,21 @@ class CommonEditorOperations { } final wordTextSelection = expandPositionToWord( - text: selectedNode.text.text, + text: selectedNode.text.toPlainText(), textPosition: TextPosition(offset: (docSelection.extent.nodePosition as TextNodePosition).offset), ); final wordNodeSelection = TextNodeSelection.fromTextSelection(wordTextSelection); - composer.selection = DocumentSelection( - base: DocumentPosition( - nodeId: selectedNode.id, - nodePosition: wordNodeSelection.base, - ), - extent: DocumentPosition( - nodeId: selectedNode.id, - nodePosition: wordNodeSelection.extent, + editor.execute([ + ChangeSelectionRequest( + selectedNode.selectionBetween( + wordNodeSelection.baseOffset, + wordNodeSelection.extentOffset, + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, ), - ); + ]); return true; } @@ -189,21 +210,26 @@ class CommonEditorOperations { /// /// Always returns [true]. bool selectAll() { - final nodes = editor.document.nodes; - if (nodes.isEmpty) { + if (document.isEmpty) { return false; } - composer.selection = DocumentSelection( - base: DocumentPosition( - nodeId: nodes.first.id, - nodePosition: nodes.first.beginningPosition, - ), - extent: DocumentPosition( - nodeId: nodes.last.id, - nodePosition: nodes.last.endPosition, + editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: document.first.id, + nodePosition: document.first.beginningPosition, + ), + extent: DocumentPosition( + nodeId: document.last.id, + nodePosition: document.last.endPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, ), - ); + ]); return true; } @@ -218,7 +244,13 @@ class CommonEditorOperations { return false; } - composer.selection = composer.selection!.collapse(); + editor.execute([ + ChangeSelectionRequest( + composer.selection!.collapse(), + SelectionChangeType.collapseSelection, + SelectionReason.userInteraction, + ), + ]); return true; } @@ -240,6 +272,8 @@ class CommonEditorOperations { /// in [movementModifier]. To move to the beginning of a line, pass /// [MovementModifier.line] in [movementModifier]. /// + /// Clears the composing region. + /// /// Returns [true] if the extent moved, or the selection changed, e.g., the /// selection collapsed but the extent stayed in the same place. Returns /// [false] if the extent did not move and the selection did not change. @@ -252,13 +286,25 @@ class CommonEditorOperations { } if (!composer.selection!.isCollapsed && !expand) { - composer.selection = composer.selection!.collapseUpstream(editor.document); + editor.execute([ + ChangeSelectionRequest( + composer.selection!.collapseUpstream(document), + SelectionChangeType.collapseSelection, + SelectionReason.userInteraction, + ), + // Since we are moving the caret, we clear the composing region (if any) + // to avoid lefting a composing region far from the caret. Without this, + // we can end up with a selection in one node and a composing region + // in another node. + const ClearComposingRegionRequest(), + ]); + return true; } final currentExtent = composer.selection!.extent; final nodeId = currentExtent.nodeId; - final node = editor.document.getNodeById(nodeId); + final node = document.getNodeById(nodeId); if (node == null) { return false; } @@ -272,6 +318,12 @@ class CommonEditorOperations { extentComponent.movePositionLeft(currentExtent.nodePosition, movementModifier); if (newExtentNodePosition == null) { + if (movementModifier == MovementModifier.line) { + // The user is trying to move to the beginning of the current line, + // and we're already there. Do nothing. + return false; + } + // Move to next node final nextNode = _getUpstreamSelectableNodeBefore(node); @@ -294,15 +346,37 @@ class CommonEditorOperations { ); if (expand) { - // Selection should be expanded. - composer.selection = composer.selection!.expandTo( - newExtent, - ); + // Push the extent of an expanded selection. + editor.execute([ + ChangeSelectionRequest( + composer.selection!.expandTo( + newExtent, + ), + SelectionChangeType.pushExtent, + SelectionReason.userInteraction, + ), + // Since we are moving the caret, we clear the composing region (if any) + // to avoid lefting a composing region far from the caret. Without this, + // we can end up with a selection in one node and a composing region + // in another node. + const ClearComposingRegionRequest(), + ]); } else { - // Selection should be replaced by new collapsed position. - composer.selection = DocumentSelection.collapsed( - position: newExtent, - ); + // Push the caret upstream. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: newExtent, + ), + SelectionChangeType.pushCaret, + SelectionReason.userInteraction, + ), + // Since we are moving the caret, we clear the composing region (if any) + // to avoid lefting a composing region far from the caret. Without this, + // we can end up with a selection in one node and a composing region + // in another node. + const ClearComposingRegionRequest(), + ]); } return true; @@ -321,6 +395,8 @@ class CommonEditorOperations { /// in [movementModifier]. To move to the end of a line, pass /// [MovementModifier.line] in [movementModifier]. /// + /// Clears any composing region. + /// /// Returns [true] if the extent moved, or the selection changed, e.g., the /// selection collapsed but the extent stayed in the same place. Returns /// [false] if the extent did not move and the selection did not change. @@ -333,13 +409,25 @@ class CommonEditorOperations { } if (!composer.selection!.isCollapsed && !expand) { - composer.selection = composer.selection!.collapseDownstream(editor.document); + editor.execute([ + ChangeSelectionRequest( + composer.selection!.collapseDownstream(document), + SelectionChangeType.collapseSelection, + SelectionReason.userInteraction, + ), + // Since we are moving the caret, we clear the composing region (if any) + // to avoid lefting a composing region far from the caret. Without this, + // we can end up with a selection in one node and a composing region + // in another node. + const ClearComposingRegionRequest(), + ]); + return true; } final currentExtent = composer.selection!.extent; final nodeId = currentExtent.nodeId; - final node = editor.document.getNodeById(nodeId); + final node = document.getNodeById(nodeId); if (node == null) { return false; } @@ -353,6 +441,12 @@ class CommonEditorOperations { extentComponent.movePositionRight(currentExtent.nodePosition, movementModifier); if (newExtentNodePosition == null) { + if (movementModifier == MovementModifier.line) { + // The user is trying to move to the end of the current line, + // and we're already there. Do nothing. + return false; + } + // Move to next node final nextNode = _getDownstreamSelectableNodeAfter(node); @@ -377,15 +471,37 @@ class CommonEditorOperations { ); if (expand) { - // Selection should be expanded. - composer.selection = composer.selection!.expandTo( - newExtent, - ); + // Push the extent of an expanded selection downstream. + editor.execute([ + ChangeSelectionRequest( + composer.selection!.expandTo( + newExtent, + ), + SelectionChangeType.pushExtent, + SelectionReason.userInteraction, + ), + // Since we are moving the caret, we clear the composing region (if any) + // to avoid lefting a composing region far from the caret. Without this, + // we can end up with a selection in one node and a composing region + // in another node. + const ClearComposingRegionRequest(), + ]); } else { - // Selection should be replaced by new collapsed position. - composer.selection = DocumentSelection.collapsed( - position: newExtent, - ); + // Push the caret downstream. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: newExtent, + ), + SelectionChangeType.pushCaret, + SelectionReason.userInteraction, + ), + // Since we are moving the caret, we clear the composing region (if any) + // to avoid lefting a composing region far from the caret. Without this, + // we can end up with a selection in one node and a composing region + // in another node. + const ClearComposingRegionRequest(), + ]); } return true; @@ -407,6 +523,8 @@ class CommonEditorOperations { /// Expands/contracts the selection if [expand] is [true], otherwise /// collapses the selection or keeps it collapsed. /// + /// Clears any composing region. + /// /// Returns [true] if the extent moved, or the selection changed, e.g., the /// selection collapsed but the extent stayed in the same place. Returns /// [false] if the extent did not move and the selection did not change. @@ -419,7 +537,7 @@ class CommonEditorOperations { final currentExtent = composer.selection!.extent; final nodeId = currentExtent.nodeId; - final node = editor.document.getNodeById(nodeId); + final node = document.getNodeById(nodeId); if (node == null) { return false; } @@ -476,6 +594,8 @@ class CommonEditorOperations { /// Expands/contracts the selection if [expand] is [true], otherwise /// collapses the selection or keeps it collapsed. /// + /// Clears any composing region. + /// /// Returns [true] if the extent moved, or the selection changed, e.g., the /// selection collapsed but the extent stayed in the same place. Returns /// [false] if the extent did not move and the selection did not change. @@ -488,7 +608,7 @@ class CommonEditorOperations { final currentExtent = composer.selection!.extent; final nodeId = currentExtent.nodeId; - final node = editor.document.getNodeById(nodeId); + final node = document.getNodeById(nodeId); if (node == null) { return false; } @@ -580,16 +700,166 @@ class CommonEditorOperations { return true; } + /// Moves the [DocumentComposer]'s selection extent to the beginning of the document. + /// + /// Expands/contracts the selection if [expand] is `true`, otherwise + /// collapses the selection or keeps it collapsed. + /// + /// Returns `true` if the extent moved, or the selection changed, e.g., the + /// selection collapsed but the extent stayed in the same place. Returns + /// `false` if the extent did not move and the selection did not change. + bool moveSelectionToBeginningOfDocument({ + bool expand = false, + }) { + if (composer.selection == null) { + return false; + } + + if (document.isEmpty) { + return false; + } + + final firstNode = document.first; + + if (expand) { + final currentExtentNode = document.getNodeById(composer.selection!.extent.nodeId); + if (currentExtentNode == null) { + return false; + } + + if (currentExtentNode is! TextNode) { + return false; + } + + editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: composer.selection!.base, + extent: DocumentPosition( + nodeId: firstNode.id, + nodePosition: firstNode.beginningPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + + return true; + } + + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: firstNode.id, + nodePosition: firstNode.beginningPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + return true; + } + + /// Moves the [DocumentComposer]'s selection extent to the end of the document. + /// + /// Expands/contracts the selection if [expand] is `true`, otherwise + /// collapses the selection or keeps it collapsed. + /// + /// Returns `true` if the extent moved, or the selection changed, e.g., the + /// selection collapsed but the extent stayed in the same place. Returns + /// `false` if the extent did not move and the selection did not change. + bool moveSelectionToEndOfDocument({ + bool expand = false, + }) { + if (composer.selection == null) { + return false; + } + + if (document.isEmpty) { + return false; + } + + final lastNode = document.last; + + if (expand) { + final currentExtentNode = document.getNodeById(composer.selection!.extent.nodeId); + if (currentExtentNode == null) { + return false; + } + + if (currentExtentNode is! TextNode) { + return false; + } + + editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: composer.selection!.base, + extent: DocumentPosition( + nodeId: lastNode.id, + nodePosition: lastNode.endPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + + return true; + } + + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: lastNode.id, + nodePosition: lastNode.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + return true; + } + void _updateSelectionExtent({ required DocumentPosition position, required bool expandSelection, }) { if (expandSelection) { // Selection should be expanded. - composer.selection = composer.selection!.expandTo(position); + editor.execute([ + ChangeSelectionRequest( + composer.selection!.expandTo(position), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + // Since we are moving the caret, we clear the composing region (if any) + // to avoid lefting a composing region far from the caret. Without this, + // we can end up with a selection in one node and a composing region + // in another node. + const ClearComposingRegionRequest() + ]); } else { // Selection should be replaced by new collapsed position. - composer.selection = DocumentSelection.collapsed(position: position); + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed(position: position), + SelectionChangeType.collapseSelection, + SelectionReason.userInteraction, + ), + // Since we are moving the caret, we clear the composing region (if any) + // to avoid lefting a composing region far from the caret. Without this, + // we can end up with a selection in one node and a composing region + // in another node. + const ClearComposingRegionRequest(), + ]); } } @@ -600,7 +870,7 @@ class CommonEditorOperations { DocumentNode prevNode = startingNode; DocumentNode? selectableNode; do { - selectableNode = editor.document.getNodeBefore(prevNode); + selectableNode = document.getNodeBeforeById(prevNode.id); if (selectableNode != null) { final nextComponent = documentLayoutResolver().getComponentByNodeId(selectableNode.id); @@ -621,7 +891,7 @@ class CommonEditorOperations { DocumentNode prevNode = startingNode; DocumentNode? selectableNode; do { - selectableNode = editor.document.getNodeAfter(prevNode); + selectableNode = document.getNodeAfterById(prevNode.id); if (selectableNode != null) { final nextComponent = documentLayoutResolver().getComponentByNodeId(selectableNode.id); @@ -655,17 +925,23 @@ class CommonEditorOperations { if (!composer.selection!.isCollapsed) { // A span of content is selected. Delete the selection. - _deleteExpandedSelection(); + _deleteExpandedSelection(TextAffinity.downstream); return true; } if (composer.selection!.extent.nodePosition is UpstreamDownstreamNodePosition) { final nodePosition = composer.selection!.extent.nodePosition as UpstreamDownstreamNodePosition; if (nodePosition.affinity == TextAffinity.upstream) { - // The caret is sitting on the upstream edge of block-level content. Delete the - // whole block by replacing it with an empty paragraph. + // The caret is sitting on the upstream edge of block-level content. final nodeId = composer.selection!.extent.nodeId; - _replaceBlockNodeWithEmptyParagraphAndCollapsedSelection(nodeId); + + if (!document.getNodeById(nodeId)!.isDeletable) { + // The node is not deletable. Fizzle. + return false; + } + + //Delete the whole block by replacing it with an empty paragraph. + replaceBlockNodeWithEmptyParagraphAndCollapsedSelection(nodeId); return true; } else { @@ -679,10 +955,10 @@ class CommonEditorOperations { if (composer.selection!.extent.nodePosition is TextNodePosition) { final textPosition = composer.selection!.extent.nodePosition as TextNodePosition; - final text = (editor.document.getNodeById(composer.selection!.extent.nodeId) as TextNode).text.text; + final text = (document.getNodeById(composer.selection!.extent.nodeId) as TextNode).text; if (textPosition.offset == text.length) { - final node = editor.document.getNodeById(composer.selection!.extent.nodeId)!; - final nodeAfter = editor.document.getNodeAfter(node); + final node = document.getNodeById(composer.selection!.extent.nodeId)!; + final nodeAfter = document.getNodeAfterById(node.id); if (nodeAfter is TextNode) { // The caret is at the end of one TextNode and is followed by @@ -691,14 +967,19 @@ class CommonEditorOperations { } else if (nodeAfter != null) { final componentAfter = documentLayoutResolver().getComponentByNodeId(nodeAfter.id)!; - if (componentAfter.isVisualSelectionSupported()) { + if (nodeAfter is BlockNode && !nodeAfter.isDeletable) { + // The user is trying to delete at the end of a node, and the downstream node + // is not deletable. Skip the non-deletable node and try to merge the selected + // node with the next non-deletable node. + return _mergeTextNodeWithDownstreamTextNode(); + } else if (componentAfter.isVisualSelectionSupported()) { // The caret is at the end of a TextNode, but the next node // is not a TextNode. Move the document selection to the // next node. return _moveSelectionToBeginningOfNextNode(); } else { // The next node/component isn't selectable. Delete it. - _deleteNonSelectedNode(nodeAfter); + deleteNonSelectedNode(nodeAfter); return true; } } @@ -715,28 +996,34 @@ class CommonEditorOperations { return false; } - final node = editor.document.getNodeById(composer.selection!.extent.nodeId); + final node = document.getNodeById(composer.selection!.extent.nodeId); if (node == null) { return false; } - final nodeAfter = editor.document.getNodeAfter(node); + final nodeAfter = document.getNodeAfterById(node.id); if (nodeAfter == null) { return false; } - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: nodeAfter.id, - nodePosition: nodeAfter.beginningPosition, + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeAfter.id, + nodePosition: nodeAfter.beginningPosition, + ), + ), + SelectionChangeType.pushCaret, + SelectionReason.userInteraction, ), - ); + ]); return true; } bool _mergeTextNodeWithDownstreamTextNode() { - final node = editor.document.getNodeById(composer.selection!.extent.nodeId); + final node = document.getNodeById(composer.selection!.extent.nodeId); if (node == null) { return false; } @@ -744,7 +1031,11 @@ class CommonEditorOperations { return false; } - final nodeAfter = editor.document.getNodeAfter(node); + DocumentNode? nodeAfter = document.getNodeAfterById(node.id); + while (nodeAfter is BlockNode && !nodeAfter.isDeletable) { + nodeAfter = document.getNodeAfterById(nodeAfter.id); + } + if (nodeAfter == null) { return false; } @@ -752,23 +1043,25 @@ class CommonEditorOperations { return false; } - final firstNodeTextLength = node.text.text.length; + final firstNodeTextLength = node.text.length; // Send edit command. - editor.executeCommand( - CombineParagraphsCommand( + editor.execute([ + CombineParagraphsRequest( firstNodeId: node.id, secondNodeId: nodeAfter.id, ), - ); - - // Place the cursor at the point where the text came together. - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: node.id, - nodePosition: TextNodePosition(offset: firstNodeTextLength), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: node.id, + nodePosition: TextNodePosition(offset: firstNodeTextLength), + ), + ), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, ), - ); + ]); return true; } @@ -777,37 +1070,31 @@ class CommonEditorOperations { if (composer.selection == null) { return false; } - if (!_isTextEntryNode(document: editor.document, selection: composer.selection!)) { + if (!_isTextEntryNode(document: document, selection: composer.selection!)) { return false; } if (composer.selection!.isCollapsed && (composer.selection!.extent.nodePosition as TextNodePosition).offset < 0) { return false; } - final textNode = editor.document.getNode(composer.selection!.extent) as TextNode; + final textNode = document.getNode(composer.selection!.extent) as TextNode; final text = textNode.text; - final currentTextPosition = (composer.selection!.extent.nodePosition as TextNodePosition); - if (currentTextPosition.offset >= text.text.length) { + final currentTextOffset = (composer.selection!.extent.nodePosition as TextNodePosition).offset; + if (currentTextOffset >= text.length) { return false; } - final nextCharacterOffset = getCharacterEndBounds(text.text, currentTextPosition.offset); + final nextCharacterOffset = getCharacterEndBounds(text.toPlainText(), currentTextOffset); // Delete the selected content. - editor.executeCommand( - DeleteSelectionCommand( - documentSelection: DocumentSelection( - base: DocumentPosition( - nodeId: textNode.id, - nodePosition: currentTextPosition, - ), - extent: DocumentPosition( - nodeId: textNode.id, - nodePosition: TextNodePosition(offset: nextCharacterOffset), - ), + editor.execute([ + DeleteContentRequest( + documentRange: textNode.selectionBetween( + currentTextOffset, + nextCharacterOffset, ), ), - ); + ]); return true; } @@ -832,11 +1119,11 @@ class CommonEditorOperations { if (!composer.selection!.isCollapsed) { // A span of content is selected. Delete the selection. - _deleteExpandedSelection(); + _deleteExpandedSelection(TextAffinity.upstream); return true; } - final node = editor.document.getNodeById(composer.selection!.extent.nodeId)!; + final node = document.getNodeById(composer.selection!.extent.nodeId)!; // If the caret is at the beginning of a list item, unindent the list item. if (node is ListItemNode && (composer.selection!.extent.nodePosition as TextNodePosition).offset == 0) { @@ -846,10 +1133,16 @@ class CommonEditorOperations { if (composer.selection!.extent.nodePosition is UpstreamDownstreamNodePosition) { final nodePosition = composer.selection!.extent.nodePosition as UpstreamDownstreamNodePosition; if (nodePosition.affinity == TextAffinity.downstream) { - // The caret is sitting on the downstream edge of block-level content. Delete the - // whole block by replacing it with an empty paragraph. + // The caret is sitting on the downstream edge of block-level content. final nodeId = composer.selection!.extent.nodeId; - _replaceBlockNodeWithEmptyParagraphAndCollapsedSelection(nodeId); + + if (!document.getNodeById(nodeId)!.isDeletable) { + // The node is not deletable. Fizzle. + return false; + } + + // Delete the whole block by replacing it with an empty paragraph. + replaceBlockNodeWithEmptyParagraphAndCollapsedSelection(nodeId); return true; } else { @@ -858,34 +1151,34 @@ class CommonEditorOperations { // * If the node above is an empty paragraph, delete it. // * If the node above is non-selectable, delete it. // * Otherwise, move the caret up to the node above. - final nodeBefore = editor.document.getNodeBefore(node); + final nodeBefore = document.getNodeBeforeById(node.id); if (nodeBefore == null) { return false; } final componentBefore = documentLayoutResolver().getComponentByNodeId(nodeBefore.id)!; - if (nodeBefore is TextNode && nodeBefore.text.text.isEmpty) { - editor.executeCommand(EditorCommandFunction((doc, transaction) { - transaction.deleteNode(nodeBefore); - })); + if (nodeBefore is TextNode && nodeBefore.text.isEmpty) { + editor.execute([ + DeleteNodeRequest(nodeId: nodeBefore.id), + ]); return true; } - if (!componentBefore.isVisualSelectionSupported()) { + if (!componentBefore.isVisualSelectionSupported() && nodeBefore.isDeletable) { // The node/component above is not selectable. Delete it. - _deleteNonSelectedNode(nodeBefore); + deleteNonSelectedNode(nodeBefore); return true; } - return _moveSelectionToEndOfPrecedingNode(); + return _moveSelectionToEndOfFirstSelectableUpstreamNode(); } } if (composer.selection!.extent.nodePosition is TextNodePosition) { final textPosition = composer.selection!.extent.nodePosition as TextNodePosition; if (textPosition.offset == 0) { - final nodeBefore = editor.document.getNodeBefore(node); + final nodeBefore = document.getNodeBeforeById(node.id); if (nodeBefore == null) { return false; } @@ -895,67 +1188,130 @@ class CommonEditorOperations { if (nodeBefore is TextNode) { // The caret is at the beginning of one TextNode and is preceded by // another TextNode. Merge the two TextNodes. - return _mergeTextNodeWithUpstreamTextNode(); + return mergeTextNodeWithUpstreamTextNode(); + } else if (nodeBefore is BlockNode && !nodeBefore.isDeletable) { + return mergeTextNodeWithUpstreamTextNode(); } else if (!componentBefore.isVisualSelectionSupported()) { // The node/component above is not selectable. Delete it. - _deleteNonSelectedNode(nodeBefore); + deleteNonSelectedNode(nodeBefore); return true; - } else if ((node as TextNode).text.text.isEmpty) { + } else if ((node as TextNode).text.isEmpty) { // The caret is at the beginning of an empty TextNode and the preceding // node is not a TextNode. Delete the current TextNode and move the // selection up to the preceding node if exist. - if (_moveSelectionToEndOfPrecedingNode()) { - editor.executeCommand(EditorCommandFunction((doc, transaction) { - transaction.deleteNode(node); - })); + if (moveSelectionToEndOfPrecedingNode()) { + editor.execute([ + DeleteNodeRequest(nodeId: node.id), + ]); } return true; } else { // The caret is at the beginning of a non-empty TextNode, and the // preceding node is not a TextNode. Move the document selection to the // preceding node. - return _moveSelectionToEndOfPrecedingNode(); + return moveSelectionToEndOfPrecedingNode(); } } else { - return _deleteUpstreamCharacter(); + editor.execute([const DeleteUpstreamCharacterRequest()]); + return true; } } return false; } - bool _moveSelectionToEndOfPrecedingNode() { + bool moveSelectionToEndOfPrecedingNode() { if (composer.selection == null) { return false; } - final node = editor.document.getNodeById(composer.selection!.extent.nodeId); + final node = document.getNodeById(composer.selection!.extent.nodeId); if (node == null) { return false; } - final nodeBefore = editor.document.getNodeBefore(node); + final nodeBefore = document.getNodeBeforeById(node.id); if (nodeBefore == null) { return false; } - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: nodeBefore.id, - nodePosition: nodeBefore.endPosition, + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeBefore.id, + nodePosition: nodeBefore.endPosition, + ), + ), + SelectionChangeType.collapseSelection, + SelectionReason.userInteraction, ), - ); + ]); return true; } - bool _mergeTextNodeWithUpstreamTextNode() { - final node = editor.document.getNodeById(composer.selection!.extent.nodeId); + /// Finds the first visually selectable node above the selection extent + /// and changes the selection to its end. + /// + /// Does nothing if no selectable node is found. + bool _moveSelectionToEndOfFirstSelectableUpstreamNode() { + if (composer.selection == null) { + return false; + } + + final node = document.getNodeById(composer.selection!.extent.nodeId); + if (node == null) { + return false; + } + + DocumentNode? nodeBefore = document.getNodeBeforeById(node.id); + while (nodeBefore != null) { + final component = documentLayoutResolver().getComponentByNodeId(nodeBefore.id); + if (component == null) { + // Assume we are in a transitive state where the node was created, but + // the component is not yet available. + return false; + } + if (component.isVisualSelectionSupported()) { + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeBefore.id, + nodePosition: nodeBefore.endPosition, + ), + ), + SelectionChangeType.collapseSelection, + SelectionReason.userInteraction, + ), + ]); + + return true; + } + + nodeBefore = document.getNodeBeforeById(nodeBefore.id); + } + + // We didn't find any selectable nodes before the current node. + return false; + } + + /// Merges the selected [TextNode] with the upstream [TextNode]. + /// + /// If there are non-deletable [BlockNode]s between the two [TextNode]s, + /// the [BlockNode]s are ignored. + bool mergeTextNodeWithUpstreamTextNode() { + final node = document.getNodeById(composer.selection!.extent.nodeId); if (node == null) { return false; } - final nodeAbove = editor.document.getNodeBefore(node); + DocumentNode? nodeAbove = document.getNodeBeforeById(node.id); + while (nodeAbove != null && nodeAbove is BlockNode && !nodeAbove.isDeletable) { + nodeAbove = document.getNodeBeforeById(nodeAbove.id); + } + if (nodeAbove == null) { return false; } @@ -963,42 +1319,47 @@ class CommonEditorOperations { return false; } - final aboveParagraphLength = nodeAbove.text.text.length; + final aboveParagraphLength = nodeAbove.text.length; // Send edit command. - editor.executeCommand( - CombineParagraphsCommand( + editor.execute([ + CombineParagraphsRequest( firstNodeId: nodeAbove.id, secondNodeId: node.id, ), - ); - - // Place the cursor at the point where the text came together. - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: nodeAbove.id, - nodePosition: TextNodePosition(offset: aboveParagraphLength), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeAbove.id, + nodePosition: TextNodePosition(offset: aboveParagraphLength), + ), + ), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, ), - ); + // Since two paragraphs were combined, the composing region might point + // to a deleted paragraph. Clear it. + const ClearComposingRegionRequest(), + ]); return true; } - bool _deleteUpstreamCharacter() { + bool deleteUpstreamCharacter() { if (composer.selection == null) { return false; } - if (!_isTextEntryNode(document: editor.document, selection: composer.selection!)) { + if (!_isTextEntryNode(document: document, selection: composer.selection!)) { return false; } if (composer.selection!.isCollapsed && (composer.selection!.extent.nodePosition as TextNodePosition).offset <= 0) { return false; } - final textNode = editor.document.getNode(composer.selection!.extent) as TextNode; - final currentTextPosition = composer.selection!.extent.nodePosition as TextNodePosition; + final textNode = document.getNode(composer.selection!.extent) as TextNode; + final currentTextOffset = (composer.selection!.extent.nodePosition as TextNodePosition).offset; - final previousCharacterOffset = getCharacterStartBounds(textNode.text.text, currentTextPosition.offset); + final previousCharacterOffset = getCharacterStartBounds(textNode.text.toPlainText(), currentTextOffset); final newSelectionPosition = DocumentPosition( nodeId: textNode.id, @@ -1006,22 +1367,19 @@ class CommonEditorOperations { ); // Delete the selected content. - editor.executeCommand( - DeleteSelectionCommand( - documentSelection: DocumentSelection( - base: DocumentPosition( - nodeId: textNode.id, - nodePosition: currentTextPosition, - ), - extent: DocumentPosition( - nodeId: textNode.id, - nodePosition: TextNodePosition(offset: previousCharacterOffset), - ), + editor.execute([ + DeleteContentRequest( + documentRange: textNode.selectionBetween( + currentTextOffset, + previousCharacterOffset, ), ), - ); - - composer.selection = DocumentSelection.collapsed(position: newSelectionPosition); + ChangeSelectionRequest( + DocumentSelection.collapsed(position: newSelectionPosition), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ) + ]); return true; } @@ -1031,34 +1389,22 @@ class CommonEditorOperations { /// /// This can be used, for example, to effectively delete an image by replacing /// it with an empty paragraph. - void _replaceBlockNodeWithEmptyParagraphAndCollapsedSelection(String nodeId) { - editor.executeCommand(EditorCommandFunction((doc, transaction) { - final oldNode = doc.getNodeById(nodeId); - if (oldNode == null) { - return; - } - - final newNode = ParagraphNode( - id: oldNode.id, - text: AttributedText(), - ); - - transaction.replaceNode(oldNode: oldNode, newNode: newNode); - - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: newNode.id, - nodePosition: newNode.beginningPosition, - ), - ); - })); + void replaceBlockNodeWithEmptyParagraphAndCollapsedSelection(String nodeId) { + editor.execute([ + ReplaceNodeWithEmptyParagraphWithCaretRequest(nodeId: nodeId), + ]); } /// Deletes all selected content. /// + /// The [affinity] defines the direction to where the user is trying to + /// delete. For example, if the users presses the backspace key, the + /// [affinity] should be [TextAffinity.upstream]. If the user presses the + /// delete key, the [affinity] should be [TextAffinity.downstream]. + /// /// Returns [true] if content was deleted, or [false] if no content was /// selected. - bool deleteSelection() { + bool deleteSelection(TextAffinity affinity) { if (composer.selection == null) { return false; } @@ -1069,39 +1415,41 @@ class CommonEditorOperations { // The document selection includes a span of content. It may or may not // cross nodes. Either way, delete the selected content. - _deleteExpandedSelection(); + _deleteExpandedSelection(affinity); return true; } - void _deleteExpandedSelection() { - final newSelectionPosition = getDocumentPositionAfterExpandedDeletion( - document: editor.document, - selection: composer.selection!, - ); - + void _deleteExpandedSelection(TextAffinity affinity) { // Delete the selected content. - editor.executeCommand( - DeleteSelectionCommand(documentSelection: composer.selection!), - ); - - composer.selection = DocumentSelection.collapsed(position: newSelectionPosition); + editor.execute([ + DeleteSelectionRequest(affinity), + ]); } /// Returns the [DocumentPosition] where the caret should sit after deleting /// the given [selection] from the given [document]. /// + /// Returns `null` if there are no deletable nodes within the [selection]. + /// /// This method doesn't delete any content. Instead, it determines what would /// be deleted if a delete operation was run for the given [selection]. Based /// on the shared understanding of content deletion rules, the resulting caret /// position is returned. // TODO: Move this method to an appropriate place. It was made public and static // because document_keyboard_actions.dart also uses this behavior. - static DocumentPosition getDocumentPositionAfterExpandedDeletion({ + static DocumentPosition? getDocumentPositionAfterExpandedDeletion({ required Document document, required DocumentSelection selection, }) { // Figure out where the caret should appear after the // deletion. + + if (selection.isCollapsed) { + // There is no expanded deletion when the selection is collapsed. Therefore, + // no selection change is expected. + return null; + } + // TODO: This calculation depends upon the first // selected node still existing after the deletion. This // is a fragile expectation and should be revisited. @@ -1110,33 +1458,52 @@ class CommonEditorOperations { if (baseNode == null) { throw Exception('Failed to _getDocumentPositionAfterDeletion because the base node no longer exists.'); } - final baseNodeIndex = document.getNodeIndexById(baseNode.id); final extentPosition = selection.extent; final extentNode = document.getNode(extentPosition); if (extentNode == null) { throw Exception('Failed to _getDocumentPositionAfterDeletion because the extent node no longer exists.'); } - final extentNodeIndex = document.getNodeIndexById(extentNode.id); - final topNodeIndex = min(baseNodeIndex, extentNodeIndex); - final topNode = document.getNodeAt(topNodeIndex)!; - final topNodePosition = baseNodeIndex < extentNodeIndex ? basePosition.nodePosition : extentPosition.nodePosition; + final selectionAffinity = document.getAffinityForSelection(selection); + final topPosition = selectionAffinity == TextAffinity.downstream // + ? selection.base + : selection.extent; + final topNodePosition = topPosition.nodePosition; + final topNode = document.getNodeById(topPosition.nodeId)!; + + final bottomPosition = selectionAffinity == TextAffinity.downstream // + ? selection.extent + : selection.base; + final bottomNodePosition = bottomPosition.nodePosition; + final bottomNode = document.getNodeById(bottomPosition.nodeId)!; - final bottomNodeIndex = max(baseNodeIndex, extentNodeIndex); - final bottomNode = document.getNodeAt(bottomNodeIndex)!; - final bottomNodePosition = - baseNodeIndex < extentNodeIndex ? extentPosition.nodePosition : basePosition.nodePosition; + final normalizedRange = selection.normalize(document); + final nodes = document.getNodesInside(normalizedRange.start, normalizedRange.end); + final firstDeletableNodeId = nodes.firstWhereOrNull((node) => node.isDeletable)?.id; DocumentPosition newSelectionPosition; - if (baseNodeIndex != extentNodeIndex) { + if (topPosition.nodeId != bottomPosition.nodeId) { if (topNodePosition == topNode.beginningPosition && bottomNodePosition == bottomNode.endPosition) { - // All nodes in the selection will be deleted. Assume that the base - // node will be retained and converted into a paragraph, if it's not + // All deletable nodes in the selection will be deleted. Assume that one of the + // nodes will be retained and converted into a paragraph, if it's not // already a paragraph. + + final emptyParagraphId = topNode.isDeletable + ? topNode.id + : bottomNode.isDeletable + ? bottomNode.id + : firstDeletableNodeId; + + if (emptyParagraphId == null) { + // There are no deletable nodes in the selection. Fizzle. + // We don't expect this method to be called if there are no deletable nodes. + return null; + } + newSelectionPosition = DocumentPosition( - nodeId: baseNode.id, + nodeId: emptyParagraphId, nodePosition: const TextNodePosition(offset: 0), ); } else if (topNodePosition == topNode.beginningPosition) { @@ -1158,7 +1525,7 @@ class CommonEditorOperations { // those nodes will remain. // The caret should end up at the base position - newSelectionPosition = baseNodeIndex <= extentNodeIndex ? selection.base : selection.extent; + newSelectionPosition = selectionAffinity == TextAffinity.downstream ? selection.base : selection.extent; } } else { // Selection is within a single node. @@ -1191,11 +1558,11 @@ class CommonEditorOperations { return newSelectionPosition; } - void _deleteNonSelectedNode(DocumentNode node) { + void deleteNonSelectedNode(DocumentNode node) { assert(composer.selection?.base.nodeId != node.id); assert(composer.selection?.extent.nodeId != node.id); - editor.executeCommand(DeleteNodeCommand(nodeId: node.id)); + editor.execute([DeleteNodeRequest(nodeId: node.id)]); } /// Adds the given [attributions] to all [AttributedText] within the @@ -1212,12 +1579,12 @@ class CommonEditorOperations { return false; } - editor.executeCommand( - AddTextAttributionsCommand( - documentSelection: composer.selection!, + editor.execute([ + AddTextAttributionsRequest( + documentRange: composer.selection!, attributions: attributions, ), - ); + ]); return false; } @@ -1236,12 +1603,12 @@ class CommonEditorOperations { return false; } - editor.executeCommand( - RemoveTextAttributionsCommand( - documentSelection: composer.selection!, + editor.execute([ + RemoveTextAttributionsRequest( + documentRange: composer.selection!, attributions: attributions, ), - ); + ]); return false; } @@ -1260,12 +1627,12 @@ class CommonEditorOperations { return false; } - editor.executeCommand( - ToggleTextAttributionsCommand( - documentSelection: composer.selection!, + editor.execute([ + ToggleTextAttributionsRequest( + documentRange: composer.selection!, attributions: attributions, ), - ); + ]); return false; } @@ -1317,6 +1684,7 @@ class CommonEditorOperations { /// Returns `true` if the [text] was successfully inserted, or [false] /// if it wasn't, e.g., there was no selection, or more than one node /// was selected. + @Deprecated("Execute a relevant EditRequest in an Editor, e.g., InsertPlainTextAtCaretRequest.") bool insertPlainText(String text) { editorOpsLog.fine('Attempting to insert "$text" at document selection: ${composer.selection}'); if (composer.selection == null) { @@ -1328,7 +1696,19 @@ class CommonEditorOperations { // The selection is expanded. Delete the selected content // and then insert the new text. editorOpsLog.fine("The selection is expanded. Deleting the selection before inserting text."); - _deleteExpandedSelection(); + + // As we are replacing text by deleting the selection and then inserting the new text, + // we need to store the current attributions. + // This is required as deleting text can clear the composer current attributions. + // Without this, the new text doesn't preserve the attributions of the replaced text. + final composerAttributions = {...composer.preferences.currentAttributions}; + + _deleteExpandedSelection(TextAffinity.downstream); + + // Restore the previous attributions. + composer.preferences + ..clearStyles() + ..addStyles(composerAttributions); } final extentNodePosition = composer.selection!.extent.nodePosition; @@ -1337,34 +1717,21 @@ class CommonEditorOperations { insertBlockLevelNewline(); } - final extentNode = editor.document.getNodeById(composer.selection!.extent.nodeId)!; + final extentNode = document.getNodeById(composer.selection!.extent.nodeId)!; if (extentNode is! TextNode) { editorOpsLog .fine("Couldn't insert text because Super Editor doesn't know how to handle a node of type: $extentNode"); return false; } - final textNode = editor.document.getNode(composer.selection!.extent) as TextNode; - final initialTextOffset = (composer.selection!.extent.nodePosition as TextNodePosition).offset; - editorOpsLog.fine("Executing text insertion command."); - editor.executeCommand( - InsertTextCommand( + editor.execute([ + InsertTextRequest( documentPosition: composer.selection!.extent, textToInsert: text, attributions: composer.preferences.currentAttributions, ), - ); - - editorOpsLog.fine("Updating Document Composer selection after text insertion."); - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: textNode.id, - nodePosition: TextNodePosition( - offset: initialTextOffset + text.length, - ), - ), - ); + ]); return true; } @@ -1393,16 +1760,16 @@ class CommonEditorOperations { } if (!composer.selection!.isCollapsed) { - _deleteExpandedSelection(); + _deleteExpandedSelection(TextAffinity.downstream); } final extentNodePosition = composer.selection!.extent.nodePosition; if (extentNodePosition is UpstreamDownstreamNodePosition) { editorOpsLog.fine("The selected position is an UpstreamDownstreamPosition. Inserting new paragraph first."); - insertBlockLevelNewline(); + editor.execute([InsertNewlineAtCaretRequest()]); } - final extentNode = editor.document.getNodeById(composer.selection!.extent.nodeId)!; + final extentNode = document.getNodeById(composer.selection!.extent.nodeId)!; if (extentNode is! TextNode) { editorOpsLog.fine( "Couldn't insert character because Super Editor doesn't know how to handle a node of type: $extentNode"); @@ -1418,222 +1785,6 @@ class CommonEditorOperations { return inserted; } - // TODO: refactor to make prefix matching extensible (#68) - bool convertParagraphByPatternMatching(String nodeId) { - final node = editor.document.getNodeById(nodeId); - if (node == null) { - return false; - } - if (node is! ParagraphNode) { - return false; - } - - editorOpsLog.fine("Running pattern matching on a ParagraphNode, to convert it to another node type."); - final text = node.text; - final textSelection = composer.selection!.extent.nodePosition as TextNodePosition; - final textBeforeCaret = text.text.substring(0, textSelection.offset); - - final unorderedListItemMatch = RegExp(r'^\s*[\*-]\s+$'); - final hasUnorderedListItemMatch = unorderedListItemMatch.hasMatch(textBeforeCaret); - - // We want to match "1. ", " 1. ", "1) ", " 1) ". - final orderedListItemMatch = RegExp(r'^\s*1[.)]\s+$'); - final hasOrderedListItemMatch = orderedListItemMatch.hasMatch(textBeforeCaret); - - editorOpsLog.fine('_convertParagraphIfDesired', ' - text before caret: "$textBeforeCaret"'); - if (hasUnorderedListItemMatch || hasOrderedListItemMatch) { - editorOpsLog.fine('_convertParagraphIfDesired', ' - found unordered list item prefix'); - int startOfNewText = textBeforeCaret.length; - while (startOfNewText < node.text.text.length && node.text.text[startOfNewText] == ' ') { - startOfNewText += 1; - } - final adjustedText = node.text.copyText(startOfNewText); - final newNode = hasUnorderedListItemMatch - ? ListItemNode.unordered(id: node.id, text: adjustedText) - : ListItemNode.ordered(id: node.id, text: adjustedText); - - editor.executeCommand( - EditorCommandFunction((document, transaction) { - transaction.replaceNode(oldNode: node, newNode: newNode); - }), - ); - - // We removed some text at the beginning of the list item. - // Move the selection back by that same amount. - final textPosition = composer.selection!.extent.nodePosition as TextNodePosition; - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: node.id, - nodePosition: TextNodePosition(offset: textPosition.offset - startOfNewText), - ), - ); - - return true; - } - - final hrMatch = RegExp(r'^---*\s$'); - final hasHrMatch = hrMatch.hasMatch(textBeforeCaret); - if (hasHrMatch) { - editorOpsLog.fine('Paragraph has an HR match'); - // Insert an HR before this paragraph and then clear the - // paragraph's content. - final paragraphNodeIndex = editor.document.getNodeIndexById(node.id); - - editor.executeCommand( - EditorCommandFunction((document, transaction) { - transaction.insertNodeAt( - paragraphNodeIndex, - HorizontalRuleNode( - id: DocumentEditor.createNodeId(), - ), - ); - }), - ); - - node.text = node.text.removeRegion(startOffset: 0, endOffset: hrMatch.firstMatch(textBeforeCaret)!.end); - - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: node.id, - nodePosition: const TextNodePosition(offset: 0), - ), - ); - - return true; - } - - final blockquoteMatch = RegExp(r'^>\s$'); - final hasBlockquoteMatch = blockquoteMatch.hasMatch(textBeforeCaret); - if (hasBlockquoteMatch) { - int startOfNewText = textBeforeCaret.length; - while (startOfNewText < node.text.text.length && node.text.text[startOfNewText] == ' ') { - startOfNewText += 1; - } - final adjustedText = node.text.copyText(startOfNewText); - final newNode = ParagraphNode( - id: node.id, - text: adjustedText, - metadata: {'blockType': blockquoteAttribution}, - ); - - editor.executeCommand( - EditorCommandFunction((document, transaction) { - transaction.replaceNode(oldNode: node, newNode: newNode); - }), - ); - - // We removed some text at the beginning of the list item. - // Move the selection back by that same amount. - final textPosition = composer.selection!.extent.nodePosition as TextNodePosition; - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: node.id, - nodePosition: TextNodePosition(offset: textPosition.offset - startOfNewText), - ), - ); - - return true; - } - - // URL match, e.g., images, social, etc. - editorOpsLog.fine('Looking for URL match...'); - final extractedLinks = linkify(node.text.text, - options: const LinkifyOptions( - humanize: false, - )); - final int linkCount = extractedLinks.fold(0, (value, element) => element is UrlElement ? value + 1 : value); - editorOpsLog.fine("Found $linkCount link(s)"); - final String nonEmptyText = - extractedLinks.fold('', (value, element) => element is TextElement ? value + element.text.trim() : value); - if (linkCount == 1 && nonEmptyText.isEmpty) { - // This node's text is just a URL, try to interpret it - // as a known type. - editorOpsLog.fine("The whole node is one big URL. Trying to convert the node type based on pattern matching..."); - final link = extractedLinks.firstWhereOrNull((element) => element is UrlElement)!.text; - _processUrlNode( - document: editor.document, - editor: editor, - nodeId: node.id, - originalText: node.text.text, - url: link, - ); - return true; - } - - // No pattern match was found - editorOpsLog.fine("ParagraphNode didn't match any conversion pattern."); - return false; - } - - Future _processUrlNode({ - required Document document, - required DocumentEditor editor, - required String nodeId, - required String originalText, - required String url, - }) async { - late http.Response response; - - // This function throws [SocketException] when the [url] is not valid. - // For instance, when typing for https://f|, it throws - // Unhandled Exception: SocketException: Failed host lookup: 'f' - // - // It doesn't affect any functionality, but it throws exception and preventing - // any related test to pass - try { - response = await http.get(Uri.parse(url)); - } on SocketException catch (e) { - editorOpsLog.fine('Failed to load URL: ${e.message}'); - return; - } - - if (response.statusCode < 200 || response.statusCode >= 300) { - editorOpsLog.fine('Failed to load URL: ${response.statusCode} - ${response.reasonPhrase}'); - return; - } - - final contentType = response.headers['content-type']; - if (contentType == null) { - editorOpsLog.fine('Failed to determine URL content type.'); - return; - } - if (!contentType.startsWith('image/')) { - editorOpsLog.fine('URL is not an image. Ignoring'); - return; - } - - // The URL is an image. Convert the node. - editorOpsLog.fine('The URL is an image. Converting the ParagraphNode to an ImageNode.'); - final node = document.getNodeById(nodeId); - if (node is! ParagraphNode) { - editorOpsLog.fine('The node has become something other than a ParagraphNode ($node). Can\'t convert ndoe.'); - return; - } - final currentText = node.text.text; - if (currentText.trim() != originalText.trim()) { - editorOpsLog.fine('The node content changed in a non-trivial way. Aborting node conversion.'); - return; - } - - final imageNode = ImageNode( - id: node.id, - imageUrl: url, - ); - - editor.executeCommand( - EditorCommandFunction((document, transaction) { - transaction.replaceNode(oldNode: node, newNode: imageNode); - }), - ); - - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: node.id, - nodePosition: imageNode.endPosition, - ), - ); - } - bool _insertCharacterInTextComposable( String character, { bool ignoreComposerAttributions = false, @@ -1644,29 +1795,17 @@ class CommonEditorOperations { if (!composer.selection!.isCollapsed) { return false; } - if (!_isTextEntryNode(document: editor.document, selection: composer.selection!)) { + if (!_isTextEntryNode(document: document, selection: composer.selection!)) { return false; } - final textNode = editor.document.getNode(composer.selection!.extent) as TextNode; - final initialTextOffset = (composer.selection!.extent.nodePosition as TextNodePosition).offset; - - editor.executeCommand( - InsertTextCommand( + editor.execute([ + InsertTextRequest( documentPosition: composer.selection!.extent, textToInsert: character, attributions: ignoreComposerAttributions ? {} : composer.preferences.currentAttributions, ), - ); - - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: textNode.id, - nodePosition: TextNodePosition( - offset: initialTextOffset + character.length, - ), - ), - ); + ]); return true; } @@ -1686,8 +1825,9 @@ class CommonEditorOperations { /// If the current selection is not collapsed then the current selection /// is first deleted, then the aforementioned operation takes place. /// - /// Returns [true] if a new node was inserted or a node was split into two. - /// Returns [false] if there was no selection. + /// Returns `true` if a new node was inserted or a node was split into two. + /// Returns `false` if there was no selection. + @Deprecated("Execute a relevant EditRequest in an Editor, e.g., InsertNewlineAtCaretRequest.") bool insertBlockLevelNewline() { editorOpsLog.fine("Inserting block-level newline"); if (composer.selection == null) { @@ -1696,8 +1836,8 @@ class CommonEditorOperations { } // Ensure that the entire selection sits within the same node. - final baseNode = editor.document.getNodeById(composer.selection!.base.nodeId)!; - final extentNode = editor.document.getNodeById(composer.selection!.extent.nodeId)!; + final baseNode = document.getNodeById(composer.selection!.base.nodeId)!; + final extentNode = document.getNodeById(composer.selection!.extent.nodeId)!; if (baseNode.id != extentNode.id) { editorOpsLog.finer("The selection spans multiple nodes. Can't insert block-level newline."); return false; @@ -1707,13 +1847,13 @@ class CommonEditorOperations { // The selection is not collapsed. Delete the selected content first, // then continue the process. editorOpsLog.finer("Deleting selection before inserting block-level newline"); - _deleteExpandedSelection(); + _deleteExpandedSelection(TextAffinity.downstream); } - final newNodeId = DocumentEditor.createNodeId(); + final newNodeId = Editor.createNodeId(); if (extentNode is ListItemNode) { - if (extentNode.text.text.isEmpty) { + if (extentNode.text.isEmpty) { // The list item is empty. Convert it to a paragraph. editorOpsLog.finer( "The current node is an empty list item. Converting it to a paragraph instead of inserting block-level newline."); @@ -1722,13 +1862,24 @@ class CommonEditorOperations { // Split the list item into two. editorOpsLog.finer("Splitting list item in two."); - editor.executeCommand( - SplitListItemCommand( + editor.execute([ + SplitListItemRequest( nodeId: extentNode.id, splitPosition: composer.selection!.extent.nodePosition as TextNodePosition, newNodeId: newNodeId, ), - ); + // Place the caret at the beginning of the new node. + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ]); } else if (extentNode is ParagraphNode) { // Split the paragraph into two. This includes headers, blockquotes, and // any other block-level paragraph. @@ -1736,57 +1887,107 @@ class CommonEditorOperations { final endOfParagraph = extentNode.endPosition; editorOpsLog.finer("Splitting paragraph in two."); - editor.executeCommand( - SplitParagraphCommand( + editor.execute([ + SplitParagraphRequest( nodeId: extentNode.id, splitPosition: currentExtentPosition, newNodeId: newNodeId, replicateExistingMetadata: currentExtentPosition.offset != endOfParagraph.offset, ), - ); + // Place the caret at the beginning of the new node. + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ]); } else if (composer.selection!.extent.nodePosition is UpstreamDownstreamNodePosition) { final extentPosition = composer.selection!.extent.nodePosition as UpstreamDownstreamNodePosition; if (extentPosition.affinity == TextAffinity.downstream) { // The caret sits on the downstream edge of block-level content. Insert // a new paragraph after this node. editorOpsLog.finer("Inserting paragraph after block-level node."); - editor.executeCommand(EditorCommandFunction((doc, transaction) { - transaction.insertNodeAfter( - existingNode: extentNode, + editor.execute([ + InsertNodeAfterNodeRequest( + existingNodeId: extentNode.id, newNode: ParagraphNode( id: newNodeId, - text: AttributedText(text: ''), + text: AttributedText(), ), - ); - })); + ), + // Place the caret at the beginning of the new node. + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ]); } else { // The caret sits on the upstream edge of block-level content. Insert // a new paragraph before this node. editorOpsLog.finer("Inserting paragraph before block-level node."); - editor.executeCommand(EditorCommandFunction((doc, transaction) { - transaction.insertNodeBefore( - existingNode: extentNode, + editor.execute([ + InsertNodeBeforeNodeRequest( + existingNodeId: extentNode.id, newNode: ParagraphNode( id: newNodeId, - text: AttributedText(text: ''), + text: AttributedText(), ), - ); - })); + ), + // Place the caret at the beginning of the new node. + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ]); } + } else if (extentNode is TaskNode) { + if (extentNode.text.isEmpty) { + // The task is empty. Convert it to a paragraph. + return convertToParagraph(); + } + + final splitOffset = (composer.selection!.extent.nodePosition as TextNodePosition).offset; + + editor.execute([ + SplitExistingTaskRequest( + existingNodeId: extentNode.id, + splitOffset: splitOffset, + newNodeId: newNodeId, + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ]); } else { // We don't know how to handle this type of node position. Do nothing. editorOpsLog.fine("Can't insert new block-level inline because we don't recognize the selected content type."); return false; } - // Place the caret at the beginning of the new node. - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: newNodeId, - nodePosition: const TextNodePosition(offset: 0), - ), - ); - return true; } @@ -1807,7 +2008,7 @@ class CommonEditorOperations { /// /// If the selection extent sits in any other kind of node, nothing happens. /// - /// Returns [true] if an image was inserted, [false] if it wasn't. + /// Returns `true` if an image was inserted, `false` if it wasn't. bool insertImage(String url) { if (composer.selection == null) { return false; @@ -1816,8 +2017,14 @@ class CommonEditorOperations { return false; } - final nodeId = composer.selection!.base.nodeId; - return _insertBlockLevelContent(ImageNode(id: nodeId, imageUrl: url)); + final node = document.getNodeById(composer.selection!.base.nodeId); + if (node is! ParagraphNode) { + return false; + } + + return _insertBlockLevelContent( + ImageNode(id: Editor.createNodeId(), imageUrl: url), + ); } /// Inserts horizontal rule at the current selection extent. @@ -1846,8 +2053,14 @@ class CommonEditorOperations { return false; } - final nodeId = composer.selection!.base.nodeId; - return _insertBlockLevelContent(HorizontalRuleNode(id: nodeId)); + final node = document.getNodeById(composer.selection!.base.nodeId); + if (node is! ParagraphNode) { + return false; + } + + return _insertBlockLevelContent( + HorizontalRuleNode(id: Editor.createNodeId()), + ); } /// Inserts the given [blockNode] after the caret. @@ -1856,6 +2069,9 @@ class CommonEditorOperations { /// is converted into the given [blockNode] and a new empty paragraph /// is inserted after the [blockNode]. /// + /// If the selection extent sits at the beginning of a non-empty paragraph, + /// the [blockNode] is inserted as a new node before that paragraph. + /// /// If the selection extent sits at the end of a paragraph, the [blockNode] /// is inserted as a new node after that paragraph, and then a new /// empty paragraph is inserted after the [blockNode]. @@ -1867,7 +2083,7 @@ class CommonEditorOperations { /// /// If the selection extent sits in any other kind of node, nothing happens. /// - /// Returns [true] if the [blockNode] was inserted, [false] if it wasn't. + /// Returns `true` if the [blockNode] was inserted, `false` if it wasn't. bool _insertBlockLevelContent(DocumentNode blockNode) { if (composer.selection == null) { return false; @@ -1877,61 +2093,14 @@ class CommonEditorOperations { } final nodeId = composer.selection!.base.nodeId; - final node = editor.document.getNodeById(nodeId); + final node = document.getNodeById(nodeId); if (node is! ParagraphNode) { return false; } - editor.executeCommand( - EditorCommandFunction((document, transaction) { - final paragraphPosition = composer.selection!.extent.nodePosition as TextNodePosition; - final endOfParagraph = node.endPosition; - - DocumentSelection newSelection; - if (node.text.text.isEmpty) { - // Convert empty paragraph to block item. - transaction.replaceNode(oldNode: node, newNode: blockNode); - - newSelection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: nodeId, - nodePosition: blockNode.endPosition, - ), - ); - } else if (paragraphPosition == endOfParagraph) { - // Insert block item after the paragraph. - transaction.insertNodeAfter(existingNode: node, newNode: blockNode); - - newSelection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: nodeId, - nodePosition: blockNode.endPosition, - ), - ); - } else { - // Split the paragraph and inset image in between. - final textBefore = node.text.copyText(0, paragraphPosition.offset); - final textAfter = node.text.copyText(paragraphPosition.offset); - - final newParagraph = ParagraphNode(id: DocumentEditor.createNodeId(), text: textAfter); - - // TODO: node operations need to be a part of a transaction, somehow. - node.text = textBefore; - transaction - ..insertNodeAfter(existingNode: node, newNode: blockNode) - ..insertNodeAfter(existingNode: blockNode, newNode: newParagraph); - - newSelection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: nodeId, - nodePosition: newParagraph.beginningPosition, - ), - ); - } - - composer.selection = newSelection; - }), - ); + editor.execute([ + InsertNodeAtCaretRequest(node: blockNode), + ]); return true; } @@ -1947,15 +2116,15 @@ class CommonEditorOperations { return false; } - final baseNode = editor.document.getNodeById(composer.selection!.base.nodeId); - final extentNode = editor.document.getNodeById(composer.selection!.extent.nodeId); + final baseNode = document.getNodeById(composer.selection!.base.nodeId); + final extentNode = document.getNodeById(composer.selection!.extent.nodeId); if (baseNode is! ListItemNode || extentNode is! ListItemNode) { return false; } - editor.executeCommand( - IndentListItemCommand(nodeId: extentNode.id), - ); + editor.execute([ + IndentListItemRequest(nodeId: extentNode.id), + ]); return true; } @@ -1974,8 +2143,8 @@ class CommonEditorOperations { return false; } - final baseNode = editor.document.getNodeById(composer.selection!.base.nodeId); - final extentNode = editor.document.getNodeById(composer.selection!.extent.nodeId); + final baseNode = document.getNodeById(composer.selection!.base.nodeId); + final extentNode = document.getNodeById(composer.selection!.extent.nodeId); if (baseNode!.id != extentNode!.id) { return false; } @@ -1984,9 +2153,9 @@ class CommonEditorOperations { return false; } - editor.executeCommand( - UnIndentListItemCommand(nodeId: extentNode.id), - ); + editor.execute([ + UnIndentListItemRequest(nodeId: extentNode.id), + ]); return true; } @@ -2007,18 +2176,16 @@ class CommonEditorOperations { } final nodeId = composer.selection!.base.nodeId; - final node = editor.document.getNodeById(nodeId); + final node = document.getNodeById(nodeId); if (node is! TextNode) { return false; } final newNode = ListItemNode(id: nodeId, itemType: type, text: text); - editor.executeCommand( - EditorCommandFunction((document, transaction) { - transaction.replaceNode(oldNode: node, newNode: newNode); - }), - ); + editor.execute([ + ReplaceNodeRequest(existingNodeId: node.id, newNode: newNode), + ]); return true; } @@ -2039,18 +2206,16 @@ class CommonEditorOperations { } final nodeId = composer.selection!.base.nodeId; - final node = editor.document.getNodeById(nodeId); + final node = document.getNodeById(nodeId); if (node is! TextNode) { return false; } final newNode = ParagraphNode(id: nodeId, metadata: {'blockType': blockquoteAttribution}, text: text); - editor.executeCommand( - EditorCommandFunction((document, transaction) { - transaction.replaceNode(oldNode: node, newNode: newNode); - }), - ); + editor.execute([ + ReplaceNodeRequest(existingNodeId: node.id, newNode: newNode), + ]); return true; } @@ -2068,37 +2233,22 @@ class CommonEditorOperations { return false; } - final baseNode = editor.document.getNodeById(composer.selection!.base.nodeId)!; - final extentNode = editor.document.getNodeById(composer.selection!.extent.nodeId)!; + final baseNode = document.getNodeById(composer.selection!.base.nodeId)!; + final extentNode = document.getNodeById(composer.selection!.extent.nodeId)!; if (baseNode.id != extentNode.id) { return false; } if (extentNode is! TextNode) { return false; } - if (extentNode is ParagraphNode && extentNode.hasMetadataValue('blockType')) { + if (extentNode is ParagraphNode && extentNode.getMetadataValue('blockType') == paragraphAttribution) { // This content is already a regular paragraph. return false; } - editor.executeCommand( - EditorCommandFunction((document, transaction) { - if (extentNode is ParagraphNode) { - extentNode.putMetadataValue('blockType', null); - // TODO: find a way to alter nodes that automatically notifies listeners - // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - extentNode.notifyListeners(); - } else { - final newParagraphNode = ParagraphNode( - id: extentNode.id, - text: extentNode.text, - metadata: newMetadata, - ); - - transaction.replaceNode(oldNode: extentNode, newNode: newParagraphNode); - } - }), - ); + editor.execute([ + ConvertTextNodeToParagraphRequest(nodeId: extentNode.id, newMetadata: newMetadata), + ]); return true; } @@ -2116,7 +2266,7 @@ class CommonEditorOperations { /// clipboard. void copy() { final textToCopy = _textInSelection( - document: editor.document, + document: document, documentSelection: composer.selection!, ); // TODO: figure out a general approach for asynchronous behaviors that @@ -2128,14 +2278,14 @@ class CommonEditorOperations { /// clipboard, and then deletes the selected content. void cut() { final textToCut = _textInSelection( - document: editor.document, + document: document, documentSelection: composer.selection!, ); // TODO: figure out a general approach for asynchronous behaviors that // need to be carried out in response to user input. _saveToClipboard(textToCut); - deleteSelection(); + deleteSelection(TextAffinity.downstream); } Future _saveToClipboard(String text) { @@ -2205,140 +2355,195 @@ class CommonEditorOperations { /// moves the caret, it's possible that the clipboard content will be pasted /// at the wrong spot. void paste() { - DocumentPosition pastePosition = composer.selection!.extent; + DocumentPosition? pastePosition = composer.selection!.extent; + + // Start a transaction so that we can capture both the initial deletion behavior, + // and the clipboard content insertion, all as one transaction. + editor.startTransaction(); // Delete all currently selected content. if (!composer.selection!.isCollapsed) { pastePosition = CommonEditorOperations.getDocumentPositionAfterExpandedDeletion( - document: editor.document, + document: document, selection: composer.selection!, ); - // Delete the selected content. - editor.executeCommand( - DeleteSelectionCommand(documentSelection: composer.selection!), - ); + if (pastePosition == null) { + // There are no deletable nodes in the selection. Do nothing. + return; + } - composer.selection = DocumentSelection.collapsed(position: pastePosition); + // Delete the selected content. + editor.execute([ + DeleteContentRequest(documentRange: composer.selection!), + ChangeSelectionRequest( + DocumentSelection.collapsed(position: pastePosition), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ]); } // TODO: figure out a general approach for asynchronous behaviors that // need to be carried out in response to user input. _paste( - document: editor.document, + document: document, editor: editor, composer: composer, pastePosition: pastePosition, ); + + editor.endTransaction(); } Future _paste({ required Document document, - required DocumentEditor editor, + required Editor editor, required DocumentComposer composer, required DocumentPosition pastePosition, }) async { final content = (await Clipboard.getData('text/plain'))?.text ?? ''; - editor.executeCommand( - _PasteEditorCommand( + editor.execute([ + PasteEditorRequest( content: content, pastePosition: pastePosition, - composer: composer, ), - ); + ]); } } -class _PasteEditorCommand implements EditorCommand { - _PasteEditorCommand({ +class PasteEditorRequest implements EditRequest { + PasteEditorRequest({ + required this.content, + required this.pastePosition, + }); + + final String content; + final DocumentPosition pastePosition; +} + +class PasteEditorCommand extends EditCommand { + PasteEditorCommand({ required String content, required DocumentPosition pastePosition, - required DocumentComposer composer, }) : _content = content, - _pastePosition = pastePosition, - _composer = composer; + _pastePosition = pastePosition; final String _content; final DocumentPosition _pastePosition; - final DocumentComposer _composer; + + // The [_content] as [DocumentNode]s so that we only generate node IDs one + // time. This is critical for undo behavior to work as expected. + List? _parsedContent; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; @override - void execute(Document document, DocumentEditorTransaction transaction) { + void execute(EditContext context, CommandExecutor executor) { + // Only parse the content if we haven't done it already. This command + // might be run 2+ times if the user runs an undo operation. + _parsedContent ??= _parseContent(); + + // Assign locally so we don't have to use a "!" everywhere we reference it. + final parsedContent = _parsedContent!.toList(); + if (parsedContent.isEmpty) { + // No content to paste. + return; + } + + final document = context.document; + final composer = context.find(Editor.composerKey); final currentNodeWithSelection = document.getNodeById(_pastePosition.nodeId); - if (currentNodeWithSelection is! ParagraphNode) { + if (currentNodeWithSelection is! TextNode) { throw Exception('Can\'t handle pasting text within node of type: $currentNodeWithSelection'); } editorOpsLog.info("Pasting clipboard content in document."); - // Split the pasted content at newlines, and apply attributions based - // on inspection of the pasted content, e.g., link attributions. - final attributedLines = _inferAttributionsForLinesOfPastedText(_content); - final textNode = document.getNode(_pastePosition) as TextNode; final pasteTextOffset = (_pastePosition.nodePosition as TextPosition).offset; - if (attributedLines.length > 1 && pasteTextOffset < textNode.endPosition.offset) { + if (parsedContent.length > 1 && pasteTextOffset < textNode.endPosition.offset) { // There is more than 1 node of content being pasted. Therefore, // new nodes will need to be added, which means that the currently // selected text node will be split at the current text offset. // Configure a new node to be added at the end of the pasted content // which contains the trailing text from the currently selected // node. - SplitParagraphCommand( - nodeId: currentNodeWithSelection.id, - splitPosition: TextPosition(offset: pasteTextOffset), - newNodeId: DocumentEditor.createNodeId(), - replicateExistingMetadata: true, - ).execute(document, transaction); + executor.executeCommand( + SplitParagraphCommand( + nodeId: currentNodeWithSelection.id, + splitPosition: TextPosition(offset: pasteTextOffset), + newNodeId: Editor.createNodeId(), + replicateExistingMetadata: true, + ), + ); } - // Paste the first piece of attributed content into the selected TextNode. - InsertAttributedTextCommand( - documentPosition: _pastePosition, - textToInsert: attributedLines.first, - ).execute(document, transaction); + if (parsedContent.first is TextNode) { + // Paste the first piece of attributed content into the existing selected TextNode. + executor.executeCommand( + InsertAttributedTextCommand( + documentPosition: _pastePosition, + textToInsert: (parsedContent.first as TextNode).text, + ), + ); + } // The first line of pasted text was added to the selected paragraph. - // Now, create new nodes for each additional line of pasted text and - // insert those nodes. - final pastedContentNodes = _convertLinesToParagraphs(attributedLines.sublist(1)); - DocumentNode previousNode = currentNodeWithSelection; - for (final pastedNode in pastedContentNodes) { - transaction.insertNodeAfter( - existingNode: previousNode, + // Now, add all remaining pasted nodes to the document.. + DocumentNode previousNode = document.getNodeById(_pastePosition.nodeId)!; + // ^ re-query the node where the first paragraph was pasted because nodes are immutable. + for (final pastedNode in parsedContent.sublist(1)) { + document.insertNodeAfter( + existingNodeId: previousNode.id, newNode: pastedNode, ); previousNode = pastedNode; + + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(pastedNode.id, document.getNodeIndexById(pastedNode.id)), + ) + ]); } // Place the caret at the end of the pasted content. - _composer.selection = DocumentSelection.collapsed( - position: pastedContentNodes.isNotEmpty - ? DocumentPosition( - nodeId: previousNode.id, - nodePosition: previousNode.endPosition, - ) - : DocumentPosition( - nodeId: currentNodeWithSelection.id, - nodePosition: TextNodePosition( - offset: pasteTextOffset + attributedLines.first.text.length, - ), - ), + final pastedNode = document.getNodeById(previousNode.id)!; + // ^ re-query the node where we pasted content because nodes are immutable. + + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: pastedNode.id, + nodePosition: pastedNode.endPosition, + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), ); - editorOpsLog.fine('New selection after paste operation: ${_composer.selection}'); - + editorOpsLog.fine('New selection after paste operation: ${composer.selection}'); editorOpsLog.fine('Done with paste command.'); } + List _parseContent() { + // Split the pasted content at newlines, and apply attributions based + // on inspection of the pasted content, e.g., link attributions. + final attributedLines = _inferAttributionsForLinesOfPastedText(_content); + return _convertLinesToParagraphs(attributedLines).toList(); + } + /// Breaks the given [content] at each newline, then applies any inferred /// attributions based on content analysis, e.g., surrounds URLs with /// [LinkAttribution]s. List _inferAttributionsForLinesOfPastedText(String content) { // Split the pasted content by newlines, because each new line of content // needs to placed in its own ParagraphNode. - final lines = content.split('\n\n'); + final lines = content.split('\n'); editorOpsLog.fine("Breaking pasted content into lines and adding attributions:"); editorOpsLog.fine("Lines of content:"); for (final line in lines) { @@ -2349,8 +2554,8 @@ class _PasteEditorCommand implements EditorCommand { for (final line in lines) { attributedLines.add( AttributedText( - text: line, - spans: _findUrlSpansInText(pastedText: lines.first), + line, + _findUrlSpansInText(pastedText: line), ), ); } @@ -2366,24 +2571,25 @@ class _PasteEditorCommand implements EditorCommand { for (final wordBoundary in wordBoundaries) { final word = wordBoundary.textInside(pastedText); - final link = Uri.tryParse(word); - - if (link != null && link.hasScheme && link.hasAuthority) { - // Valid url. Apply [LinkAttribution] to the url - final linkAttribution = LinkAttribution(url: link); - - final startOffset = wordBoundary.start; - // -1 because TextPosition's offset indexes the character after the - // selection, not the final character in the selection. - final endOffset = wordBoundary.end - 1; - - // Add link attribution. - linkAttributionSpans.addAttribution( - newAttribution: linkAttribution, - start: startOffset, - end: endOffset, - ); + + // The word is a single URL. Linkify it. + final uri = tryToParseUrl(word); + if (uri == null) { + // This word isn't a URI. + continue; } + + final startOffset = wordBoundary.start; + // -1 because TextPosition's offset indexes the character after the + // selection, not the final character in the selection. + final endOffset = wordBoundary.end - 1; + + // Add link attribution. + linkAttributionSpans.addAttribution( + newAttribution: LinkAttribution.fromUri(uri), + start: startOffset, + end: endOffset, + ); } return linkAttributionSpans; @@ -2393,9 +2599,117 @@ class _PasteEditorCommand implements EditorCommand { return attributedLines.map( // TODO: create nodes based on content inspection (e.g., image, list item). (pastedLine) => ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: pastedLine, ), ); } } + +class DeleteUpstreamCharacterRequest implements EditRequest { + const DeleteUpstreamCharacterRequest(); +} + +class DeleteUpstreamCharacterCommand extends EditCommand { + const DeleteUpstreamCharacterCommand(); + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.find(Editor.composerKey); + final selection = composer.selection; + + if (selection == null) { + throw Exception("Tried to delete upstream character but there's no selection."); + } + if (!selection.isCollapsed) { + throw Exception("Tried to delete upstream character but the selection isn't collapsed."); + } + if (document.getNodeById(selection.extent.nodeId) is! TextNode) { + throw Exception("Tried to delete upstream character but the selected node isn't a TextNode."); + } + if (selection.isCollapsed && (selection.extent.nodePosition as TextNodePosition).offset <= 0) { + throw Exception("Tried to delete upstream character but the caret is at the beginning of the text."); + } + + final textNode = document.getNode(selection.extent) as TextNode; + final currentTextOffset = (selection.extent.nodePosition as TextNodePosition).offset; + + final previousCharacterOffset = getCharacterStartBounds(textNode.text.toPlainText(), currentTextOffset); + + // Delete the selected content. + executor + ..executeCommand( + DeleteContentCommand( + documentRange: textNode.selectionBetween( + currentTextOffset, + previousCharacterOffset, + ), + ), + ) + ..executeCommand( + ChangeSelectionCommand( + textNode.selectionAt(previousCharacterOffset), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ) + // We changed the content, and moved the selection. Clear the composing region + // so that it's not incorrect or invalid. + ..executeCommand(ChangeComposingRegionCommand(null)); + } +} + +class DeleteDownstreamCharacterRequest implements EditRequest { + const DeleteDownstreamCharacterRequest(); +} + +class DeleteDownstreamCharacterCommand extends EditCommand { + const DeleteDownstreamCharacterCommand(); + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.find(Editor.composerKey); + final selection = composer.selection; + + if (selection == null) { + throw Exception("Tried to delete downstream character but there's no selection."); + } + if (!selection.isCollapsed) { + throw Exception("Tried to delete downstream character but the selection isn't collapsed."); + } + if (document.getNodeById(selection.extent.nodeId) is! TextNode) { + throw Exception("Tried to delete downstream character but the selected node isn't a TextNode."); + } + + final textNode = document.getNode(selection.extent) as TextNode; + final text = textNode.text; + final currentTextPositionOffset = (selection.extent.nodePosition as TextNodePosition).offset; + if (currentTextPositionOffset >= text.length) { + throw Exception("Tried to delete downstream character but the caret is sitting at the end of the text."); + } + + final nextCharacterOffset = getCharacterEndBounds(text.toPlainText(), currentTextPositionOffset); + + // Delete the selected content. + // + // Note: We don't clear the composing region because the selection and upstream content + // are both unchanged. If we ever find a use-case where this is wrong, and we should + // clear the composing region, add that command here, and document why. + executor.executeCommand( + DeleteContentCommand( + documentRange: textNode.selectionBetween( + currentTextPositionOffset, + nextCharacterOffset, + ), + ), + ); + } +} diff --git a/super_editor/lib/src/default_editor/composer/composer_reactions.dart b/super_editor/lib/src/default_editor/composer/composer_reactions.dart new file mode 100644 index 0000000000..8cdf5d0cc6 --- /dev/null +++ b/super_editor/lib/src/default_editor/composer/composer_reactions.dart @@ -0,0 +1,211 @@ +import 'dart:ui'; + +import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/text.dart'; + +/// [EditReaction] that synchronizes the active composer styles with the caret's +/// position, when the caret moves in relevant ways. +/// +/// When the user places the caret at a new position in a document, the caret might +/// sit immediately before or after some text with existing styles, e.g., bold, +/// italics, underline. Based on the situation, the user expects these styles to +/// be automatically applied to newly typed text. This reaction identifies these +/// situations and activates the desired styles in the [DocumentComposer]. +/// +/// Only the given [styleValuesToExtend], [styleTypesToExtend], [styleSelectorsToExtend] +/// are automatically activated. +/// +/// Styles are activated when placing the caret at the beginning of a paragraph, +/// and the first character has a style: +/// +/// **Hello, world** +/// |**Hello, world** +/// **F|Hello, world** +/// +/// Styles are activated when placing the caret immediately after a style: +/// +/// **Hello**, world +/// **Hello**|, world +/// **HelloF**|, world +/// +/// The selection can change for many reasons. This reaction only activates +/// styles when it believes that the user explicitly moved the caret. +/// Conversely, if the caret moves due to the user typing a character, or +/// if the selection is expanded, then this reaction doesn't activate any +/// styles. +class UpdateComposerTextStylesReaction extends EditReaction { + UpdateComposerTextStylesReaction({ + @Deprecated("Use styleValuesToExtend instead") // + Set? stylesToExtend, + Set? styleValuesToExtend, + Set styleTypesToExtend = defaultExtendableTypes, + Set styleSelectorsToExtend = const {}, + }) : assert( + stylesToExtend == null || styleValuesToExtend == null, + "stylesToExtend and styleValuesToExtend are the same thing - you should only provide one", + ), + _styleValuesToExtend = styleValuesToExtend ?? stylesToExtend ?? defaultExtendableStyles, + _styleTypesToExtend = styleTypesToExtend, + _styleSelectorsToExtend = styleSelectorsToExtend; + + final Set _styleValuesToExtend; + final Set _styleTypesToExtend; + final Set _styleSelectorsToExtend; + + DocumentSelection? _previousSelection; + + @override + void react(EditContext editContext, RequestDispatcher requestDispatcher, List changeList) { + final lastSelectionChange = + changeList.lastWhereOrNull((element) => element is SelectionChangeEvent) as SelectionChangeEvent?; + if (lastSelectionChange == null) { + // The selection didn't change in this transaction. + return; + } + + switch (lastSelectionChange.changeType) { + case SelectionChangeType.placeCaret: + case SelectionChangeType.pushCaret: + case SelectionChangeType.collapseSelection: + case SelectionChangeType.deleteContent: + _updateComposerStylesAtCaret(editContext); + default: + // We don't want change the composer styles for the other types of selection changes. + } + + // Update our internal accounting. + final composer = editContext.find(Editor.composerKey); + _previousSelection = composer.selection; + } + + void _updateComposerStylesAtCaret(EditContext editContext) { + final document = editContext.document; + final composer = editContext.find(Editor.composerKey); + + if (composer.selection?.extent == _previousSelection?.extent && // + // Ignore the attributions at the caret only if the previous selection + // was already collapsed. If the selection was expanded and the user + // placed the caret at the extent of the selection, we should update + // the composer attributions. + _previousSelection?.isCollapsed == true) { + return; + } + + final previousSelectionExtent = _previousSelection?.extent; + final selectionExtent = composer.selection?.extent; + if (selectionExtent != null && + selectionExtent.nodePosition is TextNodePosition && + previousSelectionExtent != null && + previousSelectionExtent.nodePosition is TextNodePosition) { + // The current and previous selections are text positions. Check for the situation where the two + // selections are functionally equivalent, but the affinity changed. + final selectedNodePosition = selectionExtent.nodePosition as TextNodePosition; + final previousSelectedNodePosition = previousSelectionExtent.nodePosition as TextNodePosition; + + // Ignore the attributions at the caret only if the previous selection + // was already collapsed. If the selection was expanded and the user + // placed the caret at the extent of the selection, we should update + // the composer attributions. + if (selectionExtent.nodeId == previousSelectionExtent.nodeId && + selectedNodePosition.offset == previousSelectedNodePosition.offset && + _previousSelection?.isCollapsed == true) { + // The text selection changed, but only the affinity is different. An affinity change doesn't alter + // the selection from the user's perspective, so don't alter any preferences. Return. + return; + } + } + + _previousSelection = composer.selection; + + composer.preferences.clearStyles(); + + if (composer.selection == null || !composer.selection!.isCollapsed) { + return; + } + + final node = document.getNodeById(composer.selection!.extent.nodeId); + if (node is! TextNode) { + return; + } + + final textPosition = composer.selection!.extent.nodePosition as TextPosition; + + if (textPosition.offset == 0 && node.text.isEmpty) { + return; + } + + late int offsetWithAttributionsToExtend; + if (textPosition.offset == 0) { + // The inserted text is at the very beginning of the text blob. Therefore, we should apply the + // same attributions to the inserted text, as the text that immediately follows the inserted text. + offsetWithAttributionsToExtend = textPosition.offset + 1; + } else { + // The inserted text is NOT at the very beginning of the text blob. Therefore, we should apply the + // same attributions to the inserted text, as the text that immediately precedes the inserted text. + offsetWithAttributionsToExtend = textPosition.offset - 1; + } + + Set allAttributions = node.text.getAllAttributionsAt(offsetWithAttributionsToExtend); + + // Add desired expandable styles. + final newStyles = { + // Extend any attributions whose value matches a desired value. + ...allAttributions.where((attribution) => _styleValuesToExtend.contains(attribution)).toSet(), + // Extend any attribution whose class type matches a desired attribution type. + if (_styleTypesToExtend.isNotEmpty) // + ...allAttributions.where((attribution) => _styleTypesToExtend.contains(attribution.runtimeType)).toSet(), + // Extend any attribution that's explicitly selected by a given selector. + if (_styleSelectorsToExtend.isNotEmpty) // + ...allAttributions + .where( + (attribution) => _styleSelectorsToExtend.firstWhereOrNull((selector) => selector(attribution)) != null) + .toSet(), + }; + + // TODO: we shouldn't have such specific behavior in here. Figure out how to generalize this. + // Add a link attribution only if the selection sits at the middle of the link. + // As we are dealing with a collapsed selection, we shouldn't have more than one link. + final linkAttribution = allAttributions.firstWhereOrNull((attribution) => attribution is LinkAttribution); + if (linkAttribution != null) { + final range = node.text.getAttributedRange({linkAttribution}, offsetWithAttributionsToExtend); + + if (textPosition.offset > 0 && + offsetWithAttributionsToExtend >= range.start && + offsetWithAttributionsToExtend < range.end) { + newStyles.add(linkAttribution); + } + } + + composer.preferences.addStyles(newStyles); + } +} + +/// A function that returns `true` if the given [attribution] should be automatically +/// extended when the caret is placed after such an attributed character, and the +/// user continues to type - or `false` to ignore the [attribution] for future typing. +/// +/// Example: Typically, when a user places the caret immediately following a bold character, +/// additional user typing also applies the bold attribution. +/// +/// Example: Typically, when a user places the caret immediately following a link, the link +/// doesn't extend to include additional characters. +typedef AttributionExtensionSelector = bool Function(Attribution attribution); + +final defaultExtendableStyles = Set.unmodifiable({ + boldAttribution, + italicsAttribution, + underlineAttribution, + strikethroughAttribution, + codeAttribution, +}); + +const defaultExtendableTypes = { + FontSizeAttribution, + ColorAttribution, + BackgroundColorAttribution, +}; diff --git a/super_editor/lib/src/default_editor/debug_visualization.dart b/super_editor/lib/src/default_editor/debug_visualization.dart new file mode 100644 index 0000000000..51f2537b7e --- /dev/null +++ b/super_editor/lib/src/default_editor/debug_visualization.dart @@ -0,0 +1,155 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; + +class SuperEditorDebugVisuals extends InheritedWidget { + static SuperEditorDebugVisualsConfig of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()!.config; + } + + static SuperEditorDebugVisualsConfig? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.config; + } + + const SuperEditorDebugVisuals({ + this.config = const SuperEditorDebugVisualsConfig(), + required Widget child, + }) : super(child: child); + + final SuperEditorDebugVisualsConfig config; + + @override + bool updateShouldNotify(SuperEditorDebugVisuals oldWidget) { + return config != oldWidget.config; + } +} + +class SuperEditorDebugVisualsConfig { + const SuperEditorDebugVisualsConfig({ + this.showFocus = false, + this.showImeConnection = false, + }); + + final bool showFocus; + final bool showImeConnection; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorDebugVisualsConfig && + runtimeType == other.runtimeType && + showFocus == other.showFocus && + showImeConnection == other.showImeConnection; + + @override + int get hashCode => showFocus.hashCode ^ showImeConnection.hashCode; +} + +class SuperEditorFocusDebugVisuals extends StatelessWidget { + const SuperEditorFocusDebugVisuals({ + Key? key, + required this.focusNode, + required this.child, + }) : super(key: key); + + final FocusNode focusNode; + + final Widget child; + + @override + Widget build(BuildContext context) { + final config = SuperEditorDebugVisuals.maybeOf(context); + if (config == null || !config.showFocus) { + return child; + } + + return AnimatedBuilder( + animation: focusNode, + builder: (context, value) { + final color = focusNode.hasPrimaryFocus + ? Colors.lightGreenAccent + : focusNode.hasFocus + ? Colors.red + : Colors.grey; + + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: color, + width: 2, + ), + ), + position: DecorationPosition.foreground, + child: child, + ); + }, + ); + } +} + +class SuperEditorImeDebugVisuals extends StatelessWidget { + const SuperEditorImeDebugVisuals({ + Key? key, + required this.imeConnection, + required this.child, + }) : super(key: key); + + final ValueListenable imeConnection; + + final Widget child; + + @override + Widget build(BuildContext context) { + final config = SuperEditorDebugVisuals.maybeOf(context); + if (config == null || !config.showImeConnection) { + return child; + } + + return AnimatedBuilder( + animation: imeConnection, + builder: (context, value) { + final color = imeConnection.value == null + ? Colors.grey + : imeConnection.value!.attached + ? Colors.greenAccent + : Colors.red; + + final message = imeConnection.value == null + ? "NO IME CONNECTION" + : imeConnection.value!.attached + ? "ATTACHED TO IME" + : "DETACHED FROM IME"; + + return SliverHybridStack( + children: [ + // Super Editor + child, + // Debug Info + Align( + alignment: Alignment.topCenter, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/super_editor/lib/src/default_editor/default_document_editor.dart b/super_editor/lib/src/default_editor/default_document_editor.dart new file mode 100644 index 0000000000..3f60fdced8 --- /dev/null +++ b/super_editor/lib/src/default_editor/default_document_editor.dart @@ -0,0 +1,379 @@ +import 'package:attributed_text/attributed_text.dart' show AttributedText; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/box_component.dart'; +import 'package:super_editor/src/default_editor/composer/composer_reactions.dart'; +import 'package:super_editor/src/default_editor/list_items.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/default_editor/text.dart'; + +import 'common_editor_operations.dart'; +import 'default_document_editor_reactions.dart'; + +Editor createDefaultDocumentEditor({ + MutableDocument? document, + MutableDocumentComposer? composer, + HistoryGroupingPolicy historyGroupingPolicy = defaultMergePolicy, + bool isHistoryEnabled = false, +}) { + final editor = Editor( + editables: { + Editor.documentKey: document ?? MutableDocument.empty(), + Editor.composerKey: composer ?? MutableDocumentComposer(), + }, + requestHandlers: List.from(defaultRequestHandlers), + historyGroupingPolicy: historyGroupingPolicy, + reactionPipeline: List.from(defaultEditorReactions), + isHistoryEnabled: isHistoryEnabled, + ); + + return editor; +} + +final defaultRequestHandlers = List.unmodifiable([ + (editor, request) => request is ChangeSelectionRequest + ? ChangeSelectionCommand( + request.newSelection, + request.changeType, + request.reason, + notifyListeners: request.notifyListeners, + ) + : null, + (editor, request) => request is ClearSelectionRequest + ? const ChangeSelectionCommand( + null, + SelectionChangeType.clearSelection, + SelectionReason.userInteraction, + ) + : null, + (editor, request) => request is ChangeComposingRegionRequest // + ? ChangeComposingRegionCommand(request.composingRegion) + : null, + (editor, request) => request is ClearComposingRegionRequest // + ? ChangeComposingRegionCommand(null) + : null, + (editor, request) => request is ChangeInteractionModeRequest // + ? ChangeInteractionModeCommand(isInteractionModeDesired: request.isInteractionModeDesired) + : null, + (editor, request) => request is RemoveComposerPreferenceStylesRequest // + ? RemoveComposerPreferenceStylesCommand(request.stylesToRemove) + : null, + + //--- Start text insertion --- + (editor, request) => request is InsertStyledTextAtCaretRequest // + ? InsertStyledTextAtCaretCommand(request.text, createdAt: request.createdAt) + : null, + (editor, request) => request is InsertInlinePlaceholderAtCaretRequest // + ? InsertInlinePlaceholderAtCaretCommand(request.placeholder, createdAt: request.createdAt) + : null, + (editor, request) => request is InsertPlainTextAtEndOfDocumentRequest // + ? InsertStyledTextAtEndOfDocumentCommand( + AttributedText(request.text), + newNodeId: request.newNodeId, + createdAt: request.createdAt, + ) + : null, + (editor, request) => request is InsertStyledTextAtEndOfDocumentRequest // + ? InsertStyledTextAtEndOfDocumentCommand(request.text, newNodeId: request.newNodeId, createdAt: request.createdAt) + : null, + (editor, request) => request is InsertTextRequest + ? InsertTextCommand( + documentPosition: request.documentPosition, + textToInsert: request.textToInsert, + attributions: request.attributions, + createdAt: request.createdAt, + ) + : null, + (editor, request) => request is InsertAttributedTextRequest + ? InsertAttributedTextCommand( + documentPosition: request.documentPosition, + textToInsert: request.textToInsert, + createdAt: request.createdAt, + ) + : null, + (editor, request) => request is InsertSoftNewlineAtCaretRequest // + ? const InsertSoftNewlineCommand() + : null, + (editor, request) { + if (request is! InsertNewlineAtCaretRequest) { + return null; + } + + final selection = editor.composer.selection; + if (selection == null) { + return null; + } + + final base = selection.base; + if (editor.document.getNodeById(base.nodeId) is! ListItemNode) { + return null; + } + + return InsertNewlineInListItemAtCaretCommand(request.newNodeId); + }, + (editor, request) { + if (request is! InsertNewlineAtCaretRequest) { + return null; + } + + final selection = editor.composer.selection; + if (selection == null) { + return null; + } + + final base = selection.base; + final node = editor.document.getNodeById(base.nodeId); + if (node is! ParagraphNode) { + return null; + } + if (node.metadata[NodeMetadata.blockType] != codeAttribution) { + return null; + } + + return InsertNewlineInCodeBlockAtCaretCommand(request.newNodeId); + }, + (editor, request) { + if (request is! InsertNewlineAtCaretRequest) { + return null; + } + + final selection = editor.composer.selection; + if (selection == null) { + return null; + } + + final base = selection.base; + if (editor.document.getNodeById(base.nodeId) is! TaskNode) { + return null; + } + + return InsertNewlineInTaskAtCaretCommand(request.newNodeId); + }, + (editor, request) => request is InsertNewlineAtCaretRequest // + ? DefaultInsertNewlineAtCaretCommand(request.newNodeId) + : null, + //---- End text insertion ---- + + (editor, request) => request is PasteStructuredContentEditorRequest + ? PasteStructuredContentEditorCommand( + content: request.content, + pastePosition: request.pastePosition, + ) + : null, + (editor, request) => request is InsertNodeAtEndOfDocumentRequest + ? InsertNodeAtIndexCommand(nodeIndex: editor.document.length, newNode: request.newNode) + : null, + (editor, request) => request is InsertNodeAtIndexRequest + ? InsertNodeAtIndexCommand(nodeIndex: request.nodeIndex, newNode: request.newNode) + : null, + (editor, request) => request is InsertNodeBeforeNodeRequest + ? InsertNodeBeforeNodeCommand(existingNodeId: request.existingNodeId, newNode: request.newNode) + : null, + (editor, request) => request is InsertNodeAfterNodeRequest + ? InsertNodeAfterNodeCommand(existingNodeId: request.existingNodeId, newNode: request.newNode) + : null, + (editor, request) => request is InsertNodeAtCaretRequest // + ? InsertNodeAtCaretCommand(newNode: request.node) + : null, + (editor, request) => request is MoveNodeRequest // + ? MoveNodeCommand(nodeId: request.nodeId, newIndex: request.newIndex) + : null, + (editor, request) => request is CombineParagraphsRequest + ? CombineParagraphsCommand(firstNodeId: request.firstNodeId, secondNodeId: request.secondNodeId) + : null, + (editor, request) => request is ReplaceNodeRequest + ? ReplaceNodeCommand(existingNodeId: request.existingNodeId, newNode: request.newNode) + : null, + (editor, request) => request is ReplaceNodeWithEmptyParagraphWithCaretRequest + ? ReplaceNodeWithEmptyParagraphWithCaretCommand(nodeId: request.nodeId) + : null, + (editor, request) => request is DeleteContentRequest // + ? DeleteContentCommand(documentRange: request.documentRange) + : null, + (editor, request) => request is DeleteSelectionRequest // + ? DeleteSelectionCommand(affinity: request.affinity) + : null, + (editor, request) => request is DeleteUpstreamAtBeginningOfNodeRequest && request.node is ListItemNode + ? ConvertListItemToParagraphCommand(nodeId: request.node.id, paragraphMetadata: request.node.metadata) + : null, + (editor, request) => request is DeleteUpstreamAtBeginningOfNodeRequest && request.node is ParagraphNode + ? DeleteUpstreamAtBeginningOfParagraphCommand(request.node) + : null, + (editor, request) => request is DeleteUpstreamAtBeginningOfNodeRequest && request.node is BlockNode + ? DeleteUpstreamAtBeginningOfBlockNodeCommand(request.node) + : null, + (editor, request) => request is DeleteNodeRequest // + ? DeleteNodeCommand(nodeId: request.nodeId) + : null, + (editor, request) => request is ClearDocumentRequest // + ? ClearDocumentCommand() + : null, + (editor, request) => request is DeleteUpstreamCharacterRequest // + ? const DeleteUpstreamCharacterCommand() + : null, + (editor, request) => request is DeleteDownstreamCharacterRequest // + ? const DeleteDownstreamCharacterCommand() + : null, + (editor, request) => request is InsertCharacterAtCaretRequest + ? InsertCharacterAtCaretCommand( + character: request.character, + ignoreComposerAttributions: request.ignoreComposerAttributions, + newNodeId: request.newNodeId, + ) + : null, + (editor, request) => request is InsertPlainTextAtCaretRequest // + ? InsertPlainTextAtCaretCommand( + request.plainText, + attributions: editor.composer.preferences.currentAttributions, + createdAt: request.createdAt, + ) + : null, + (editor, request) => request is InsertTextRequest + ? InsertTextCommand( + documentPosition: request.documentPosition, + textToInsert: request.textToInsert, + attributions: request.attributions) + : null, + (editor, request) => request is ChangeParagraphAlignmentRequest + ? ChangeParagraphAlignmentCommand( + nodeId: request.nodeId, + alignment: request.alignment, + ) + : null, + (editor, request) => request is IndentParagraphRequest + ? IndentParagraphCommand( + request.nodeId, + ) + : null, + (editor, request) => request is UnIndentParagraphRequest + ? UnIndentParagraphCommand( + request.nodeId, + ) + : null, + (editor, request) => request is SetParagraphIndentRequest + ? SetParagraphIndentCommand( + request.nodeId, + level: request.level, + ) + : null, + (editor, request) => request is ChangeParagraphBlockTypeRequest + ? ChangeParagraphBlockTypeCommand( + nodeId: request.nodeId, + blockType: request.blockType, + ) + : null, + (editor, request) => request is SplitParagraphRequest + ? SplitParagraphCommand( + nodeId: request.nodeId, + splitPosition: request.splitPosition, + newNodeId: request.newNodeId, + replicateExistingMetadata: request.replicateExistingMetadata, + attributionsToExtendToNewParagraph: request.attributionsToExtendToNewParagraph, + ) + : null, + (editor, request) => request is ConvertParagraphToTaskRequest + ? ConvertParagraphToTaskCommand( + nodeId: request.nodeId, + isComplete: request.isComplete, + ) + : null, + (editor, request) => request is ConvertTaskToParagraphRequest + ? ConvertTaskToParagraphCommand( + nodeId: request.nodeId, + paragraphMetadata: request.paragraphMetadata, + ) + : null, + (editor, request) => request is DeleteUpstreamAtBeginningOfNodeRequest && request.node is TaskNode + ? ConvertTaskToParagraphCommand(nodeId: request.node.id, paragraphMetadata: request.node.metadata) + : null, + (editor, request) => request is ChangeTaskCompletionRequest + ? ChangeTaskCompletionCommand( + nodeId: request.nodeId, + isComplete: request.isComplete, + ) + : null, + (editor, request) => request is IndentTaskRequest // + ? IndentTaskCommand(request.nodeId) + : null, + (editor, request) => request is UnIndentTaskRequest // + ? UnIndentTaskCommand(request.nodeId) + : null, + (editor, request) => request is SetTaskIndentRequest // + ? SetTaskIndentCommand(request.nodeId, request.indent) + : null, + (editor, request) => request is SplitExistingTaskRequest + ? SplitExistingTaskCommand( + nodeId: request.existingNodeId, + splitOffset: request.splitOffset, + newNodeId: request.newNodeId, + ) + : null, + (editor, request) => request is SplitListItemRequest + ? SplitListItemCommand( + nodeId: request.nodeId, + splitPosition: request.splitPosition, + newNodeId: request.newNodeId, + ) + : null, + (editor, request) => request is IndentListItemRequest // + ? IndentListItemCommand(nodeId: request.nodeId) + : null, + (editor, request) => request is UnIndentListItemRequest // + ? UnIndentListItemCommand(nodeId: request.nodeId) + : null, + (editor, request) => request is ChangeListItemTypeRequest + ? ChangeListItemTypeCommand(nodeId: request.nodeId, newType: request.newType) + : null, + (editor, request) => request is ConvertListItemToParagraphRequest // + ? ConvertListItemToParagraphCommand(nodeId: request.nodeId, paragraphMetadata: request.paragraphMetadata) + : null, + (editor, request) => request is ConvertParagraphToListItemRequest + ? ConvertParagraphToListItemCommand(nodeId: request.nodeId, type: request.type) + : null, + (editor, request) => request is AddTextAttributionsRequest + ? AddTextAttributionsCommand( + documentRange: request.documentRange, + attributions: request.attributions, + autoMerge: request.autoMerge, + ) + : null, + (editor, request) => request is ToggleTextAttributionsRequest + ? ToggleTextAttributionsCommand(documentRange: request.documentRange, attributions: request.attributions) + : null, + (editor, request) => request is RemoveTextAttributionsRequest + ? RemoveTextAttributionsCommand(documentRange: request.documentRange, attributions: request.attributions) + : null, + (editor, request) => request is ChangeSingleColumnLayoutComponentStylesRequest + ? ChangeSingleColumnLayoutComponentStylesCommand(nodeId: request.nodeId, styles: request.styles) // + : null, + (editor, request) => request is ConvertTextNodeToParagraphRequest + ? ConvertTextNodeToParagraphCommand(nodeId: request.nodeId, newMetadata: request.newMetadata) + : null, + (editor, request) => request is PasteEditorRequest + ? PasteEditorCommand( + content: request.content, + pastePosition: request.pastePosition, + ) + : null, +]); + +final defaultEditorReactions = List.unmodifiable([ + UpdateComposerTextStylesReaction(), + const LinkifyReaction(), + + //---- Start Content Conversions ---- + HeaderConversionReaction(), + const UnorderedListItemConversionReaction(), + const OrderedListItemConversionReaction(), + const BlockquoteConversionReaction(), + const HorizontalRuleConversionReaction(), + const ImageUrlConversionReaction(), + const DashConversionReaction(), + //---- End Content Conversions --- + + UpdateSubTaskIndentAfterTaskDeletionReaction(), +]); diff --git a/super_editor/lib/src/default_editor/default_document_editor_reactions.dart b/super_editor/lib/src/default_editor/default_document_editor_reactions.dart new file mode 100644 index 0000000000..cfe02a1cd4 --- /dev/null +++ b/super_editor/lib/src/default_editor/default_document_editor_reactions.dart @@ -0,0 +1,1278 @@ +import 'dart:io'; + +import 'package:attributed_text/attributed_text.dart'; +import 'package:characters/characters.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:linkify/linkify.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/horizontal_rule.dart'; +import 'package:super_editor/src/default_editor/image.dart'; +import 'package:super_editor/src/default_editor/list_items.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/strings.dart'; + +import 'multi_node_editing.dart'; + +/// Converts a [ParagraphNode] from a regular paragraph to a header when the +/// user types "# " (or similar) at the start of the paragraph. +class HeaderConversionReaction extends ParagraphPrefixConversionReaction { + static Attribution _getHeaderAttributionForLevel(int level) { + switch (level) { + case 1: + return header1Attribution; + case 2: + return header2Attribution; + case 3: + return header3Attribution; + case 4: + return header4Attribution; + case 5: + return header5Attribution; + case 6: + return header6Attribution; + default: + throw Exception( + "Tried to match a header pattern level ($level) to a header attribution, but there's no attribution for that level."); + } + } + + HeaderConversionReaction([ + this.maxLevel = 6, + this.mapping = _getHeaderAttributionForLevel, + ]) { + _headerRegExp = RegExp("^#{1,$maxLevel}\\s+\$"); + } + + /// The highest level of header that this reaction will recognize, e.g., `3` -> "### ". + final int maxLevel; + + /// The mapping from integer header levels to header [Attribution]s. + final HeaderAttributionMapping mapping; + + @override + RegExp get pattern => _headerRegExp; + late final RegExp _headerRegExp; + + @override + void onPrefixMatched( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ParagraphNode paragraph, + String match, + ) { + final prefixLength = match.length - 1; // -1 for the space on the end + late Attribution headerAttribution = _getHeaderAttributionForLevel(prefixLength); + + final paragraphPatternSelection = DocumentSelection( + base: DocumentPosition( + nodeId: paragraph.id, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: paragraph.id, + nodePosition: TextNodePosition(offset: paragraph.text.toPlainText().indexOf(" ") + 1), + ), + ); + + requestDispatcher.execute([ + // Change the paragraph to a header. + ChangeParagraphBlockTypeRequest( + nodeId: paragraph.id, + blockType: headerAttribution, + ), + // Delete the header pattern from the content. + ChangeSelectionRequest( + paragraphPatternSelection, + SelectionChangeType.expandSelection, + SelectionReason.contentChange, + ), + DeleteContentRequest( + documentRange: paragraphPatternSelection, + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraph.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ]); + } +} + +typedef HeaderAttributionMapping = Attribution Function(int level); + +/// Converts a [ParagraphNode] to an [UnorderedListItemNode] when the +/// user types "* " (or similar) at the start of the paragraph. +class UnorderedListItemConversionReaction extends ParagraphPrefixConversionReaction { + static final _unorderedListItemInEmptyParagraphPattern = RegExp(r'^\s*[*•-]\s+$'); + static final _unorderedListItemInNonEmptyParagraphPattern = RegExp(r'^\s*[*•-]\s+'); + + const UnorderedListItemConversionReaction({ + this.allowConversionOfNonEmptyParagraphs = true, + }); + + final bool allowConversionOfNonEmptyParagraphs; + + @override + RegExp get pattern => allowConversionOfNonEmptyParagraphs + ? _unorderedListItemInNonEmptyParagraphPattern + : _unorderedListItemInEmptyParagraphPattern; + + @override + void onPrefixMatched( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ParagraphNode paragraph, + String match, + ) { + // The user started a paragraph with an unordered list item pattern. + // Convert the paragraph to an unordered list item. + requestDispatcher.execute([ + ReplaceNodeRequest( + existingNodeId: paragraph.id, + newNode: ListItemNode.unordered( + id: paragraph.id, + text: paragraph.text.copy().removeRegion( + startOffset: 0, // + endOffset: match.length, + ), + ), + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraph.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ]); + } +} + +/// Converts a [ParagraphNode] to an [OrderedListItemNode] when the +/// user types " 1. " (or similar) at the start of the paragraph. +class OrderedListItemConversionReaction extends ParagraphPrefixConversionReaction { + /// Matches strings like ` 1. `, ` 2. `, ` 1) `, ` 2) `, etc. + static final _orderedListPatternInEmptyParagraph = RegExp(r'^\s*\d+[.)]\s+$'); + static final _orderedListPatternInNonEmptyParagraph = RegExp(r'^\s*\d+[.)]\s+'); + + /// Matches one or more numbers. + static final _numberRegex = RegExp(r'\d+'); + + const OrderedListItemConversionReaction({ + this.allowConversionOfNonEmptyParagraphs = true, + this.continuationStrategy, + }); + + final bool allowConversionOfNonEmptyParagraphs; + + final OrderedListContinuationStrategy? continuationStrategy; + + @override + RegExp get pattern => allowConversionOfNonEmptyParagraphs + ? _orderedListPatternInNonEmptyParagraph + : _orderedListPatternInEmptyParagraph; + + @override + void onPrefixMatched( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ParagraphNode paragraph, + String match, + ) { + // Extract the number from the match. + final numberMatch = _numberRegex.firstMatch(match)!; + final numberTyped = int.parse(match.substring(numberMatch.start, numberMatch.end)); + + final nextOrderedListItem = numberTyped == 1 + ? const NextOrderedListItem() + : _maybeContinueList(editContext.document, paragraph, numberTyped); + if (nextOrderedListItem == null) { + return; + } + + // The user started a paragraph with an ordered list item pattern. + // Convert the paragraph to an unordered list item. + final continuingListItemNode = createNextListItemNode( + paragraph, + match: match, + numberTyped: numberTyped, + indent: nextOrderedListItem.indent, + ); + + requestDispatcher.execute([ + ReplaceNodeRequest( + existingNodeId: paragraph.id, + newNode: continuingListItemNode, + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraph.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ]); + } + + /// Checks if the [numberTyped] at the start of the [paragraph] should continue an + /// existing list, and returns the configuration of that new ordered list item, or returns + /// `null` if no list continuation is desired. + NextOrderedListItem? _maybeContinueList(Document document, ParagraphNode paragraph, int numberTyped) { + if (continuationStrategy != null) { + return continuationStrategy!.call(document, paragraph, numberTyped); + } + + // Check if the user typed a number that continues the sequence of an upstream + // ordered list item. For example, the list has the items 1, 2, 3 and 4, + // and the user types " 5. ". + final upstreamNode = document.getNodeBefore(paragraph); + if (upstreamNode == null || upstreamNode is! ListItemNode || upstreamNode.type != ListItemType.ordered) { + // There isn't an ordered list item immediately before this paragraph. Fizzle. + return null; + } + + // The node immediately before this paragraph is an ordered list item. Compute its ordinal value, + // so we can check if the user typed the next number in the sequence. + int upstreamListItemOrdinalValue = computeListItemOrdinalValue(upstreamNode, document); + if (numberTyped != upstreamListItemOrdinalValue + 1) { + // The user typed a number that doesn't continue the sequence of the upstream ordered list item. + return null; + } + + return NextOrderedListItem( + // In this implementation, we don't care about the ordinal value + // because it's auto-computed when laying out the document UI. + indent: upstreamNode.indent, + ); + } + + /// Creates the [ListItemNode] that will replace the paragraph with the typed + /// prefix. + /// + /// This method is protected so that client apps can choose their own implementation + /// of list items, if needed. + @protected + ListItemNode createNextListItemNode( + ParagraphNode paragraph, { + required String match, + required int numberTyped, + required int indent, + }) { + return ListItemNode.ordered( + id: paragraph.id, + text: paragraph.text.copy().removeRegion( + startOffset: 0, + endOffset: match.length, + ), + indent: indent, + ); + } +} + +/// Strategy for deciding whether a typed prefix number should continue an existing list, +/// and if so, what the next number should be. +/// +/// Contract: +/// * Returns `null` if no continuation is desired. +/// * Returns a non-null [NextOrderedListItem.ordinalValue], if the app cares about explicit +/// ordinal values per list item, or a `null` [NextOrderedListItem.ordinalValue] if the app +/// auto-increments list items as the document UI is built. +/// * Returns the desired indent of the new list item in [NextOrderedListItem.indent]. +typedef OrderedListContinuationStrategy = NextOrderedListItem? Function( + Document document, ParagraphNode paragraph, int typedOrdinal); + +/// Data structure that reports the configuration for a new ordered list item that +/// should be created due to an automatic prefix conversion, e.g., typing "1. ". +class NextOrderedListItem { + const NextOrderedListItem({ + this.ordinalValue, + this.indent = 0, + }) : assert(ordinalValue == null || ordinalValue >= 0), + assert(indent >= 0); + + final int? ordinalValue; + final int indent; +} + +/// Adjusts a [ParagraphNode] to use a blockquote block attribution when a +/// user types " > " (or similar) at the start of the paragraph. +class BlockquoteConversionReaction extends ParagraphPrefixConversionReaction { + static final _blockquotePattern = RegExp(r'^>\s$'); + + const BlockquoteConversionReaction(); + + @override + RegExp get pattern => _blockquotePattern; + + @override + void onPrefixMatched( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ParagraphNode paragraph, + String match, + ) { + // The user started a paragraph with blockquote pattern. + // Convert the paragraph to a blockquote. + requestDispatcher.execute([ + ReplaceNodeRequest( + existingNodeId: paragraph.id, + newNode: ParagraphNode( + id: paragraph.id, + text: AttributedText(), + metadata: { + "blockType": blockquoteAttribution, + }, + ), + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraph.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ]); + } +} + +/// Converts node content that looks like "--- " or "—- " (an em-dash followed by a regular dash) +/// at the beginning of a paragraph into a horizontal rule. +/// +/// The horizontal rule is inserted before the current node and the remainder of +/// the node's text is kept. +/// +/// Applied only to all [TextNode]s. +class HorizontalRuleConversionReaction extends EditReaction { + // Matches "---" or "—-" (an em-dash followed by a regular dash) at the beginning of a line, + // followed by a space. + static final _hrPattern = RegExp(r'^(---|—-)\s'); + + const HorizontalRuleConversionReaction(); + + @override + void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { + if (changeList.length < 2) { + // This reaction requires at least an insertion event and a selection change event. + // There are less than two events in the the change list, therefore this reaction + // shouldn't apply. Fizzle. + return; + } + + final document = editorContext.document; + + final didTypeSpace = EditInspector.didTypeSpace(document, changeList); + if (!didTypeSpace) { + return; + } + + // final edit = changeList[changeList.length - 2] as DocumentEdit; + final edit = changeList.reversed.firstWhere((edit) => edit is DocumentEdit) as DocumentEdit; + if (edit.change is! TextInsertionEvent) { + // This reaction requires that the two last events are an insertion event + // followed by a selection change event. + // The second to last event isn't a text insertion event, therefore this reaction + // shouldn't apply. Fizzle. + } + + final textInsertionEvent = edit.change as TextInsertionEvent; + final paragraph = document.getNodeById(textInsertionEvent.nodeId) as TextNode; + final match = _hrPattern.firstMatch(paragraph.text.toPlainText())?.group(0); + if (match == null) { + return; + } + + // The user typed a horizontal rule pattern at the beginning of a paragraph. + // - Remove the dashes and the space. + // - Insert a horizontal rule before the paragraph. + // - Place caret at the start of the paragraph. + requestDispatcher.execute([ + DeleteContentRequest( + documentRange: DocumentRange( + start: DocumentPosition(nodeId: paragraph.id, nodePosition: const TextNodePosition(offset: 0)), + end: DocumentPosition(nodeId: paragraph.id, nodePosition: TextNodePosition(offset: match.length)), + ), + ), + InsertNodeAtIndexRequest( + nodeIndex: document.getNodeIndexById(paragraph.id), + newNode: HorizontalRuleNode( + id: Editor.createNodeId(), + ), + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraph.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ]); + } +} + +/// Base class for [EditReaction]s that want to take action when the user types text at +/// the beginning of a paragraph, which matches a given [RegExp]. +abstract class ParagraphPrefixConversionReaction extends EditReaction { + const ParagraphPrefixConversionReaction({ + bool requireSpaceInsertion = true, + }) : _requireSpaceInsertion = requireSpaceInsertion; + + /// Whether the [_prefixPattern] requires a trailing space. + /// + /// The [_prefixPattern] will always be honored. This hint provides a performance + /// optimization so that the pattern expression is never evaluated in cases where the + /// user didn't insert a space into the paragraph. + final bool _requireSpaceInsertion; + + /// Pattern that is matched at the beginning of a paragraph and then passed to + /// sub-classes for processing. + RegExp get pattern; + + @override + void react(EditContext editContext, RequestDispatcher requestDispatcher, List changeList) { + final document = editContext.document; + final typedText = EditInspector.findLastTextUserTyped(document, changeList); + if (typedText == null) { + return; + } + if (_requireSpaceInsertion && !typedText.text.toPlainText().endsWith(" ")) { + return; + } + + final paragraph = document.getNodeById(typedText.nodeId); + if (paragraph is! ParagraphNode) { + return; + } + + final match = pattern.firstMatch(paragraph.text.toPlainText())?.group(0); + if (match == null) { + return; + } + + // The user started a paragraph with the desired pattern. Delegate to the subclass + // to do whatever it wants. + onPrefixMatched(editContext, requestDispatcher, changeList, paragraph, match); + } + + /// Hook, called by the superclass, when the user starts the given [paragraph] with + /// the given [match], which fits the desired [pattern]. + @protected + void onPrefixMatched( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ParagraphNode paragraph, + String match, + ); +} + +/// When the user creates a new node, and the previous node is just a URL +/// to an image, the replaces the previous node with the referenced image. +class ImageUrlConversionReaction extends EditReaction { + const ImageUrlConversionReaction(); + + @override + void react(EditContext editContext, RequestDispatcher requestDispatcher, List changeList) { + if (changeList.isEmpty) { + return; + } + if (changeList.last is! SubmitParagraphIntention) { + return; + } + + editorOpsLog.finer("Checking for image URL after paragraph submission"); + + // The user pressed "enter" at the end of a paragraph. Check if the + // paragraph is comprised of a URL. + final selectionChange = + changeList.reversed.firstWhereOrNull((item) => item is SelectionChangeEvent) as SelectionChangeEvent?; + if (selectionChange == null || selectionChange.oldSelection == null) { + // There was no selection change. There should be a selection change when + // a paragraph is inserted. We don't know what's going on. Bail out. + editorOpsLog.finer("There was no selection change. Not an image URL."); + return; + } + + final document = editContext.document; + final previousNode = document.getNodeById(selectionChange.oldSelection!.extent.nodeId); + if (previousNode is! ParagraphNode) { + // The intention indicated that the user pressed "enter" from a paragraph + // but the previously selected node isn't a paragraph. We don't know why. + // Bail out. + editorOpsLog.finer("Previous node wasn't a paragraph. Bailing."); + return; + } + + // Check if the submitted paragraph is comprised of a single URL. + final extractedLinks = linkify( + previousNode.text.toPlainText(), + options: const LinkifyOptions( + humanize: false, + ), + ); + final int linkCount = extractedLinks.fold(0, (value, element) => element is UrlElement ? value + 1 : value); + if (linkCount != 1) { + // Either there aren't any URLs, or there are multiple. This reaction + // doesn't apply. + editorOpsLog.finer("Didn't find exactly 1 link. Found: $linkCount"); + return; + } + + final url = extractedLinks.firstWhere((element) => element is UrlElement).text; + if (url != previousNode.text.toPlainText().trim()) { + // There's more in the paragraph than just a URL. This reaction + // doesn't apply. + editorOpsLog.finer("Paragraph had more than just a URL"); + return; + } + + // The submitted paragraph consists of a single URL. Check if that + // URL is an image. If it is, replace the submitted paragraph with + // an image. + // TODO: move the URL lookup into a behavior within the node. We don't want async reaction behaviors. + final originalText = previousNode.text.toPlainText(); + _isImageUrl(url).then((isImage) { + if (!isImage) { + editorOpsLog.finer("Checked URL, but it's not an image"); + return; + } + + // The URL is an image. Convert the node. + editorOpsLog.finer('The URL is an image. Converting the ParagraphNode to an ImageNode.'); + final node = document.getNodeById(previousNode.id); + if (node is! ParagraphNode) { + editorOpsLog.finer('The node has become something other than a ParagraphNode ($node). Can\'t convert node.'); + return; + } + final currentText = node.text.toPlainText(); + if (currentText.trim() != originalText.trim()) { + editorOpsLog.finer('The node content changed in a non-trivial way. Aborting node conversion.'); + return; + } + + final imageNode = ImageNode( + id: node.id, + imageUrl: url, + ); + + requestDispatcher.execute([ + ReplaceNodeRequest( + existingNodeId: node.id, + newNode: imageNode, + ), + ]); + }); + } + + Future _isImageUrl(String url) async { + late http.Response response; + + // This function throws [SocketException] when the [url] is not valid. + // For instance, when typing for https://f|, it throws + // Unhandled Exception: SocketException: Failed host lookup: 'f' + // + // It doesn't affect any functionality, but it throws exception and preventing + // any related test to pass + try { + response = await http.get(Uri.parse(url)); + } on SocketException catch (e) { + editorOpsLog.fine('Failed to load URL: ${e.message}'); + return false; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + editorOpsLog.fine('Failed to load URL: ${response.statusCode} - ${response.reasonPhrase}'); + return false; + } + + final contentType = response.headers['content-type']; + if (contentType == null) { + editorOpsLog.fine('Failed to determine URL content type.'); + return false; + } + if (!contentType.startsWith('image/')) { + editorOpsLog.fine('URL is not an image. Ignoring'); + return false; + } + + return true; + } +} + +/// An [EditReaction] which converts a URL into a styled link. +/// +/// When the URL has characters added or removed, the [updatePolicy] determines +/// which action to take: +/// +/// - [LinkUpdatePolicy.preserve] : the attribution remains unchanged. +/// - [LinkUpdatePolicy.update] : the attribution is updated to reflect the new URL. +/// - [LinkUpdatePolicy.remove] : the attribution is removed. +/// +/// A plain text URL only has a link applied to it when the user enters a space " " +/// after a token that looks like a URL. If the user doesn't enter a trailing space, +/// or the preceding token doesn't look like a URL, then the link attribution isn't aplied. +class LinkifyReaction extends EditReaction { + const LinkifyReaction({ + this.updatePolicy = LinkUpdatePolicy.preserve, + }); + + /// Configures how a change in a URL should be handled. + final LinkUpdatePolicy updatePolicy; + + @override + void react(EditContext editContext, RequestDispatcher requestDispatcher, List edits) { + final document = editContext.document; + final composer = editContext.find(Editor.composerKey); + final selection = composer.selection; + + bool didInsertSpace = false; + + TextInsertionEvent? linkifyCandidate; + for (int i = 0; i < edits.length; i++) { + final edit = edits[i]; + if (edit is DocumentEdit) { + final change = edit.change; + if (change is TextInsertionEvent && change.text.toPlainText() == " ") { + // Every space insertion might appear after a URL. + linkifyCandidate = change; + didInsertSpace = true; + } + } else if (edit is SelectionChangeEvent) { + if (linkifyCandidate == null) { + // There was no text insertion to linkify. + continue; + } + + if (selection == null) { + // The editor doesn't have a selection. Don't linkify. + linkifyCandidate = null; + continue; + } + + if (!selection.isCollapsed) { + // The selection is expanded. Don't linkify. + linkifyCandidate = null; + continue; + } + + final caretPosition = selection.extent; + if (caretPosition.nodeId != linkifyCandidate.nodeId) { + // The selection moved to some other node. Don't linkify. + linkifyCandidate = null; + continue; + } + + // +1 for the inserted space + if ((caretPosition.nodePosition as TextNodePosition).offset != linkifyCandidate.offset + 1) { + // The caret isn't sitting directly after the space. Whatever + // these events represent, it doesn't represent the user typing + // a URL and then press SPACE. Don't linkify. + linkifyCandidate = null; + continue; + } + + // The caret sits directly after an inserted space. Get the word before + // the space from the document, and linkify, if it fits a schema. + final textNode = document.getNodeById(linkifyCandidate.nodeId) as TextNode; + _extractUpstreamWordAndLinkify(textNode.text, linkifyCandidate.offset); + } else if ((edit is SubmitParagraphIntention && edit.isStart) || + (edit is SplitParagraphIntention && edit.isStart) || + (edit is SplitListItemIntention && edit.isStart) || + (edit is SplitTaskIntention && edit.isStart)) { + // The user is splitting a node or submit a paragraph. For example, by pressing ENTER. + // Get the nodeId on the next change to try to linkify the text. + + if (i >= edits.length - 1) { + // The current edit is the last on the list. + // We can't get the node id. + continue; + } + + final nextEdit = edits[i + 1]; + if (nextEdit is DocumentEdit && nextEdit.change is NodeChangeEvent) { + final editedNode = document.getNodeById((nextEdit.change as NodeChangeEvent).nodeId); + if (editedNode is TextNode) { + _extractUpstreamWordAndLinkify(editedNode.text, editedNode.text.length); + } + } + } + } + + if (!didInsertSpace) { + // We didn't linkify any text. Check if we need to update an URL. + _tryUpdateLinkAttribution(requestDispatcher, document, composer, edits); + } + } + + /// Extracts a word ending at [endOffset] tries to linkify it. + void _extractUpstreamWordAndLinkify(AttributedText text, int endOffset) { + final wordStartOffset = _moveOffsetByWord(text.toPlainText(), endOffset, true) ?? 0; + final word = text.substring(wordStartOffset, endOffset); + + // Ensure that the preceding word doesn't already contain a full or partial + // link attribution. + if (text + .getAttributionSpansInRange( + attributionFilter: (attribution) => attribution is LinkAttribution, + range: SpanRange(wordStartOffset, endOffset), + ) + .isNotEmpty) { + // There are link attributions in the preceding word. We don't want to mess with them. + return; + } + + // Try to linkify. + final uri = tryToParseUrl(word); + if (uri == null) { + // No link in the word. Fizzle. + return; + } + + // We found a link. Attribute it. + text.addAttribution( + LinkAttribution.fromUri(uri), + SpanRange(wordStartOffset, endOffset - 1), + ); + } + + int? _moveOffsetByWord(String text, int textOffset, bool upstream) { + if (textOffset < 0 || textOffset > text.length) { + throw Exception("Index '$textOffset' is out of string range. Length: ${text.length}"); + } + + // Create a character range, initially with zero length + // Note that the getter for this object is confusingly named: it is an iterator but includes lots of functionality + // beyond that interface, most importantly for us a range over this string that can be manipulated in terms of + // characters + final range = text.characters.iterator; + // Expand the range so it reaches from the start of the string to the initial text offset. The text offset is passed + // to us in terms of code units but the iterator deals in grapheme clusters, so we need to manually count the length + // of each cluster until we reach the desired offset + var remainingOffset = textOffset; + range.expandWhile((char) { + remainingOffset -= char.length; + return remainingOffset >= 0; + }); + final moveWhile = upstream ? range.dropBackWhile : range.expandWhile; + // Adjust the range in the requested direction as long it does not end in a word. This accounts for cases where the + // text offset starts in between words. After this we know the range ends on a word character + moveWhile((char) => char != " "); + // Adjust the range in the requested direction until it reaches a non-word character. After this we know that the + // range ends at the start of the next word upstream or end of the next word downstream from the initial text offset + moveWhile((char) => char != " "); + // The range now reaches from the start of the string to our new text offset. Calculate that offset using the + // range's string length and return it + return range.current.length; + } + + /// Update or remove the link attributions if edits happen at the middle of a link. + void _tryUpdateLinkAttribution(RequestDispatcher requestDispatcher, Document document, + MutableDocumentComposer composer, List changeList) { + if (!const [LinkUpdatePolicy.remove, LinkUpdatePolicy.update].contains(updatePolicy)) { + // We are configured to NOT change the attributions. Fizzle. + return; + } + + if (changeList.isEmpty) { + // There aren't any changes, therefore no URL was changed, therefore we don't + // need to update a URL. Fizzle. + return; + } + + late NodeChangeEvent insertionOrDeletionEvent; + if (changeList.length == 1) { + final editEvent = changeList.last; + if (editEvent is! DocumentEdit || editEvent.change is! TextDeletedEvent) { + // There's only a single event in the change list, and it's not a deletion + // event. The only situation where a URL would change with a single + // event is a deletion event. Therefore, we don't need to change a URL. + // Fizzle. + return; + } + + insertionOrDeletionEvent = editEvent.change as NodeChangeEvent; + } else { + final lastSelectionEventIndex = changeList.lastIndexWhere((change) => change is SelectionChangeEvent); + if (lastSelectionEventIndex < 1) { + // There's no selection change event. We expect a URL change + // to consist of an insertion or a deletion followed by a selection + // change. This event list doesn't fit the pattern. Fizzle. + return; + } + + final edit = changeList[lastSelectionEventIndex - 1]; + if (edit is! DocumentEdit || // + (edit.change is! TextInsertionEvent && edit.change is! TextDeletedEvent)) { + // The event before the selection change isn't an insertion or deletion. We + // expect a URL change to consist of an insertion or a deletion followed by + // a selection change. This event list doesn't fit the pattern. Fizzle. + return; + } + + insertionOrDeletionEvent = edit.change as NodeChangeEvent; + } + + // The change list includes an insertion or deletion followed by a selection + // change, therefore a URL may have changed. Look for a URL around the + // altered text. + + final changedNodeId = insertionOrDeletionEvent.nodeId; + final changedNodeText = (document.getNodeById(changedNodeId) as TextNode).text; + + AttributionSpan? upstreamLinkAttribution; + AttributionSpan? downstreamLinkAttribution; + + final insertionOrDeletionOffset = insertionOrDeletionEvent is TextInsertionEvent + ? insertionOrDeletionEvent.offset + : (insertionOrDeletionEvent as TextDeletedEvent).offset; + if (insertionOrDeletionOffset > 0) { + // Check if the upstream character has a link attribution. + upstreamLinkAttribution = changedNodeText + .getAttributionSpansInRange( + attributionFilter: (attribution) => attribution is LinkAttribution, + range: SpanRange(insertionOrDeletionOffset - 1, insertionOrDeletionOffset - 1), + ) + .firstOrNull; + } + + if ((insertionOrDeletionEvent is TextInsertionEvent && insertionOrDeletionOffset < changedNodeText.length - 1) || + (insertionOrDeletionEvent is TextDeletedEvent && insertionOrDeletionOffset < changedNodeText.length)) { + // Check if the downstream character has a link attribution. + final downstreamOffset = insertionOrDeletionEvent is TextInsertionEvent // + ? insertionOrDeletionOffset + 1 + : insertionOrDeletionOffset; + downstreamLinkAttribution = changedNodeText + .getAttributionSpansInRange( + attributionFilter: (attribution) => attribution is LinkAttribution, + range: SpanRange(downstreamOffset, downstreamOffset), + ) + .firstOrNull; + } + + if (upstreamLinkAttribution == null && downstreamLinkAttribution == null) { + // There isn't a link around the changed offset. Fizzle. + return; + } + + // We only want to update a URL if a change happened within an existing URL, + // not at the edges. Determine whether this change reflects an insertion or + // deletion within an existing URL by looking for identical link attributions + // both upstream and downstream from the edited text offset. + final isAtMiddleOfLink = upstreamLinkAttribution != null && + downstreamLinkAttribution != null && + upstreamLinkAttribution.attribution == downstreamLinkAttribution.attribution; + + if (!isAtMiddleOfLink && insertionOrDeletionEvent is TextInsertionEvent) { + // An insertion happened at an edge of the link. + // Insertion only updates the attribution when happening at the middle of a link. Fizzle. + return; + } + + final rangeToUpdate = isAtMiddleOfLink // + ? SpanRange(upstreamLinkAttribution.start, downstreamLinkAttribution.end) + : (upstreamLinkAttribution ?? downstreamLinkAttribution!).range; + + // Remove the existing link attributions. + final attributionsToRemove = changedNodeText.getAttributionSpansInRange( + attributionFilter: (attr) => attr is LinkAttribution, + range: rangeToUpdate, + ); + + final linkRange = DocumentRange( + start: DocumentPosition( + nodeId: changedNodeId, + nodePosition: TextNodePosition(offset: rangeToUpdate.start), + ), + end: DocumentPosition( + nodeId: changedNodeId, + nodePosition: TextNodePosition(offset: rangeToUpdate.end + 1), + ), + ); + + final linkChangeRequests = [ + RemoveTextAttributionsRequest( + documentRange: linkRange, + attributions: {attributionsToRemove.first.attribution}, + ), + ]; + + // A URL was changed and we have now removed the original link. Removing + // the original link was a necessary step for both `LinkUpdatePolicy.remove` + // and for `LinkUpdatePolicy.update`. + // + // If the policy is `LinkUpdatePolicy.update` then we need to add a new + // link attribution that reflects the edited URL text. We do that below. + if (updatePolicy == LinkUpdatePolicy.update) { + final existingLinkAttribution = + changedNodeText.getAllAttributionsAt(rangeToUpdate.start).whereType().firstOrNull; + assert( + existingLinkAttribution != null, + "Tried to update a LinkAttribution after the user added/deleted character, but we couldn't find the LinkAttribution. We searched at offset ${rangeToUpdate.start} in '${changedNodeText.toPlainText()}'", + ); + + // We expect the link attribution to be non-null, but we can't know for + // sure until runtime. So only attempt an attribution update if we found + // the attribution. + if (existingLinkAttribution != null) { + final newLinkText = changedNodeText.toPlainText().substring(rangeToUpdate.start, rangeToUpdate.end + 1); + final newLinkUri = Uri.tryParse(newLinkText); + final newScheme = newLinkUri?.scheme; + + late final LinkAttribution updatedAttribution; + if (newLinkUri != null && newScheme != null && newScheme.isNotEmpty) { + // The text includes a scheme - use that scheme. + updatedAttribution = LinkAttribution.fromUri(newLinkUri); + } else { + // The text doesn't include a scheme. + if (existingLinkAttribution.hasStructuredUri && existingLinkAttribution.uri!.scheme.isNotEmpty) { + // The existing link attribution has a structured URI, and that URI has a scheme. + // Retain the existing scheme. + final scheme = existingLinkAttribution.uri!.scheme; + updatedAttribution = LinkAttribution.fromUri(Uri.parse("$scheme://$newLinkText")); + } else { + // The existing link attribution doesn't have a structure URI, + // so we can't ask what scheme to use. Use the literal text as + // the full URL because that's the best we can do. It might even + // be an invalid URL or URI. + updatedAttribution = LinkAttribution(newLinkText); + } + } + + linkChangeRequests.add( + // Switch out the old link attribution for the new one. + AddTextAttributionsRequest( + documentRange: linkRange, + attributions: { + updatedAttribution, + }, + ), + ); + } + } + + linkChangeRequests.add( + // When the caret is in the middle of a link then the composer will automatically + // apply that style to the next character. Remove the current link style + // from the composer's preferences, so that as the user types, he doesn't + // immediately add the link attribution we just deleted. + RemoveComposerPreferenceStylesRequest( + attributionsToRemove.map((span) => span.attribution).toSet(), + ), + ); + + requestDispatcher.execute(linkChangeRequests); + } +} + +/// Parses the [text] as [Uri], prepending "https://" if it doesn't start +/// with "http://" or "https://". +// TODO: Make this private again. It was private, but we have some split linkification between the reaction +// and the paste behavior in common_editor_operations. Once we create a way for reactions to identify +// paste behaviors, move the paste linkification into the linkify reaction and make this private again. +Uri? tryToParseUrl(String word) { + // First, try extracting emails. + final extractedEmails = linkify( + word, + options: const LinkifyOptions( + humanize: false, + looseUrl: true, + ), + linkifiers: [ + const EmailLinkifier(), + ], + ); + final int emailCount = extractedEmails.fold(0, (value, element) => element is EmailElement ? value + 1 : value); + if (emailCount == 1) { + // Found exactly one email. Create and return a link attribution. + final emailElement = extractedEmails.first as EmailElement; + return Uri( + scheme: "mailto", + path: emailElement.emailAddress, + ); + } + + // Second, try extracting HTTP URLs. + final extractedLinks = linkify( + word, + options: const LinkifyOptions( + humanize: false, + looseUrl: true, + ), + linkifiers: [ + const UrlLinkifier(), + ], + ); + final int linkCount = extractedLinks.fold(0, (value, element) => element is UrlElement ? value + 1 : value); + if (linkCount == 1) { + // Found exactly 1 URL. Create and return an attribution. + try { + // Try to parse the word as a link. + final uri = Uri.parse(word); + if (uri.hasScheme) { + // URL is fully specified. Return it. + return uri; + } + + // The URL is missing a scheme. Add "https:" and re-parse. + return Uri.parse("https://$word"); + } catch (exception) { + // Something went wrong parsing the link. Fizzle. + return null; + } + } + + // Third, try directly parsing a non-http URL. + if (word.contains("://")) { + return Uri.tryParse(word); + } + + // Didn't find a URL in the given text. + return null; +} + +/// Configuration for the action that should happen when a text containing +/// a link attribution is modified, e.g., "google.com" becomes "gogle.com". +enum LinkUpdatePolicy { + /// When a linkified URL has characters added or deleted, the link remains the same. + preserve, + + /// When a linkified URL has characters added or removed, the link is updated to reflect the new URL value. + update, + + /// When a linkified URL has characters added or removed, the link is completely removed. + remove, +} + +/// An [EditReaction] which converts two dashes (--) to an em-dash (—). +/// +/// This reaction only applies when the user enters a dash (-) after +/// another dash in the same node. The upstream dash and the newly inserted +/// dash are removed and an em-dash (—) is inserted. +/// +/// This reaction applies to all [TextNode]s in the document. +class DashConversionReaction extends EditReaction { + const DashConversionReaction(); + + @override + void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { + final document = editorContext.document; + final composer = editorContext.find(Editor.composerKey); + + if (changeList.length < 2) { + // This reaction requires at least an insertion event and a selection change event. + // There are less than two events in the the change list, therefore this reaction + // shouldn't apply. Fizzle. + return; + } + + TextInsertionEvent? dashInsertionEvent; + for (final event in changeList) { + if (event is! DocumentEdit) { + continue; + } + + final change = event.change; + if (change is! TextInsertionEvent) { + continue; + } + if (change.text.toPlainText() != "-") { + continue; + } + + dashInsertionEvent = change; + break; + } + if (dashInsertionEvent == null) { + // The user didn't type a dash. + return; + } + + if (dashInsertionEvent.offset == 0) { + // There's nothing upstream from this dash, therefore it can't + // be a 2nd dash. + return; + } + + final insertionNode = document.getNodeById(dashInsertionEvent.nodeId) as TextNode; + final upstreamCharacter = insertionNode.text.toPlainText()[dashInsertionEvent.offset - 1]; + if (upstreamCharacter != '-') { + return; + } + + // A dash was inserted after another dash. + // Convert the two dashes to an em-dash. + requestDispatcher.execute([ + DeleteContentRequest( + documentRange: DocumentRange( + start: DocumentPosition( + nodeId: insertionNode.id, nodePosition: TextNodePosition(offset: dashInsertionEvent.offset - 1)), + end: DocumentPosition( + nodeId: insertionNode.id, nodePosition: TextNodePosition(offset: dashInsertionEvent.offset + 1)), + ), + ), + InsertTextRequest( + documentPosition: DocumentPosition( + nodeId: insertionNode.id, + nodePosition: TextNodePosition( + offset: dashInsertionEvent.offset - 1, + ), + ), + textToInsert: SpecialCharacters.emDash, + attributions: composer.preferences.currentAttributions, + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: insertionNode.id, + nodePosition: TextNodePosition(offset: dashInsertionEvent.offset), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ]); + } +} + +class EditInspector { + /// Returns `true` if the given [edits] end with the user typing a space anywhere + /// within a [TextNode], e.g., typing a " " between two words in a paragraph. + static bool didTypeSpace(Document document, List edits) { + if (edits.length < 2) { + // This reaction requires at least an insertion event and a selection change event. + // There are less than two events in the the change list, therefore this reaction + // shouldn't apply. Fizzle. + return false; + } + + // If the user typed a space, then the final document edit should be a text + // insertion event with a space " ". + DocumentEdit? lastDocumentEditEvent; + SelectionChangeEvent? lastSelectionChangeEvent; + for (int i = edits.length - 1; i >= 0; i -= 1) { + if (edits[i] is DocumentEdit) { + lastDocumentEditEvent = edits[i] as DocumentEdit; + } else if (lastSelectionChangeEvent == null && edits[i] is SelectionChangeEvent) { + lastSelectionChangeEvent = edits[i] as SelectionChangeEvent; + } + + if (lastDocumentEditEvent != null) { + break; + } + } + if (lastDocumentEditEvent == null) { + return false; + } + if (lastSelectionChangeEvent == null) { + return false; + } + + final textInsertionEvent = lastDocumentEditEvent.change; + if (textInsertionEvent is! TextInsertionEvent) { + return false; + } + if (textInsertionEvent.text.toPlainText() != " ") { + return false; + } + + if (lastSelectionChangeEvent.newSelection!.extent.nodeId != textInsertionEvent.nodeId) { + return false; + } + + final editedNode = document.getNodeById(textInsertionEvent.nodeId)!; + if (editedNode is! TextNode) { + return false; + } + + // The inserted text was a space. We assume this means that the user just typed a space. + return true; + } + + /// Finds and returns the last text the user typed within the given [edit]s, or `null` if + /// no text was typed. + static UserTypedText? findLastTextUserTyped(Document document, List edits) { + final lastSpaceInsertion = edits.whereType().lastWhereOrNull((edit) => + edit.change is TextInsertionEvent && (edit.change as TextInsertionEvent).text.toPlainText().endsWith(" ")); + if (lastSpaceInsertion == null) { + // The user didn't insert any text segment that ended with a space. + return null; + } + + final spaceInsertionChangeIndex = edits.indexWhere((edit) => edit == lastSpaceInsertion); + final selectionAfterInsertionIndex = + edits.indexWhere((edit) => edit is SelectionChangeEvent, spaceInsertionChangeIndex); + if (selectionAfterInsertionIndex < 0) { + // The text insertion wasn't followed by a selection change. It's not clear what this + // means, but we can't say with confidence that the user typed the space. Perhaps the + // space was injected by some other means. + return null; + } + + final newSelection = (edits[selectionAfterInsertionIndex] as SelectionChangeEvent).newSelection; + if (newSelection == null) { + // There's no selection, which indicates something other than the user typing. + return null; + } + if (!newSelection.isCollapsed) { + // The selection is expanded, which indicates something other than the user typing. + return null; + } + + final textInsertionEvent = lastSpaceInsertion.change as TextInsertionEvent; + if (textInsertionEvent.nodeId != newSelection.extent.nodeId) { + // The selection is in a different node than where tex was inserted. This indicates + // something other than a user typing. + return null; + } + + final newCaretOffset = (newSelection.extent.nodePosition as TextNodePosition).offset; + if (textInsertionEvent.offset + textInsertionEvent.text.length != newCaretOffset) { + return null; + } + + return UserTypedText( + textInsertionEvent.nodeId, + textInsertionEvent.offset, + textInsertionEvent.text, + ); + } + + EditInspector._(); +} + +class UserTypedText { + const UserTypedText(this.nodeId, this.offset, this.text); + + final String nodeId; + final int offset; + final AttributedText text; +} diff --git a/super_editor/lib/src/default_editor/document_caret_overlay.dart b/super_editor/lib/src/default_editor/document_caret_overlay.dart index 6bd03095e7..3c3dff308d 100644 --- a/super_editor/lib/src/default_editor/document_caret_overlay.dart +++ b/super_editor/lib/src/default_editor/document_caret_overlay.dart @@ -1,17 +1,27 @@ -import 'package:flutter/widgets.dart'; -import 'package:super_editor/src/core/document.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; +import 'package:super_editor/src/infrastructure/flutter/empty_box.dart'; +import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; import 'package:super_text_layout/super_text_layout.dart'; /// Document overlay that paints a caret with the given [caretStyle]. -class CaretDocumentOverlay extends StatefulWidget { +class CaretDocumentOverlay extends DocumentLayoutLayerStatefulWidget { const CaretDocumentOverlay({ Key? key, required this.composer, required this.documentLayoutResolver, - required this.caretStyle, - required this.document, + this.caretStyle = const CaretStyle( + width: 2, + color: Colors.black, + ), + this.platformOverride, + this.displayOnAllPlatforms = false, + this.displayCaretWithExpandedSelection = true, + this.blinkTimingMode = BlinkTimingMode.ticker, }) : super(key: key); /// The editor's [DocumentComposer], which reports the current selection. @@ -21,147 +31,203 @@ class CaretDocumentOverlay extends StatefulWidget { /// that the current selection can be mapped to an (x,y) offset and a height. final DocumentLayout Function() documentLayoutResolver; - /// The editor's [Document]. + /// The visual style of the caret that this overlay paints. + final CaretStyle caretStyle; + + /// The platform to use to determine caret behavior, defaults to [defaultTargetPlatform]. + final TargetPlatform? platformOverride; + + /// Whether to display a caret on all platforms, including mobile. /// - /// Some operations that affect caret position don't trigger a selection change, e.g., - /// indenting a list item. + /// By default, the caret is only displayed on desktop. + final bool displayOnAllPlatforms; + + /// Whether to display the caret when the selection is expanded. /// - /// We need to listen to all document changes to update the caret position when these - /// operations happen. - final Document document; + /// Defaults to `true`. + final bool displayCaretWithExpandedSelection; - /// The visual style of the caret that this overlay paints. - final CaretStyle caretStyle; + /// The timing mechanism used to blink, e.g., `Ticker` or `Timer`. + /// + /// `Timer`s are not expected to work in tests. + final BlinkTimingMode blinkTimingMode; @override - State createState() => _CaretDocumentOverlayState(); + DocumentLayoutLayerState createState() => CaretDocumentOverlayState(); } -class _CaretDocumentOverlayState extends State with SingleTickerProviderStateMixin { - final _caret = ValueNotifier(null); +@visibleForTesting +class CaretDocumentOverlayState extends DocumentLayoutLayerState + with SingleTickerProviderStateMixin { late final BlinkController _blinkController; - BoxConstraints? _previousConstraints; @override void initState() { super.initState(); - widget.composer.selectionNotifier.addListener(_scheduleCaretUpdate); - widget.document.addListener(_scheduleCaretUpdate); - _blinkController = BlinkController(tickerProvider: this)..startBlinking(); - // If we already have a selection, we need to display the caret. - if (widget.composer.selection != null) { - _scheduleCaretUpdate(); + switch (widget.blinkTimingMode) { + case BlinkTimingMode.ticker: + _blinkController = BlinkController(tickerProvider: this); + case BlinkTimingMode.timer: + _blinkController = BlinkController.withTimer(); } + + widget.composer.selectionNotifier.addListener(_onSelectionChange); + + _startOrStopBlinking(); } @override void didUpdateWidget(CaretDocumentOverlay oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.document != oldWidget.document) { - oldWidget.document.removeListener(_scheduleCaretUpdate); - widget.document.addListener(_scheduleCaretUpdate); - } - if (widget.composer != oldWidget.composer) { - oldWidget.composer.selectionNotifier.removeListener(_scheduleCaretUpdate); - widget.composer.selectionNotifier.addListener(_scheduleCaretUpdate); + oldWidget.composer.selectionNotifier.removeListener(_onSelectionChange); + widget.composer.selectionNotifier.addListener(_onSelectionChange); - // Selection has changed, we need to update the caret. - if (widget.composer.selection != oldWidget.composer.selection) { - _scheduleCaretUpdate(); - } + _startOrStopBlinking(); } } @override void dispose() { - widget.composer.selectionNotifier.removeListener(_scheduleCaretUpdate); - widget.document.removeListener(_scheduleCaretUpdate); + widget.composer.selectionNotifier.removeListener(_onSelectionChange); + _blinkController.dispose(); + super.dispose(); } - /// Schedules a caret update after the current frame. - void _scheduleCaretUpdate() { - // Give the document a frame to update its layout before we lookup - // the extent offset. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _updateCaretOffset(); - }); + @visibleForTesting + bool get isCaretVisible => _blinkController.opacity == 1.0 && !_shouldHideCaretForExpandedSelection; + + /// Returns `true` if the selection is currently expanded, and we want to hide the caret when + /// the selection is expanded. + /// + /// Returns `false` if the selection is collapsed or `null`, or if we want to show the caret + /// when the selection is expanded. + bool get _shouldHideCaretForExpandedSelection => + !widget.displayCaretWithExpandedSelection && widget.composer.selection?.isCollapsed == false; + + @visibleForTesting + Duration get caretFlashPeriod => _blinkController.flashPeriod; + + void _onSelectionChange() { + _updateCaretFlash(); + + if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { + // The Flutter pipeline isn't running. Schedule a re-build and re-position the caret. + setState(() { + // The caret is positioned in the build() call. + }); + } } - void _updateCaretOffset() { - if (!mounted) { + void _startOrStopBlinking() { + // TODO: allow a configurable policy as to whether to show the caret at all when the selection is expanded: https://github.com/superlistapp/super_editor/issues/234 + final wantsToBlink = widget.composer.selection != null; + if (wantsToBlink && _blinkController.isBlinking) { + return; + } + if (!wantsToBlink && !_blinkController.isBlinking) { return; } + wantsToBlink // + ? _blinkController.startBlinking() + : _blinkController.stopBlinking(); + } + + void _updateCaretFlash() { + // TODO: allow a configurable policy as to whether to show the caret at all when the selection is expanded: https://github.com/superlistapp/super_editor/issues/234 final documentSelection = widget.composer.selection; if (documentSelection == null) { - _caret.value = null; _blinkController.stopBlinking(); return; } - _blinkController.startBlinking(); _blinkController.jumpToOpaque(); + _startOrStopBlinking(); + } + + @override + Rect? computeLayoutDataWithDocumentLayout( + BuildContext contentLayersContext, BuildContext documentContext, DocumentLayout documentLayout) { + final documentSelection = widget.composer.selection; + if (documentSelection == null) { + return null; + } - final documentLayout = widget.documentLayoutResolver(); - _caret.value = documentLayout.getRectForPosition(documentSelection.extent)!; + final selectedComponent = documentLayout.getComponentByNodeId(widget.composer.selection!.extent.nodeId); + if (selectedComponent == null) { + // Assume that we're in a momentary transitive state where the document layout + // just gained or lost a component. We expect this method ot run again in a moment + // to correct for this. + return null; + } + + Rect caretRect = + documentLayout.getEdgeForPosition(documentSelection.extent)!.translate(-widget.caretStyle.width / 2, 0.0); + + final overlayBox = context.findRenderObject() as RenderBox?; + if (overlayBox != null && overlayBox.hasSize && caretRect.left + widget.caretStyle.width >= overlayBox.size.width) { + // Ajust the caret position to make it entirely visible because it's currently placed + // partially or entirely outside of the layers' bounds. This can happen for downstream selections + // of block components that take all the available width. + caretRect = Rect.fromLTWH( + overlayBox.size.width - widget.caretStyle.width, + caretRect.top, + caretRect.width, + caretRect.height, + ); + } + + return caretRect; } @override - Widget build(BuildContext context) { - // IgnorePointer so that when the user double and triple taps, the - // caret doesn't intercept those later taps. + Widget doBuild(BuildContext context, Rect? caret) { + // By default, don't show a caret on mobile because SuperEditor displays + // mobile carets and handles elsewhere. This can be overridden by settings + // `displayOnAllPlatforms` to true. + final platform = widget.platformOverride ?? defaultTargetPlatform; + if (!widget.displayOnAllPlatforms && (platform == TargetPlatform.android || platform == TargetPlatform.iOS)) { + return const EmptyBox(); + } + + if (_shouldHideCaretForExpandedSelection) { + return const EmptyBox(); + } + + // Use a RepaintBoundary so that caret flashing doesn't invalidate our + // ancestor painting. return IgnorePointer( - child: ValueListenableBuilder( - valueListenable: _caret, - builder: (context, caret, child) { - // We use a LayoutBuilder because the appropriate offset for the caret - // is based on the flow of content, which is based on the document's - // size/constraints. We need to re-calculate the caret offset when the - // constraints change. - return LayoutBuilder(builder: (context, constraints) { - if (_previousConstraints != null && constraints != _previousConstraints) { - // Use a post-frame callback to avoid calling setState() during build. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _updateCaretOffset(); - }); - } - _previousConstraints = constraints; - - return RepaintBoundary( - child: Stack( - children: [ - if (caret != null) - Positioned( - top: caret.top, - left: caret.left, - height: caret.height, - child: AnimatedBuilder( - animation: _blinkController, - builder: (context, child) { - return Container( - key: primaryCaretKey, - width: widget.caretStyle.width, - decoration: BoxDecoration( - color: widget.caretStyle.color.withOpacity(_blinkController.opacity), - borderRadius: widget.caretStyle.borderRadius, - ), - ); - }, + child: RepaintBoundary( + child: Stack( + clipBehavior: Clip.none, + children: [ + if (caret != null) + Positioned( + top: caret.top, + left: caret.left, + height: caret.height, + child: AnimatedBuilder( + animation: _blinkController, + builder: (context, child) { + return Container( + key: DocumentKeys.caret, + width: widget.caretStyle.width, + decoration: BoxDecoration( + color: widget.caretStyle.color.withValues(alpha: _blinkController.opacity), + borderRadius: widget.caretStyle.borderRadius, ), - ), - ], + ); + }, + ), ), - ); - }); - }, + ], + ), ), ); } } - -const primaryCaretKey = ValueKey("caret_primary"); diff --git a/super_editor/lib/src/default_editor/document_focus_and_selection_policies.dart b/super_editor/lib/src/default_editor/document_focus_and_selection_policies.dart new file mode 100644 index 0000000000..4333db661b --- /dev/null +++ b/super_editor/lib/src/default_editor/document_focus_and_selection_policies.dart @@ -0,0 +1,251 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// Widget that applies policies to an editor's focus and selection, such as placing the +/// caret at the end of a document when the editor receives focus, and clearing the +/// selection when the editor loses focus. +class EditorSelectionAndFocusPolicy extends StatefulWidget { + const EditorSelectionAndFocusPolicy({ + Key? key, + required this.focusNode, + required this.editor, + required this.document, + required this.selection, + required this.isDocumentLayoutAvailable, + required this.getDocumentLayout, + this.placeCaretAtEndOfDocumentOnGainFocus = true, + this.restorePreviousSelectionOnGainFocus = true, + this.clearSelectionWhenEditorLosesFocus = true, + required this.child, + }) : super(key: key); + + /// Returns whether or not we can access the document layout, which is needed for [placeCaretAtEndOfDocumentOnGainFocus]. + /// + /// When [SuperEditor] has `autofocus`, the focus change callback is called before we can access + /// the document layout using [getDocumentLayout]. If [getDocumentLayout] is called before we can + /// access the document layout we get an exception. + /// + /// When this method returns `true`, we assume it's safe to call [getDocumentLayout]. + final bool Function() isDocumentLayoutAvailable; + + /// The document editor's [FocusNode]. + /// + /// When focus is lost, this widget may clear the editor's selection. + final FocusNode focusNode; + + /// The [Editor], which alters the [document]. + final Editor editor; + + /// The editor's [Document]. + final Document document; + + /// The document editor's current selection. + final ValueListenable selection; + + /// Document layout, used to locate the last selectable piece of content in a document, + /// which is needed for [placeCaretAtEndOfDocumentOnGainFocus]. + final DocumentLayoutResolver getDocumentLayout; + + /// Whether the editor should automatically place the caret at the end of the document, + /// if the editor receives focus without an existing selection. + /// + /// [restorePreviousSelectionOnGainFocus] takes priority over this policy. + final bool placeCaretAtEndOfDocumentOnGainFocus; + + /// Whether the editor's previous selection should be restored when the editor re-gains + /// focus, after having previous lost focus. + final bool restorePreviousSelectionOnGainFocus; + + /// Whether the editor's selection should be removed when the editor loses + /// all focus (not just primary focus). + /// + /// If `true`, when focus moves to a different subtree, such as a popup text + /// field, or a button somewhere else on the screen, the editor will remove + /// its selection. When focus returns to the editor, the previous selection can + /// be restored, but that's controlled by other policies. + /// + /// If `false`, the editor will retain its selection, including a visual caret + /// and selected content, even when the editor doesn't have any focus, and can't + /// process any input. + final bool clearSelectionWhenEditorLosesFocus; + + final Widget child; + + @override + State createState() => _EditorSelectionAndFocusPolicyState(); +} + +class _EditorSelectionAndFocusPolicyState extends State { + bool _wasFocused = false; + DocumentSelection? _previousSelection; + + @override + void initState() { + super.initState(); + widget.focusNode.addListener(_onFocusChange); + _wasFocused = widget.focusNode.hasFocus; + + widget.selection.addListener(_onSelectionChange); + _previousSelection = widget.selection.value; + } + + @override + void didUpdateWidget(EditorSelectionAndFocusPolicy oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_onFocusChange); + widget.focusNode.addListener(_onFocusChange); + _onFocusChange(); + } + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + _onSelectionChange(); + } + } + + @override + void dispose() { + widget.focusNode.removeListener(_onFocusChange); + widget.selection.removeListener(_onSelectionChange); + super.dispose(); + } + + void _onFocusChange() { + // Ensure the editor has a selection when focused. + if (!_wasFocused && widget.focusNode.hasFocus) { + if (widget.restorePreviousSelectionOnGainFocus && _previousSelection != null) { + if (widget.document.getNodeById(_previousSelection!.base.nodeId) == null || + widget.document.getNodeById(_previousSelection!.extent.nodeId) == null) { + editorPoliciesLog.info( + "[${widget.runtimeType}] - not restoring previous editor selection because one of the selected nodes was deleted"); + return; + } + + if (widget.selection.value == _previousSelection) { + // The editor already has the correct selection. + return; + } + + if (_previousSelection == null) { + // There's no selection to restore. + return; + } + + // Restore the previous selection. + editorPoliciesLog + .info("[${widget.runtimeType}] - restoring previous editor selection because the editor re-gained focus"); + final previousSelection = _previousSelection!; + late final DocumentSelection restoredSelection; + final baseNode = widget.editor.context.document.getNodeById(previousSelection.base.nodeId); + final extentNode = widget.editor.context.document.getNodeById(previousSelection.extent.nodeId); + if (baseNode == null && extentNode == null) { + // The node(s) where the selection was previously are gone. Possibly deleted. + // Therefore, we can't restore the previous selection. Fizzle. + return; + } + + if (baseNode != null && extentNode != null) { + if (!baseNode.containsPosition(previousSelection.base.nodePosition)) { + // Either the base node content changed and the selection no longer fits, or the + // type of content in the node changed. Either way, we can't restore this selection. + return; + } + if (!extentNode.containsPosition(previousSelection.extent.nodePosition)) { + // Either the extent node content changed and the selection no longer fits, or the + // type of content in the node changed. Either way, we can't restore this selection. + return; + } + + // The base and extent nodes both still exist. Use the previous selection + // without modification. + restoredSelection = previousSelection; + } else if (baseNode == null) { + // The base node disappeared, but the extent node remains. + if (!extentNode!.containsPosition(previousSelection.extent.nodePosition)) { + // Either the extent node content changed and the selection no longer fits, or the + // type of content in the node changed. Either way, we can't restore this selection. + return; + } + + restoredSelection = DocumentSelection.collapsed(position: previousSelection.extent); + } else if (extentNode == null) { + // The extent node disappeared, but the base node remains. + if (!baseNode.containsPosition(previousSelection.base.nodePosition)) { + // Either the base node content changed and the selection no longer fits, or the + // type of content in the node changed. Either way, we can't restore this selection. + return; + } + + restoredSelection = DocumentSelection.collapsed(position: previousSelection.base); + } + + widget.editor.execute([ + ChangeSelectionRequest( + restoredSelection, + restoredSelection.isCollapsed ? SelectionChangeType.placeCaret : SelectionChangeType.expandSelection, + SelectionReason.contentChange, + ), + ]); + } else if (widget.placeCaretAtEndOfDocumentOnGainFocus) { + // Place the caret at the end of the document. + editorPoliciesLog + .info("[${widget.runtimeType}] - placing caret at end of document because the editor gained focus"); + if (!widget.isDocumentLayoutAvailable()) { + // We are focused, but the document hasn't been laid out yet. This could happen if SuperEditor has autofocus. + // Wait until the end of the frame, so we have access to the document layout. + editorPoliciesLog.info( + "[${widget.runtimeType}] - the document hasn't been laid out yet. Trying again at the end of the frame"); + WidgetsBinding.instance.scheduleFrameCallback((timeStamp) { + if (!mounted) { + return; + } + + _onFocusChange(); + }); + return; + } + + DocumentPosition? position = widget.getDocumentLayout().findLastSelectablePosition(); + if (position != null) { + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: position, + ), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ]); + } + } + } + + // (Maybe) remove the editor's selection when it loses focus. + if (!widget.focusNode.hasFocus && widget.clearSelectionWhenEditorLosesFocus) { + editorPoliciesLog.info("[${widget.runtimeType}] - clearing editor selection because the editor lost all focus"); + + widget.editor.execute([ + const ClearSelectionRequest(), + ]); + } + + _wasFocused = widget.focusNode.hasFocus; + } + + void _onSelectionChange() { + // TODO: avoiding null selections isn't always the right thing to do. If the editor purposefully clears + // its selection, we wouldn't want to restore the previous selection when focus changes. + if (widget.selection.value != null) { + _previousSelection = widget.selection.value; + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/super_editor/lib/src/default_editor/document_gestures_mouse.dart b/super_editor/lib/src/default_editor/document_gestures_mouse.dart index cb12a5e499..4bd106377c 100644 --- a/super_editor/lib/src/default_editor/document_gestures_mouse.dart +++ b/super_editor/lib/src/default_editor/document_gestures_mouse.dart @@ -1,18 +1,26 @@ -import 'dart:math'; +import 'dart:async'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/box_component.dart'; import 'package:super_editor/src/default_editor/document_scrollable.dart'; -import 'package:super_editor/src/default_editor/document_selection_on_focus_mixin.dart'; import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; import 'package:super_editor/src/default_editor/text_tools.dart'; import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; + +import '../infrastructure/document_gestures_interaction_overrides.dart'; /// Governs mouse gesture interaction with a document, such as scrolling /// a document with a scroll wheel, tapping to place a caret, and @@ -30,27 +38,47 @@ import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; /// components /// - automatically scrolls up or down when the user drags near /// a boundary +/// +/// Whenever a selection change caused by a [SelectionReason.userInteraction] happens, +/// [DocumentMouseInteractor] auto-scrolls the editor to make the selection region visible. class DocumentMouseInteractor extends StatefulWidget { const DocumentMouseInteractor({ Key? key, this.focusNode, + required this.editor, required this.document, required this.getDocumentLayout, - required this.selection, + required this.selectionNotifier, + required this.selectionChanges, + this.contentTapHandlers, required this.autoScroller, + required this.fillViewport, this.showDebugPaint = false, required this.child, }) : super(key: key); final FocusNode? focusNode; + final Editor editor; final Document document; final DocumentLayoutResolver getDocumentLayout; - final ValueNotifier selection; + final Stream selectionChanges; + final ValueListenable selectionNotifier; + + /// Optional list of handlers that respond to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List? contentTapHandlers; /// Auto-scrolling delegate. final AutoScrollController autoScroller; + /// Whether the document gesture detector should fill the entire viewport + /// even if the actual content is smaller. + final bool fillViewport; + /// Paints some extra visual ornamentation to help with /// debugging, when `true`. final bool showDebugPaint; @@ -62,33 +90,49 @@ class DocumentMouseInteractor extends StatefulWidget { State createState() => _DocumentMouseInteractorState(); } -class _DocumentMouseInteractorState extends State - with SingleTickerProviderStateMixin, DocumentSelectionOnFocusMixin { - final _documentWrapperKey = GlobalKey(); - +class _DocumentMouseInteractorState extends State with SingleTickerProviderStateMixin { late FocusNode _focusNode; + DocumentSelection? _previousSelection; + // Tracks user drag gestures for selection purposes. SelectionType _selectionType = SelectionType.position; Offset? _dragStartGlobal; + // The selection's document position where the user started dragging an expanded selection. + // The selection base is cached instead of continuously re-computed because components + // can change size and position during selection. + DocumentPosition? _dragSelectionBase; Offset? _dragEndGlobal; bool _expandSelectionDuringDrag = false; + // When selecting by word, this is the initial word's upstream position. + DocumentPosition? _wordSelectionUpstream; + // When selecting by word, this is the initial word's downstream position. + DocumentPosition? _wordSelectionDownstream; /// Holds which kind of device started a pan gesture, e.g., a mouse or a trackpad. PointerDeviceKind? _panGestureDevice; + late StreamSubscription _selectionSubscription; + + DocumentSelection? get _currentSelection => widget.selectionNotifier.value; + + final _mouseCursor = ValueNotifier(SystemMouseCursors.text); + Offset? _lastHoverOffset; + @override void initState() { super.initState(); _focusNode = widget.focusNode ?? FocusNode(); - widget.selection.addListener(_onSelectionChange); - widget.autoScroller.addListener(_updateDragSelection); - - startSyncingSelectionWithFocus( - focusNode: _focusNode, - getDocumentLayout: widget.getDocumentLayout, - selection: widget.selection, - ); + _selectionSubscription = widget.selectionChanges.listen(_onSelectionChange); + _previousSelection = widget.selectionNotifier.value; + widget.autoScroller + ..addListener(_updateDragSelection) + ..addListener(_updateMouseCursorAtLatestOffset); + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + handler.addListener(_updateMouseCursorAtLatestOffset); + } + } } @override @@ -96,28 +140,51 @@ class _DocumentMouseInteractorState extends State super.didUpdateWidget(oldWidget); if (widget.focusNode != oldWidget.focusNode) { _focusNode = widget.focusNode ?? FocusNode(); - onFocusNodeReplaced(_focusNode); } - if (widget.selection != oldWidget.selection) { - oldWidget.selection.removeListener(_onSelectionChange); - widget.selection.addListener(_onSelectionChange); - onDocumentSelectionNotifierReplaced(widget.selection); + if (widget.selectionNotifier != oldWidget.selectionNotifier) { + _previousSelection = widget.selectionNotifier.value; + } + if (widget.selectionChanges != oldWidget.selectionChanges) { + _selectionSubscription.cancel(); + _selectionSubscription = widget.selectionChanges.listen(_onSelectionChange); } if (widget.autoScroller != oldWidget.autoScroller) { - oldWidget.autoScroller.removeListener(_updateDragSelection); - widget.autoScroller.addListener(_updateDragSelection); + oldWidget.autoScroller + ..removeListener(_updateDragSelection) + ..removeListener(_updateMouseCursorAtLatestOffset); + widget.autoScroller + ..addListener(_updateDragSelection) + ..addListener(_updateMouseCursorAtLatestOffset); + } + if (!const DeepCollectionEquality().equals(oldWidget.contentTapHandlers, widget.contentTapHandlers)) { + if (oldWidget.contentTapHandlers != null) { + for (final handler in oldWidget.contentTapHandlers!) { + handler.removeListener(_updateMouseCursorAtLatestOffset); + } + } + + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + handler.addListener(_updateMouseCursorAtLatestOffset); + } + } } - onDocumentLayoutResolverReplaced(widget.getDocumentLayout); } @override void dispose() { + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + handler.removeListener(_updateMouseCursorAtLatestOffset); + } + } if (widget.focusNode == null) { _focusNode.dispose(); } - widget.selection.removeListener(_onSelectionChange); - widget.autoScroller.removeListener(_updateDragSelection); - stopSyncingSelectionWithFocus(); + _selectionSubscription.cancel(); + widget.autoScroller + ..removeListener(_updateDragSelection) + ..removeListener(_updateMouseCursorAtLatestOffset); super.dispose(); } @@ -130,30 +197,54 @@ class _DocumentMouseInteractorState extends State } bool get _isShiftPressed => - (RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft) || - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftRight) || - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shift)) && + (HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shift)) && // TODO: this condition doesn't belong here. Move it to where it applies - widget.selection.value != null; - - void _onSelectionChange() { - if (mounted) { - // Use a post-frame callback to "ensure selection extent is visible" - // so that any pending visual document changes can happen before - // attempting to calculate the visual position of the selection extent. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - editorGesturesLog.finer("Ensuring selection extent is visible because the doc selection changed"); - - final globalExtentRect = _getSelectionExtentAsGlobalRect(); - if (globalExtentRect != null) { - widget.autoScroller.ensureGlobalRectIsVisible(globalExtentRect); - } - }); + _currentSelection != null; + + void _onSelectionChange(DocumentSelectionChange selectionChange) { + if (!mounted) { + return; + } + + if (selectionChange.reason != SelectionReason.userInteraction) { + // The selection changed, but it isn't caused by an user interaction. + // We don't want auto-scroll. + return; + } + if (selectionChange.selection == _previousSelection) { + // The selection hasn't actually changed. We don't want to auto-scroll. + return; + } + _previousSelection = selectionChange.selection; + + final selection = widget.selectionNotifier.value; + if (selection == null) { + return; + } + + final node = widget.document.getNodeById(selection.extent.nodeId); + if (node is BlockNode) { + // We don't want auto-scroll block components. + return; } + + // Use a post-frame callback to "ensure selection extent is visible" + // so that any pending visual document changes can happen before + // attempting to calculate the visual position of the selection extent. + onNextFrame((_) { + editorGesturesLog.finer("Ensuring selection extent is visible because the doc selection changed"); + + final globalExtentRect = _getSelectionExtentAsGlobalRect(); + if (globalExtentRect != null) { + widget.autoScroller.ensureGlobalRectIsVisible(globalExtentRect); + } + }); } Rect? _getSelectionExtentAsGlobalRect() { - final selection = widget.selection.value; + final selection = _currentSelection; if (selection == null) { return null; } @@ -180,11 +271,28 @@ class _DocumentMouseInteractorState extends State editorGesturesLog.info("Tap up on document"); final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); editorGesturesLog.fine(" - document offset: $docOffset"); - final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); - editorGesturesLog.fine(" - tapped document position: $docPosition"); _focusNode.requestFocus(); + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + } + + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + editorGesturesLog.fine(" - tapped document position: $docPosition"); if (docPosition == null) { editorGesturesLog.fine("No document content at ${details.globalPosition}."); _clearSelection(); @@ -192,7 +300,7 @@ class _DocumentMouseInteractorState extends State } final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; - final expandSelection = _isShiftPressed && widget.selection.value != null; + final expandSelection = _isShiftPressed && _currentSelection != null; if (!tappedComponent.isVisualSelectionSupported()) { _moveToNearestSelectableComponent( @@ -206,9 +314,16 @@ class _DocumentMouseInteractorState extends State if (expandSelection) { // The user tapped while pressing shift and there's an existing // selection. Move the extent of the selection to where the user tapped. - widget.selection.value = widget.selection.value!.copyWith( - extent: docPosition, - ); + widget.editor.execute([ + ChangeSelectionRequest( + _currentSelection!.copyWith( + extent: docPosition, + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); } else { // Place the document selection at the location where the // user tapped. @@ -221,9 +336,26 @@ class _DocumentMouseInteractorState extends State editorGesturesLog.info("Double tap down on document"); final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); editorGesturesLog.fine(" - document offset: $docOffset"); + + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onDoubleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + } + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null) { final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; if (!tappedComponent.isVisualSelectionSupported()) { @@ -232,13 +364,19 @@ class _DocumentMouseInteractorState extends State } _selectionType = SelectionType.word; - _clearSelection(); + bool didSelectContent = false; if (docPosition != null) { - bool didSelectContent = _selectWordAt( + didSelectContent = _selectWordAt( docPosition: docPosition, docLayout: _docLayout, ); + if (didSelectContent) { + // We selected a word - store the word bounds so that we can correctly + // select by word when moving upstream or downstream from this word. + _wordSelectionUpstream = widget.selectionNotifier.value!.start; + _wordSelectionDownstream = widget.selectionNotifier.value!.end; + } if (!didSelectContent) { didSelectContent = _selectBlockAt(docPosition); @@ -251,6 +389,15 @@ class _DocumentMouseInteractorState extends State } } + // Only clear the existing selection if we were not able to place a new selection, + // because clearing the selection might close the IME connection, depending + // on the `SuperEditorImePolicies` used. If we cleared the selection and then + // placed a new selection, the IME connection would be closed and then immediately + // reopened, and this doesn't seem to work on Safari and Firefox. + if (!didSelectContent) { + _clearSelection(); + } + _focusNode.requestFocus(); } @@ -260,7 +407,13 @@ class _DocumentMouseInteractorState extends State }) { final newSelection = getWordSelection(docPosition: docPosition, docLayout: docLayout); if (newSelection != null) { - widget.selection.value = newSelection; + widget.editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); return true; } else { return false; @@ -272,16 +425,22 @@ class _DocumentMouseInteractorState extends State return false; } - widget.selection.value = DocumentSelection( - base: DocumentPosition( - nodeId: position.nodeId, - nodePosition: const UpstreamDownstreamNodePosition.upstream(), - ), - extent: DocumentPosition( - nodeId: position.nodeId, - nodePosition: const UpstreamDownstreamNodePosition.downstream(), + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: position.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: position.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, ), - ); + ]); return true; } @@ -295,9 +454,26 @@ class _DocumentMouseInteractorState extends State editorGesturesLog.info("Triple down down on document"); final docOffset = _getDocOffsetFromGlobalOffset(details.globalPosition); editorGesturesLog.fine(" - document offset: $docOffset"); + + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTripleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + } + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null) { final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; if (!tappedComponent.isVisualSelectionSupported()) { @@ -329,7 +505,13 @@ class _DocumentMouseInteractorState extends State }) { final newSelection = getParagraphSelection(docPosition: docPosition, docLayout: docLayout); if (newSelection != null) { - widget.selection.value = newSelection; + widget.editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); return true; } else { return false; @@ -343,9 +525,16 @@ class _DocumentMouseInteractorState extends State void _selectPosition(DocumentPosition position) { editorGesturesLog.fine("Setting document selection to $position"); - widget.selection.value = DocumentSelection.collapsed( - position: position, - ); + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: position, + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); } void _onPanStart(DragStartDetails details) { @@ -381,16 +570,6 @@ class _DocumentMouseInteractorState extends State editorGesturesLog .info("Pan update on document, global offset: ${details.globalPosition}, device: $_panGestureDevice"); - if (_panGestureDevice == PointerDeviceKind.trackpad) { - // The user dragged using two fingers on a trackpad. - // Scroll the document and keep the selection unchanged. - // We multiply by -1 because the scroll should be in the opposite - // direction of the drag, e.g., dragging up on a trackpad scrolls - // the document to downstream direction. - _scrollVertically(details.delta.dy * -1); - return; - } - setState(() { _dragEndGlobal = details.globalPosition; @@ -404,13 +583,6 @@ class _DocumentMouseInteractorState extends State void _onPanEnd(DragEndDetails details) { editorGesturesLog.info("Pan end on document, device: $_panGestureDevice"); - - if (_panGestureDevice == PointerDeviceKind.trackpad) { - // The user ended a pan gesture with two fingers on a trackpad. - // We already scrolled the document. - widget.autoScroller.goBallistic(-details.velocity.pixelsPerSecond.dy); - return; - } _onDragEnd(); } @@ -422,36 +594,17 @@ class _DocumentMouseInteractorState extends State void _onDragEnd() { setState(() { _dragStartGlobal = null; + _dragSelectionBase = null; _dragEndGlobal = null; _expandSelectionDuringDrag = false; + _wordSelectionUpstream = null; + _wordSelectionDownstream = null; + _selectionType = SelectionType.position; }); widget.autoScroller.disableAutoScrolling(); } - /// Scrolls the document vertically by [delta] pixels. - void _scrollVertically(double delta) { - widget.autoScroller.jumpBy(delta); - _updateDragSelection(); - } - - /// We prevent SingleChildScrollView from processing mouse events because - /// it scrolls by drag by default, which we don't want. However, we do - /// still want mouse scrolling. This method re-implements a primitive - /// form of mouse scrolling. - void _scrollOnMouseWheel(PointerSignalEvent event) { - if (event is PointerScrollEvent) { - _scrollVertically(event.scrollDelta.dy); - } - } - - /// Beginning with Flutter 3.3.3, we are responsible for starting and - /// stopping scroll momentum. This method cancels any scroll momentum - /// in our scroll controller. - void _cancelScrollMomentum() { - widget.autoScroller.goIdle(); - } - void _updateDragSelection() { if (_dragEndGlobal == null) { // User isn't dragging. No need to update drag selection. @@ -495,12 +648,15 @@ Updating drag selection: baseOffsetInDocument, extentOffsetInDocument, ); - DocumentPosition? basePosition = selection?.base; + + _dragSelectionBase ??= selection?.base; + + DocumentPosition? basePosition = _dragSelectionBase; DocumentPosition? extentPosition = selection?.extent; editorGesturesLog.fine(" - base: $basePosition, extent: $extentPosition"); if (basePosition == null || extentPosition == null) { - widget.selection.value = null; + _clearSelection(); return; } @@ -510,7 +666,7 @@ Updating drag selection: docLayout: documentLayout, ); if (baseParagraphSelection == null) { - widget.selection.value = null; + _clearSelection(); return; } basePosition = baseOffsetInDocument.dy < extentOffsetInDocument.dy @@ -522,45 +678,56 @@ Updating drag selection: docLayout: documentLayout, ); if (extentParagraphSelection == null) { - widget.selection.value = null; + _clearSelection(); return; } extentPosition = baseOffsetInDocument.dy < extentOffsetInDocument.dy ? extentParagraphSelection.extent : extentParagraphSelection.base; } else if (selectionType == SelectionType.word) { - final baseWordSelection = getWordSelection( - docPosition: basePosition, - docLayout: documentLayout, + final dragDirection = widget.document.getAffinityBetween( + base: _wordSelectionUpstream!, + extent: selection!.extent, ); - if (baseWordSelection == null) { - widget.selection.value = null; - return; - } - basePosition = baseWordSelection.base; + + basePosition = dragDirection == TextAffinity.downstream // + ? _wordSelectionUpstream! + : _wordSelectionDownstream!; final extentWordSelection = getWordSelection( docPosition: extentPosition, docLayout: documentLayout, ); if (extentWordSelection == null) { - widget.selection.value = null; + _clearSelection(); return; } - extentPosition = extentWordSelection.extent; + extentPosition = dragDirection == TextAffinity.downstream // + ? extentWordSelection.end + : extentWordSelection.start; } - widget.selection.value = (DocumentSelection( - // If desired, expand the selection instead of replacing it. - base: expandSelection ? widget.selection.value?.base ?? basePosition : basePosition, - extent: extentPosition, - )); - editorGesturesLog.fine("Selected region: ${widget.selection.value}"); + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + // If desired, expand the selection instead of replacing it. + base: expandSelection ? _currentSelection?.base ?? basePosition : basePosition, + extent: extentPosition, + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + + editorGesturesLog.fine("Selected region: $_currentSelection"); } void _clearSelection() { editorGesturesLog.fine("Clearing document selection"); - widget.selection.value = null; + widget.editor.execute([ + const ClearSelectionRequest(), + const ClearComposingRegionRequest(), + ]); } void _moveToNearestSelectableComponent( @@ -569,9 +736,10 @@ Updating drag selection: bool expandSelection = false, }) { moveSelectionToNearestSelectableNode( + editor: widget.editor, document: widget.document, documentLayoutResolver: widget.getDocumentLayout, - selection: widget.selection, + currentSelection: widget.selectionNotifier.value, startingNode: widget.document.getNodeById(nodeId)!, expand: expandSelection, ); @@ -581,28 +749,69 @@ Updating drag selection: } } + void _onMouseMove(PointerHoverEvent event) { + _updateMouseCursor(event.position); + _lastHoverOffset = event.position; + } + + void _updateMouseCursorAtLatestOffset() { + if (_lastHoverOffset == null) { + return; + } + _updateMouseCursor(_lastHoverOffset!); + } + + void _updateMouseCursor(Offset globalPosition) { + final docOffset = _getDocOffsetFromGlobalOffset(globalPosition); + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); + if (docPosition == null) { + _mouseCursor.value = SystemMouseCursors.text; + return; + } + + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final cursorForContent = handler.mouseCursorForContentHover(docPosition); + if (cursorForContent != null) { + _mouseCursor.value = cursorForContent; + return; + } + } + } + + _mouseCursor.value = SystemMouseCursors.text; + } + @override Widget build(BuildContext context) { - return Listener( - onPointerSignal: _scrollOnMouseWheel, - onPointerHover: (event) => _cancelScrollMomentum(), - onPointerDown: (event) => _cancelScrollMomentum(), - onPointerPanZoomStart: (event) => _cancelScrollMomentum(), - child: _buildCursorStyle( - child: _buildGestureInput( - child: _buildDocumentContainer( - document: widget.child, + return SliverHybridStack( + fillViewport: widget.fillViewport, + children: [ + Listener( + onPointerHover: _onMouseMove, + child: _buildCursorStyle( + child: _buildGestureInput( + child: const SizedBox(), + ), ), ), - ), + widget.child, + ], ); } Widget _buildCursorStyle({ required Widget child, }) { - return MouseRegion( - cursor: SystemMouseCursors.text, + return ValueListenableBuilder( + valueListenable: _mouseCursor, + builder: (context, value, child) { + return MouseRegion( + cursor: _mouseCursor.value, + onExit: (_) => _lastHoverOffset = null, + child: child, + ); + }, child: child, ); } @@ -610,6 +819,7 @@ Updating drag selection: Widget _buildGestureInput({ required Widget child, }) { + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; return RawGestureDetector( behavior: HitTestBehavior.translucent, gestures: { @@ -621,95 +831,28 @@ Updating drag selection: ..onDoubleTapDown = _onDoubleTapDown ..onDoubleTap = _onDoubleTap ..onTripleTapDown = _onTripleTapDown - ..onTripleTap = _onTripleTap; + ..onTripleTap = _onTripleTap + ..gestureSettings = gestureSettings; }, ), PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), + () => PanGestureRecognizer(supportedDevices: { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + }), (PanGestureRecognizer recognizer) { recognizer ..onStart = _onPanStart ..onUpdate = _onPanUpdate ..onEnd = _onPanEnd - ..onCancel = _onPanCancel; + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; }, ), }, child: child, ); } - - Widget _buildDocumentContainer({ - required Widget document, - }) { - return Align( - alignment: Alignment.topCenter, - child: Stack( - children: [ - SizedBox( - key: _documentWrapperKey, - child: document, - ), - if (widget.showDebugPaint) // - ..._buildDebugPaintInDocSpace(), - ], - ), - ); - } - - List _buildDebugPaintInDocSpace() { - final dragStartInDoc = _dragStartGlobal != null - ? _getDocOffsetFromGlobalOffset(_dragStartGlobal!) + Offset(0, widget.autoScroller.deltaWhileAutoScrolling) - : null; - final dragEndInDoc = _dragEndGlobal != null ? _getDocOffsetFromGlobalOffset(_dragEndGlobal!) : null; - - return [ - if (dragStartInDoc != null) - Positioned( - left: dragStartInDoc.dx, - top: dragStartInDoc.dy, - child: FractionalTranslation( - translation: const Offset(-0.5, -0.5), - child: Container( - width: 16, - height: 16, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Color(0xFF0088FF), - ), - ), - ), - ), - if (dragEndInDoc != null) - Positioned( - left: dragEndInDoc.dx, - top: dragEndInDoc.dy, - child: FractionalTranslation( - translation: const Offset(-0.5, -0.5), - child: Container( - width: 16, - height: 16, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Color(0xFF0088FF), - ), - ), - ), - ), - if (dragStartInDoc != null && dragEndInDoc != null) - Positioned( - left: min(dragStartInDoc.dx, dragEndInDoc.dx), - top: min(dragStartInDoc.dy, dragEndInDoc.dy), - width: (dragEndInDoc.dx - dragStartInDoc.dx).abs(), - height: (dragEndInDoc.dy - dragStartInDoc.dy).abs(), - child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFF0088FF), width: 3), - ), - ), - ), - ]; - } } /// Paints a rectangle border around the given `selectionRect`. diff --git a/super_editor/lib/src/default_editor/document_gestures_touch.dart b/super_editor/lib/src/default_editor/document_gestures_touch.dart deleted file mode 100644 index aca51553d2..0000000000 --- a/super_editor/lib/src/default_editor/document_gestures_touch.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:super_editor/src/infrastructure/document_gestures.dart'; -import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/_scrolling.dart'; - -/// Platform independent tools for touch gesture interaction with a -/// document, such as dragging to scroll a document, and dragging -/// handles to expand a selection. -/// -/// See also: -/// * document_gestures_touch_ios for iOS-specific touch gesture tools. -/// * document_gestures_touch_android for Android-specific touch gesture tools. -/// * super_editor's mouse gesture support. - -/// Displays the given [child] document within a `Scrollable`, if and only -/// if there is no ancestor `Scrollable` in the widget tree. -/// -/// The given [scrollController] is attached to inner `Scrollable`, when -/// a `Scrollable` is included in this widget tree. -/// -/// The [documentLayerLink] is given a `CompositedTransformTarget` that -/// surrounds the document. -class ScrollableDocument extends StatelessWidget { - const ScrollableDocument({ - Key? key, - this.scrollController, - this.disableDragScrolling = false, - required this.documentLayerLink, - required this.child, - }) : super(key: key); - - /// `ScrollController` that's attached to the `Scrollable` in this - /// widget, if a `Scrollable` is added. - /// - /// A `Scrollable` is added if, and only if, there is no ancestor - /// `Scrollable` in the widget tree. - final ScrollController? scrollController; - - /// Whether to disable drag-based scrolling, for cases in which drag - /// behaviors are handled elsewhere, e.g., the user drags a handle that's - /// displayed within the document. - final bool disableDragScrolling; - - /// `LayerLink` that will be aligned to the top-left of the document layout. - final LayerLink documentLayerLink; - - /// The document layout widget. - final Widget child; - - ScrollableState? _findAncestorScrollable(BuildContext context) { - final ancestorScrollable = Scrollable.of(context); - if (ancestorScrollable == null) { - return null; - } - - final direction = ancestorScrollable.axisDirection; - // If the direction is horizontal, then we are inside a widget like a TabBar - // or a horizontal ListView, so we can't use the ancestor scrollable - if (direction == AxisDirection.left || direction == AxisDirection.right) { - return null; - } - - return ancestorScrollable; - } - - @override - Widget build(BuildContext context) { - final ancestorScrollable = _findAncestorScrollable(context); - final ancestorScrollPosition = ancestorScrollable?.position; - final addScrollView = ancestorScrollPosition == null; - - return addScrollView - ? SizedBox( - height: double.infinity, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }), - child: SingleChildScrollView( - physics: disableDragScrolling ? const NeverScrollableScrollPhysics() : null, - controller: scrollController, - child: _buildDocument(), - ), - ), - ) - : _buildDocument(); - } - - Widget _buildDocument() { - return Center( - child: CompositedTransformTarget( - link: documentLayerLink, - child: child, - ), - ); - } -} diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart index dfd9cdfbe6..80c6c1995d 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart @@ -1,55 +1,485 @@ -import 'dart:math'; +import 'dart:async'; +import 'dart:ui'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/document_operations/selection_operations.dart'; -import 'package:super_editor/src/default_editor/document_selection_on_focus_mixin.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; import 'package:super_editor/src/default_editor/text_tools.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; +import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/blinking_caret.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart'; +import 'package:super_editor/src/infrastructure/flutter/empty_box.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/android/android_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/drag_handle_selection.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/long_press_selection.dart'; import 'package:super_editor/src/infrastructure/platforms/android/magnifier.dart'; import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; -import 'package:super_editor/src/infrastructure/toolbar_position_delegate.dart'; +import 'package:super_editor/src/infrastructure/signal_notifier.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; -import 'package:super_text_layout/super_text_layout.dart'; +import 'package:super_keyboard/super_keyboard.dart'; import '../infrastructure/document_gestures.dart'; -import 'document_gestures_touch.dart'; +import '../infrastructure/document_gestures_interaction_overrides.dart'; import 'selection_upstream_downstream.dart'; +/// An [InheritedWidget] that provides shared access to a [SuperEditorAndroidControlsController], +/// which coordinates the state of Android controls like the caret, handles, magnifier, etc. +/// +/// This widget and its associated controller exist so that [SuperEditor] has maximum freedom +/// in terms of where to implement Android gestures vs carets vs the magnifier vs the toolbar. +/// Each of these responsibilities have some unique differences, which make them difficult or +/// impossible to implement within a single widget. By sharing a controller, a group of independent +/// widgets can work together to cover those various responsibilities. +/// +/// Centralizing a controller in an [InheritedWidget] also allows [SuperEditor] to share that +/// control with application code outside of [SuperEditor], by placing a [SuperEditorAndroidControlsScope] +/// above the [SuperEditor] in the widget tree. For this reason, [SuperEditor] should access +/// the [SuperEditorAndroidControlsScope] through [rootOf]. +class SuperEditorAndroidControlsScope extends InheritedWidget { + /// Finds the highest [SuperEditorAndroidControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperEditorAndroidControlsController]. + static SuperEditorAndroidControlsController rootOf(BuildContext context) { + final data = maybeRootOf(context); + + if (data == null) { + throw Exception( + "Tried to depend upon the root SuperEditorAndroidControlsScope but no such ancestor widget exists."); + } + + return data; + } + + static SuperEditorAndroidControlsController? maybeRootOf(BuildContext context) { + InheritedElement? root; + + context.visitAncestorElements((element) { + if (element is! InheritedElement || element.widget is! SuperEditorAndroidControlsScope) { + // Keep visiting. + return true; + } + + root = element; + + // Keep visiting, to ensure we get the root scope. + return true; + }); + + if (root == null) { + return null; + } + + // Create build dependency on the Android controls context. + context.dependOnInheritedElement(root!); + + // Return the current Android controls data. + return (root!.widget as SuperEditorAndroidControlsScope).controller; + } + + /// Finds the nearest [SuperEditorAndroidControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperEditorAndroidControlsController]. + static SuperEditorAndroidControlsController nearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()!.controller; + + static SuperEditorAndroidControlsController? maybeNearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.controller; + + const SuperEditorAndroidControlsScope({ + super.key, + required this.controller, + required super.child, + }); + + final SuperEditorAndroidControlsController controller; + + @override + bool updateShouldNotify(SuperEditorAndroidControlsScope oldWidget) { + return controller != oldWidget.controller; + } +} + +/// A controller, which coordinates the state of various Android editor controls, including +/// the caret, handles, magnifier, and toolbar. +class SuperEditorAndroidControlsController { + SuperEditorAndroidControlsController({ + this.controlsColor, + LeaderLink? collapsedHandleFocalPoint, + this.collapsedHandleBuilder, + LeaderLink? upstreamHandleFocalPoint, + LeaderLink? downstreamHandleFocalPoint, + this.expandedHandlesBuilder, + this.magnifierBuilder, + this.toolbarBuilder, + this.createOverlayControlsClipper, + }) : collapsedHandleFocalPoint = collapsedHandleFocalPoint ?? LeaderLink(), + upstreamHandleFocalPoint = upstreamHandleFocalPoint ?? LeaderLink(), + downstreamHandleFocalPoint = downstreamHandleFocalPoint ?? LeaderLink(); + + void dispose() { + cancelCollapsedHandleAutoHideCountdown(); + _shouldCaretBlink.dispose(); + _shouldShowMagnifier.dispose(); + _shouldShowToolbar.dispose(); + } + + /// Whether the caret should blink right now. + ValueListenable get shouldCaretBlink => _shouldCaretBlink; + final _shouldCaretBlink = ValueNotifier(true); + + /// Tells the caret to blink by setting [shouldCaretBlink] to `true`. + void blinkCaret() { + _shouldCaretBlink.value = true; + } + + /// Tells the caret to stop blinking by setting [shouldCaretBlink] to `false`. + void doNotBlinkCaret() { + _shouldCaretBlink.value = false; + } + + /// Signal that's notified when the caret should return to fully opaque, such as + /// when the user moves the caret. + final caretJumpToOpaqueSignal = SignalNotifier(); + + /// Immediately make the caret fully opaque. + void jumpCaretToOpaque() { + caretJumpToOpaqueSignal.notifyListeners(); + } + + /// Color of the caret and text selection drag handles on Android. + /// + /// The default handle builders honor this color. If custom handle builders are + /// provided, its up to those handle builders to honor this color, or not. + final Color? controlsColor; + + /// The focal point for the collapsed drag handle. + /// + /// The collapsed handle builder should place the handle near this focal point. + final LeaderLink collapsedHandleFocalPoint; + + /// Whether the collapsed drag handle should be displayed right now. + /// + /// This value is enforced to be opposite of [shouldShowExpandedHandles]. + ValueListenable get shouldShowCollapsedHandle => _shouldShowCollapsedHandle; + final _shouldShowCollapsedHandle = ValueNotifier(false); + + Timer? _collapsedHandleAutoHideCountdown; + + /// Shows the collapsed drag handle by setting [shouldShowCollapsedHandle] to `true`, and also + /// hides the expanded handle by setting [shouldShowExpandedHandles] to `false`. + void showCollapsedHandle() { + cancelCollapsedHandleAutoHideCountdown(); + + _shouldShowCollapsedHandle.value = true; + _shouldShowExpandedHandles.value = false; + } + + /// Starts a short countdown, after which the collapsed handle will be + /// hidden (the caret will remain visible). + void startCollapsedHandleAutoHideCountdown() { + _collapsedHandleAutoHideCountdown?.cancel(); + + _collapsedHandleAutoHideCountdown = Timer(const Duration(seconds: 5), () { + hideCollapsedHandle(); + }); + } + + /// Cancels any on-going timer started by [startCollapsedHandleAutoHideCountdown]. + void cancelCollapsedHandleAutoHideCountdown() { + _collapsedHandleAutoHideCountdown?.cancel(); + _collapsedHandleAutoHideCountdown = null; + } + + /// Hides the collapsed drag handle by setting [shouldShowCollapsedHandle] to `false`. + void hideCollapsedHandle() { + cancelCollapsedHandleAutoHideCountdown(); + + _shouldShowCollapsedHandle.value = false; + } + + /// Toggles [shouldShowCollapsedHandle], and if necessary, hides the expanded handles. + void toggleCollapsedHandle() { + if (shouldShowCollapsedHandle.value) { + hideCollapsedHandle(); + } else { + showCollapsedHandle(); + } + } + + /// (Optional) Builder to create the visual representation of all drag handles: collapsed, + /// upstream, downstream. + /// + /// If [collapsedHandleBuilder] is `null`, a default Android handle is displayed. + final DocumentCollapsedHandleBuilder? collapsedHandleBuilder; + + /// The focal point for the upstream drag handle, when the selection is expanded. + /// + /// The upstream handle builder should place its handle near this focal point. + final LeaderLink upstreamHandleFocalPoint; + + /// The focal point for the downstream drag handle, when the selection is expanded. + /// + /// The downstream handle builder should place its handle near this focal point. + final LeaderLink downstreamHandleFocalPoint; + + /// Whether the expanded drag handles should be displayed right now. + /// + /// This value is enforced to be opposite of [shouldShowCollapsedHandle]. + ValueListenable get shouldShowExpandedHandles => _shouldShowExpandedHandles; + final _shouldShowExpandedHandles = ValueNotifier(false); + + /// Shows the expanded drag handles by setting [shouldShowExpandedHandles] to `true`, and also + /// hides the collapsed handle by setting [shouldShowCollapsedHandle] to `false`. + void showExpandedHandles() { + _shouldShowExpandedHandles.value = true; + _shouldShowCollapsedHandle.value = false; + } + + /// Hides the expanded drag handles by setting [shouldShowExpandedHandles] to `false`. + void hideExpandedHandles() => _shouldShowExpandedHandles.value = false; + + /// Toggles [shouldShowExpandedHandles], and if necessary, hides the collapsed handle. + void toggleExpandedHandles() { + if (shouldShowExpandedHandles.value) { + hideCollapsedHandle(); + } else { + showCollapsedHandle(); + } + } + + /// {@template are_selection_handles_allowed} + /// Whether or not the selection handles are allowed to be displayed. + /// + /// Typically, whenever the selection changes the drag handles are displayed. However, + /// there are some cases where we want to select some content, but don't show the + /// drag handles. For example, when the user taps a misspelled word, we might want to select + /// the misspelled word without showing any handles. + /// + /// Defaults to `true`. + /// {@endtemplate} + ValueListenable get areSelectionHandlesAllowed => _areSelectionHandlesAllowed; + final _areSelectionHandlesAllowed = ValueNotifier(true); + + /// Temporarily prevents any selection handles from being displayed. + /// + /// Call this when you want to select some content, but don't want to show the drag handles. + /// [allowSelectionHandles] must be called to allow the drag handles to be displayed again. + void preventSelectionHandles() => _areSelectionHandlesAllowed.value = false; + + /// Allows the selection handles to be displayed after they have been temporarily + /// prevented by [preventSelectionHandles]. + void allowSelectionHandles() => _areSelectionHandlesAllowed.value = true; + + /// (Optional) Builder to create the visual representation of the expanded drag handles. + /// + /// If [expandedHandlesBuilder] is `null`, default Android handles are displayed. + final DocumentExpandedHandlesBuilder? expandedHandlesBuilder; + + /// Whether the Android magnifier should be displayed right now. + ValueListenable get shouldShowMagnifier => _shouldShowMagnifier; + final _shouldShowMagnifier = ValueNotifier(false); + + /// Shows the magnifier by setting [shouldShowMagnifier] to `true`. + void showMagnifier() => _shouldShowMagnifier.value = true; + + /// Hides the magnifier by setting [shouldShowMagnifier] to `false`. + void hideMagnifier() => _shouldShowMagnifier.value = false; + + /// Toggles [shouldShowMagnifier]. + void toggleMagnifier() => _shouldShowMagnifier.value = !_shouldShowMagnifier.value; + + /// Link to a location where a magnifier should be focused. + /// + /// The magnifier builder should place the magnifier near this focal point. + final magnifierFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the magnifier. + /// + /// If [magnifierBuilder] is `null`, a default Android magnifier is displayed. + final DocumentMagnifierBuilder? magnifierBuilder; + + /// Whether the Android floating toolbar should be displayed right now. + ValueListenable get shouldShowToolbar => _shouldShowToolbar; + final _shouldShowToolbar = ValueNotifier(false); + + /// Shows the toolbar by setting [shouldShowToolbar] to `true`. + void showToolbar() => _shouldShowToolbar.value = true; + + /// Hides the toolbar by setting [shouldShowToolbar] to `false`. + void hideToolbar() => _shouldShowToolbar.value = false; + + /// Toggles [shouldShowToolbar]. + void toggleToolbar() => _shouldShowToolbar.value = !_shouldShowToolbar.value; + + /// Link to a location where a toolbar should be focused. + /// + /// This link probably points to a rectangle, such as a bounding rectangle + /// around the user's selection. Therefore, the toolbar builder shouldn't + /// assume that this focal point is a single pixel. + final toolbarFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the floating + /// toolbar. + /// + /// If [toolbarBuilder] is `null`, a default Android toolbar is displayed. + final DocumentFloatingToolbarBuilder? toolbarBuilder; + + /// Creates a clipper that restricts where the toolbar and magnifier can + /// appear in the overlay. + /// + /// If no clipper factory method is provided, then the overlay controls + /// will be allowed to appear anywhere in the overlay in which they sit + /// (probably the entire screen). + final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; +} + +/// A [SuperEditorDocumentLayerBuilder] that builds an [AndroidToolbarFocalPointDocumentLayer], which +/// positions a [Leader] widget around the document selection, as a focal point for an Android +/// floating toolbar. +class SuperEditorAndroidToolbarFocalPointDocumentLayerBuilder implements SuperEditorLayerBuilder { + const SuperEditorAndroidToolbarFocalPointDocumentLayerBuilder({ + // ignore: unused_element + this.showDebugLeaderBounds = false, + }); + + /// Whether to paint colorful bounds around the leader widget. + final bool showDebugLeaderBounds; + + @override + ContentLayerWidget build(BuildContext context, SuperEditorContext editorContext) { + if (defaultTargetPlatform != TargetPlatform.android || + SuperEditorAndroidControlsScope.maybeNearestOf(context) == null) { + // There's no controls scope. This probably means SuperEditor is configured with + // a non-Android gesture mode. Build nothing. + return const ContentLayerProxyWidget(child: EmptyBox()); + } + + return AndroidToolbarFocalPointDocumentLayer( + document: editorContext.document, + selection: editorContext.composer.selectionNotifier, + toolbarFocalPointLink: SuperEditorAndroidControlsScope.rootOf(context).toolbarFocalPoint, + showDebugLeaderBounds: showDebugLeaderBounds, + ); + } +} + +/// A [SuperEditorLayerBuilder], which builds an [AndroidHandlesDocumentLayer], +/// which displays Android-style caret and handles. +class SuperEditorAndroidHandlesDocumentLayerBuilder implements SuperEditorLayerBuilder { + const SuperEditorAndroidHandlesDocumentLayerBuilder({ + this.caretColor, + this.caretWidth = 2, + }); + + /// The (optional) color of the caret (not the drag handle), by default the color + /// defers to the root [SuperEditorAndroidControlsScope], or the app theme if the + /// controls controller has no preference for the color. + final Color? caretColor; + + final double caretWidth; + + @override + ContentLayerWidget build(BuildContext context, SuperEditorContext editContext) { + if (defaultTargetPlatform != TargetPlatform.android || + SuperEditorAndroidControlsScope.maybeNearestOf(context) == null) { + // There's no controls scope. This probably means SuperEditor is configured with + // a non-Android gesture mode. Build nothing. + return const ContentLayerProxyWidget(child: EmptyBox()); + } + + return AndroidHandlesDocumentLayer( + document: editContext.document, + documentLayout: editContext.documentLayout, + selection: editContext.composer.selectionNotifier, + changeSelection: (newSelection, changeType, reason) { + editContext.editor.execute([ + ChangeSelectionRequest(newSelection, changeType, reason), + const ClearComposingRegionRequest(), + ]); + }, + caretWidth: caretWidth, + caretColor: caretColor, + ); + } +} + /// Document gesture interactor that's designed for Android touch input, e.g., /// drag to scroll, and handles to control selection. class AndroidDocumentTouchInteractor extends StatefulWidget { const AndroidDocumentTouchInteractor({ Key? key, required this.focusNode, + required this.editor, required this.document, - required this.documentKey, required this.getDocumentLayout, required this.selection, - this.scrollController, + required this.isImeConnected, + this.isScribbleInProgress, + this.openKeyboardWhenTappingExistingSelection = true, + this.openKeyboardOnSelectionChange = true, + required this.openSoftwareKeyboard, + required this.scrollController, + required this.fillViewport, + this.contentTapHandlers, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), - required this.handleColor, - required this.popoverToolbarBuilder, - this.createOverlayControlsClipper, + required this.dragHandleAutoScroller, this.showDebugPaint = false, required this.child, }) : super(key: key); final FocusNode focusNode; + final Editor editor; final Document document; - final GlobalKey documentKey; final DocumentLayout Function() getDocumentLayout; - final ValueNotifier selection; + final ValueListenable selection; + + /// A listenable that reports whether the IME is currently connected to this + /// editor, which means either a software keyboard or hardware keyboard is + /// currently configured to edit the document in this editor. + /// + /// This signal is used, for example, to decide whether we should show + /// the popover toolbar on tap. + final ValueListenable isImeConnected; + + /// A listenable that reports whether a Scribble or stylus writing interaction + /// (e.g., Samsung S-Pen) is currently in progress. + /// + /// When scribble is in progress, gesture handling avoids interfering with + /// the IME's scribble input. + final ValueListenable? isScribbleInProgress; + + /// {@macro openKeyboardWhenTappingExistingSelection} + final bool openKeyboardWhenTappingExistingSelection; + + /// {@macro openKeyboardOnSelectionChange} + final bool openKeyboardOnSelectionChange; + + /// A callback that should open the software keyboard when invoked. + final VoidCallback openSoftwareKeyboard; + + /// Optional list of handlers that respond to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List? contentTapHandlers; - final ScrollController? scrollController; + final ScrollController scrollController; /// The closest that the user's selection drag gesture can get to the /// document boundary before auto-scrolling. @@ -58,19 +488,11 @@ class AndroidDocumentTouchInteractor extends StatefulWidget { /// edges. final AxisOffset dragAutoScrollBoundary; - /// The color of the Android-style drag handles. - final Color handleColor; + final ValueNotifier dragHandleAutoScroller; - final WidgetBuilder popoverToolbarBuilder; - - /// Creates a clipper that applies to overlay controls, preventing - /// the overlay controls from appearing outside the given clipping - /// region. - /// - /// If no clipper factory method is provided, then the overlay controls - /// will be allowed to appear anywhere in the overlay in which they sit - /// (probably the entire screen). - final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; + /// Whether the document gesture detector should fill the entire viewport + /// even if the actual content is smaller. + final bool fillViewport; final bool showDebugPaint; @@ -81,88 +503,48 @@ class AndroidDocumentTouchInteractor extends StatefulWidget { } class _AndroidDocumentTouchInteractorState extends State - with WidgetsBindingObserver, SingleTickerProviderStateMixin, DocumentSelectionOnFocusMixin { - // ScrollController used when this interactor installs its own Scrollable. - // The alternative case is the one in which this interactor defers to an - // ancestor scrollable. - late ScrollController _scrollController; + with WidgetsBindingObserver, SingleTickerProviderStateMixin { + SuperEditorAndroidControlsController? _controlsController; + // The ScrollPosition attached to the _ancestorScrollable, if there's an ancestor // Scrollable. ScrollPosition? _ancestorScrollPosition; - // The actual ScrollPosition that's used for the document layout, either - // the Scrollable installed by this interactor, or an ancestor Scrollable. - ScrollPosition? _activeScrollPosition; - - // OverlayEntry that displays editing controls, e.g., - // drag handles, magnifier, and toolbar. - OverlayEntry? _controlsOverlayEntry; - late AndroidDocumentGestureEditingController _editingController; - final _documentLayoutLink = LayerLink(); - final _magnifierFocalPointLink = LayerLink(); - - late DragHandleAutoScroller _handleAutoScrolling; + + Offset? _globalTapDownOffset; Offset? _globalStartDragOffset; Offset? _dragStartInDoc; Offset? _startDragPositionOffset; double? _dragStartScrollOffset; Offset? _globalDragOffset; - Offset? _dragEndInInteractor; - SelectionHandleType? _selectionType; + + final _magnifierGlobalOffset = ValueNotifier(null); + + Timer? _tapDownLongPressTimer; + bool get _isLongPressInProgress => _longPressStrategy != null; + AndroidDocumentLongPressSelectionStrategy? _longPressStrategy; + + bool _isCaretDragInProgress = false; + + // Cached view metrics to ignore unnecessary didChangeMetrics calls. + Size? _lastSize; + ViewPadding? _lastInsets; + + final _interactor = GlobalKey(); @override void initState() { super.initState(); - _handleAutoScrolling = DragHandleAutoScroller( + widget.dragHandleAutoScroller.value = DragHandleAutoScroller( vsync: this, dragAutoScrollBoundary: widget.dragAutoScrollBoundary, getScrollPosition: () => scrollPosition, getViewportBox: () => viewportBox, ); - widget.focusNode.addListener(_onFocusChange); - - _scrollController = _scrollController = (widget.scrollController ?? ScrollController()); - // On the next frame, after our ScrollController is attached to the Scrollable, - // add a listener for scroll changes. - // - // During Hot Reload, the gesture mode could be changed. - // If that's the case, initState is called while the Overlay is being - // built. This could crash the app. Because of that, we show the editing - // controls overlay in the next frame. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (widget.focusNode.hasFocus) { - _showEditingControlsOverlay(); - } - _updateScrollPositionListener(); - }); - // I added this listener directly to our ScrollController because the listener we added - // to the ScrollPosition wasn't triggering once the user makes an initial selection. I'm - // not sure why that happened. It's as if the ScrollPosition was replaced, but I don't - // know why the ScrollPosition would be replaced. In the meantime, adding this listener - // keeps the toolbar positioning logic working. - // TODO: rely solely on a ScrollPosition listener, not a ScrollController listener. - _scrollController.addListener(_onScrollChange); - - _editingController = AndroidDocumentGestureEditingController( - documentLayoutLink: _documentLayoutLink, - magnifierFocalPointLink: _magnifierFocalPointLink, - ); - widget.document.addListener(_onDocumentChange); widget.selection.addListener(_onSelectionChange); - startSyncingSelectionWithFocus( - focusNode: widget.focusNode, - getDocumentLayout: widget.getDocumentLayout, - selection: widget.selection, - ); - - // If we already have a selection, we need to display the caret. - if (widget.selection.value != null) { - _onSelectionChange(); - } - WidgetsBinding.instance.addObserver(this); } @@ -170,29 +552,19 @@ class _AndroidDocumentTouchInteractorState extends State _ancestorScrollPosition ?? _scrollController.position; + ScrollPosition get scrollPosition => _ancestorScrollPosition ?? widget.scrollController.position; /// Returns the `RenderBox` for the scrolling viewport. /// @@ -403,15 +641,20 @@ class _AndroidDocumentTouchInteractorState extends State - (_findAncestorScrollable(context)?.context.findRenderObject() ?? context.findRenderObject()) as RenderBox; + RenderBox get viewportBox => context.findViewportBox(); - /// Converts the given [offset] from the [DocumentInteractor]'s coordinate - /// space to the [DocumentLayout]'s coordinate space. - Offset _getDocOffset(Offset offset) { - return _docLayout.getDocumentOffsetFromAncestorOffset(offset, context.findRenderObject()!); + Offset _getDocumentOffsetFromGlobalOffset(Offset globalOffset) { + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); } + Offset _documentOffsetToViewportOffset(Offset documentOffset) { + final globalOffset = _docLayout.getGlobalOffsetFromDocumentOffset(documentOffset); + return viewportBox.globalToLocal(globalOffset); + } + + /// Returns the render box for the interactor gesture detector. + RenderBox get interactorBox => _interactor.currentContext!.findRenderObject() as RenderBox; + /// Maps the given [interactorOffset] within the interactor's coordinate space /// to the same screen position in the viewport's coordinate space. /// @@ -423,31 +666,149 @@ class _AndroidDocumentTouchInteractorState extends State{ - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapUp = _onTapUp - ..onDoubleTapDown = _onDoubleTapDown - ..onTripleTapDown = _onTripleTapDown; + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + // PanGestureRecognizer is above contents to have first pass at gestures, but it only accepts + // gestures that are over caret or handles or when a long press is in progress. + // TapGestureRecognizer is below contents so that it doesn't interferes with buttons and other + // tappable widgets. + return SliverHybridStack( + fillViewport: widget.fillViewport, + children: [ + // Layer below + RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapCancel = _onTapCancel + ..onTapUp = _onTapUp + ..onDoubleTapDown = _onDoubleTapDown + ..onTripleTapDown = _onTripleTapDown + ..gestureSettings = gestureSettings; + }, + ), }, ), - }, - child: child, + widget.child, + // Layer above + RawGestureDetector( + key: _interactor, + behavior: HitTestBehavior.translucent, + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..shouldAccept = () { + if (widget.isScribbleInProgress?.value == true) { + // A Scribble/stylus writing interaction is in progress. + // Don't accept the pan so the IME can handle scribble input. + return false; + } + if (_globalTapDownOffset == null) { + return false; + } + return _isOverCaret(_globalTapDownOffset!) || _isLongPressInProgress; + } + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + ), + ], ); } } -class AndroidDocumentTouchEditingControls extends StatefulWidget { - const AndroidDocumentTouchEditingControls({ - Key? key, - required this.editingController, - required this.documentKey, - required this.documentLayout, - required this.handleColor, - this.onHandleDragStart, - this.onHandleDragUpdate, - this.onHandleDragEnd, - required this.popoverToolbarBuilder, - this.createOverlayControlsClipper, +/// Adds and removes an Android-style editor controls overlay, as dictated by an ancestor +/// [SuperEditorAndroidControlsScope]. +class SuperEditorAndroidControlsOverlayManager extends StatefulWidget { + const SuperEditorAndroidControlsOverlayManager({ + super.key, + this.tapRegionGroupId, + required this.document, + required this.getDocumentLayout, + required this.selection, + required this.setSelection, + required this.isImeConnected, + required this.scrollChangeSignal, + required this.dragHandleAutoScroller, + this.defaultToolbarBuilder, this.showDebugPaint = false, - }) : super(key: key); - - final AndroidDocumentGestureEditingController editingController; + this.child, + }); - final GlobalKey documentKey; + /// {@macro super_editor_tap_region_group_id} + final String? tapRegionGroupId; - final DocumentLayout documentLayout; + final Document document; + final DocumentLayoutResolver getDocumentLayout; + final ValueListenable selection; + final void Function(DocumentSelection?) setSelection; - /// Creates a clipper that applies to overlay controls, preventing - /// the overlay controls from appearing outside the given clipping - /// region. + /// A listenable that reports whether the IME is currently connected to this + /// editor, which means either a software keyboard or hardware keyboard is + /// currently configured to edit the document in this editor. /// - /// If no clipper factory method is provided, then the overlay controls - /// will be allowed to appear anywhere in the overlay in which they sit - /// (probably the entire screen). - final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; - - /// The color of the Android-style drag handles. - final Color handleColor; + /// This signal is used to, for example, to decide whether we should show + /// the popover toolbar on tap. + final ValueListenable isImeConnected; - final void Function(HandleType handleType, Offset globalOffset)? onHandleDragStart; + final SignalNotifier scrollChangeSignal; - final void Function(Offset globalOffset)? onHandleDragUpdate; + final ValueListenable dragHandleAutoScroller; - final void Function()? onHandleDragEnd; - - /// Builder that constructs the popover toolbar that's displayed above - /// selected text. - /// - /// Typically, this bar includes actions like "copy", "cut", "paste", etc. - final Widget Function(BuildContext) popoverToolbarBuilder; + final DocumentFloatingToolbarBuilder? defaultToolbarBuilder; + /// Paints some extra visual ornamentation to help with + /// debugging, when `true`. final bool showDebugPaint; + final Widget? child; + @override - State createState() => _AndroidDocumentTouchEditingControlsState(); + State createState() => SuperEditorAndroidControlsOverlayManagerState(); } -class _AndroidDocumentTouchEditingControlsState extends State - with SingleTickerProviderStateMixin { - // These global keys are assigned to each draggable handle to - // prevent a strange dragging issue. +@visibleForTesting +class SuperEditorAndroidControlsOverlayManagerState extends State { + final _boundsKey = GlobalKey(); + final _overlayController = OverlayPortalController(); + + SuperEditorAndroidControlsController? _controlsController; + late FollowerAligner _toolbarAligner; + + // The selection bound that the user is dragging, e.g., base or extent. + // + // The drag selection bound varies independently from the drag handle type. + SelectionBound? _dragHandleSelectionBound; + + // The type of handle that the user started dragging, e.g., upstream or downstream. // - // Without these keys, if the user drags into the auto-scroll area - // of the text field for a period of time, we never receive a - // "pan end" or "pan cancel" callback. I have no idea why this is - // the case. These handles sit in an Overlay, so it's not as if they - // suffered some conflict within a ScrollView. I tried many adjustments - // to recover the end/cancel callbacks. Finally, I tried adding these - // global keys based on a hunch that perhaps the gesture detector was - // somehow getting switched out, or assigned to a different widget, and - // that was somehow disrupting the callback series. For now, these keys - // seem to solve the problem. - final _collapsedHandleKey = GlobalKey(); - final _upstreamHandleKey = GlobalKey(); - final _downstreamHandleKey = GlobalKey(); - - bool _isDraggingExpandedHandle = false; - bool _isDraggingHandle = false; - Offset? _localDragOffset; - - late BlinkController _caretBlinkController; - Offset? _prevCaretOffset; + // The drag handle type varies independently from the drag selection bound. + HandleType? _dragHandleType; + AndroidTextFieldDragHandleSelectionStrategy? _dragHandleSelectionStrategy; + + final _dragHandleSelectionGlobalFocalPoint = ValueNotifier(null); + final _magnifierFocalPoint = ValueNotifier(null); + + late final DocumentHandleGestureDelegate _collapsedHandleGestureDelegate; + late final DocumentHandleGestureDelegate _upstreamHandleGesturesDelegate; + late final DocumentHandleGestureDelegate _downstreamHandleGesturesDelegate; @override void initState() { super.initState(); - _caretBlinkController = BlinkController(tickerProvider: this); - _prevCaretOffset = widget.editingController.caretTop; - widget.editingController.addListener(_onEditingControllerChange); - if (widget.editingController.shouldDisplayCollapsedHandle) { - widget.editingController.startCollapsedHandleAutoHideCountdown(); - } + widget.selection.addListener(_onSelectionChange); + + _collapsedHandleGestureDelegate = DocumentHandleGestureDelegate( + onTap: _toggleToolbarOnCollapsedHandleTap, + onPanStart: (details) => _onHandlePanStart(details, HandleType.collapsed), + onPanUpdate: _onHandlePanUpdate, + onPanEnd: (details) => _onHandlePanEnd(details, HandleType.collapsed), + ); + + _upstreamHandleGesturesDelegate = DocumentHandleGestureDelegate( + onTap: () { + // Register tap down to win gesture arena ASAP. + }, + onPanStart: (details) => _onHandlePanStart(details, HandleType.upstream), + onPanUpdate: _onHandlePanUpdate, + onPanEnd: (details) => _onHandlePanEnd(details, HandleType.upstream), + onPanCancel: () => _onHandlePanCancel(HandleType.upstream), + ); + + _downstreamHandleGesturesDelegate = DocumentHandleGestureDelegate( + onTap: () { + // Register tap down to win gesture arena ASAP. + }, + onPanStart: (details) => _onHandlePanStart(details, HandleType.downstream), + onPanUpdate: _onHandlePanUpdate, + onPanEnd: (details) => _onHandlePanEnd(details, HandleType.downstream), + onPanCancel: () => _onHandlePanCancel(HandleType.downstream), + ); + + onNextFrame((_) { + // Call `show()` at the end of the frame because calling during a build + // process blows up. + _overlayController.show(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsController = SuperEditorAndroidControlsScope.rootOf(context); + // TODO: Replace CupertinoPopoverToolbarAligner aligner with a generic aligner because this code runs on Android. + _toolbarAligner = CupertinoPopoverToolbarAligner( + toolbarVerticalOffsetAbove: 20, + toolbarVerticalOffsetBelow: 90, + ); } @override - void didUpdateWidget(AndroidDocumentTouchEditingControls oldWidget) { + void didUpdateWidget(SuperEditorAndroidControlsOverlayManager oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.editingController != oldWidget.editingController) { - oldWidget.editingController.removeListener(_onEditingControllerChange); - widget.editingController.addListener(_onEditingControllerChange); + if (widget.scrollChangeSignal != oldWidget.scrollChangeSignal) { + oldWidget.scrollChangeSignal.removeListener(_onDocumentScroll); + if (_dragHandleType != null) { + // The user is currently dragging a handle. Listen for scroll changes. + widget.scrollChangeSignal.addListener(_onDocumentScroll); + } + } + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); } } @override void dispose() { - widget.editingController.removeListener(_onEditingControllerChange); - _caretBlinkController.dispose(); + // In case we're disposed in the middle of auto-scrolling, stop auto-scrolling and + // stop listening for document scroll changes. + widget.dragHandleAutoScroller.value?.stopAutoScrollHandleMonitoring(); + widget.scrollChangeSignal.removeListener(_onDocumentScroll); + widget.selection.removeListener(_onSelectionChange); + super.dispose(); } - void _onEditingControllerChange() { - if (_prevCaretOffset != widget.editingController.caretTop) { - if (widget.editingController.caretTop == null) { - _caretBlinkController.stopBlinking(); - } else { - _caretBlinkController.jumpToOpaque(); - } + @visibleForTesting + bool get wantsToDisplayToolbar => _controlsController!.shouldShowToolbar.value; + + @visibleForTesting + bool get wantsToDisplayMagnifier => _controlsController!.shouldShowMagnifier.value; + + /// Returns the `RenderBox` for the scrolling viewport. + /// + /// If this widget has an ancestor `Scrollable`, then the returned + /// `RenderBox` belongs to that ancestor `Scrollable`. + /// + /// If this widget doesn't have an ancestor `Scrollable`, then this + /// widget includes a `ScrollView` and this `State`'s render object + /// is the viewport `RenderBox`. + RenderBox get viewportBox => + (context.findAncestorScrollableWithVerticalScroll?.context.findRenderObject() ?? context.findRenderObject()) + as RenderBox; + + void _onSelectionChange() { + final selection = widget.selection.value; + if (selection == null) { + return; + } + + if (selection.isCollapsed && + _controlsController!.shouldShowExpandedHandles.value == true && + _dragHandleType == null) { + // The selection is collapsed, but the expanded handles are visible and the user isn't dragging a handle. + // This can happen when the selection is expanded, and the user deletes the selected text. The only situation + // where the expanded handles should be visible when the selection is collapsed is when the selection + // collapses while the user is dragging an expanded handle, which isn't the case here. Hide the handles. + _controlsController! + ..hideCollapsedHandle() + ..hideExpandedHandles() + ..hideMagnifier() + ..hideToolbar() + ..blinkCaret(); + } - _prevCaretOffset = widget.editingController.caretTop; + if (!selection.isCollapsed && _controlsController!.shouldShowCollapsedHandle.value == true) { + // The selection is expanded, but the collapsed handle is visible. This can happen when the + // selection is collapsed and the user taps the "Select All" button. There isn't any situation + // where the collapsed handle should be visible when the selection is expanded. Hide the collapsed + // handle and show the expanded handles. + _controlsController! + ..hideCollapsedHandle() + ..showExpandedHandles() + ..hideMagnifier(); } } - void _onCollapsedPanStart(DragStartDetails details) { - editorGesturesLog.fine('_onCollapsedPanStart'); + void _toggleToolbarOnCollapsedHandleTap() { + if (!widget.isImeConnected.value) { + // We have a selection with a handle, but no IME connection. This probably + // shouldn't happen, but in general, we don't want to support content changes + // without a connection to the OS IME. + return; + } - setState(() { - _isDraggingExpandedHandle = false; - _isDraggingHandle = true; - // We map global to local instead of using details.localPosition because - // this drag event started in a handle, not within this overall widget. - _localDragOffset = (context.findRenderObject() as RenderBox).globalToLocal(details.globalPosition); - }); + _controlsController!.toggleToolbar(); + } + + void _updateDragHandleSelection(DocumentSelection newSelection) { + if (newSelection != widget.selection.value) { + widget.setSelection(newSelection); + HapticFeedback.lightImpact(); + } + } + + void _onHandlePanStart(DragStartDetails details, HandleType handleType) { + final selection = widget.selection.value; + if (selection == null) { + throw Exception("Tried to drag a collapsed Android handle when there's no selection."); + } + + final isSelectionDownstream = widget.selection.value!.hasDownstreamAffinity(widget.document); + _dragHandleType = handleType; + late final DocumentPosition selectionBoundPosition; + if (isSelectionDownstream) { + _dragHandleSelectionBound = handleType == HandleType.upstream ? SelectionBound.base : SelectionBound.extent; + selectionBoundPosition = handleType == HandleType.upstream ? selection.base : selection.extent; + } else { + _dragHandleSelectionBound = handleType == HandleType.upstream ? SelectionBound.extent : SelectionBound.base; + selectionBoundPosition = handleType == HandleType.upstream ? selection.extent : selection.base; + } - widget.onHandleDragStart?.call(HandleType.collapsed, details.globalPosition); + // Find the global offset for the center of the caret as the selection focal point. + final documentLayout = widget.getDocumentLayout(); + // FIXME: this logic makes sense for selecting characters, but what about images? Does it make sense to set the focal point at the center of the image? + final centerOfContentAtOffset = documentLayout.getAncestorOffsetFromDocumentOffset( + documentLayout.getRectForPosition(selectionBoundPosition)!.center, + ); + _dragHandleSelectionGlobalFocalPoint.value = centerOfContentAtOffset; + _magnifierFocalPoint.value = centerOfContentAtOffset; + + _dragHandleSelectionStrategy = AndroidTextFieldDragHandleSelectionStrategy( + document: widget.document, + documentLayout: widget.getDocumentLayout(), + select: _updateDragHandleSelection, + )..onHandlePanStart(details, selection, handleType); + + // Update the controls for handle dragging. + _controlsController! + ..cancelCollapsedHandleAutoHideCountdown() + ..doNotBlinkCaret() + ..showMagnifier() + ..hideToolbar(); + + // Start auto-scrolling based on the drag-handle offset. + widget.dragHandleAutoScroller.value?.startAutoScrollHandleMonitoring(); + + // Listen for scroll changes so that we can update the selection when the user's + // finger is standing still, but the document is moving beneath it during auto scrolling. + widget.scrollChangeSignal.addListener(_onDocumentScroll); } - void _onUpstreamHandlePanStart(DragStartDetails details) { - _onExpandedHandleDragStart(details); - widget.onHandleDragStart?.call(HandleType.upstream, details.globalPosition); + void _onHandlePanUpdate(DragUpdateDetails details) { + if (_dragHandleSelectionGlobalFocalPoint.value == null) { + throw Exception( + "Tried to pan an Android drag handle but the focal point is null. The focal point is set when the drag begins. This shouldn't be possible."); + } + + // Move the selection focal point by the given delta. + _dragHandleSelectionGlobalFocalPoint.value = _dragHandleSelectionGlobalFocalPoint.value! + details.delta; + + _dragHandleSelectionStrategy!.onHandlePanUpdate(details); + + // Update the magnifier based on the latest drag handle offset. + _moveMagnifierToDragHandleOffset(dragDx: details.delta.dx); } - void _onDownstreamHandlePanStart(DragStartDetails details) { - _onExpandedHandleDragStart(details); - widget.onHandleDragStart?.call(HandleType.downstream, details.globalPosition); + void _onHandlePanEnd(DragEndDetails details, HandleType handleType) { + _dragHandleSelectionStrategy = null; + _onHandleDragEnd(handleType); } - void _onExpandedHandleDragStart(DragStartDetails details) { - setState(() { - _isDraggingExpandedHandle = true; - _isDraggingHandle = true; - // We map global to local instead of using details.localPosition because - // this drag event started in a handle, not within this overall widget. - _localDragOffset = (context.findRenderObject() as RenderBox).globalToLocal(details.globalPosition); - }); + void _onHandlePanCancel(HandleType handleType) { + _dragHandleSelectionStrategy = null; + _onHandleDragEnd(handleType); } - void _onPanUpdate(DragUpdateDetails details) { - editorGesturesLog.fine('_onPanUpdate'); + void _onHandleDragEnd(HandleType handleType) { + _dragHandleSelectionStrategy = null; + _dragHandleType = null; + _dragHandleSelectionGlobalFocalPoint.value = null; + _magnifierFocalPoint.value = null; + + // Start blinking the caret again, and hide the magnifier. + _controlsController! + ..blinkCaret() + ..hideMagnifier(); + + if (widget.selection.value?.isCollapsed == true && + const [HandleType.upstream, HandleType.downstream].contains(handleType)) { + // The user dragged an expanded handle until the selection collapsed and then released the handle. + // While the user was dragging, the expanded handles were displayed. + // Show the collapsed. + _controlsController! + ..hideExpandedHandles() + ..showCollapsedHandle(); + } - widget.onHandleDragUpdate?.call(details.globalPosition); + // Stop auto-scrolling based on the drag-handle offset. + widget.dragHandleAutoScroller.value?.stopAutoScrollHandleMonitoring(); + widget.scrollChangeSignal.removeListener(_onDocumentScroll); - setState(() { - _localDragOffset = _localDragOffset! + details.delta; - }); + if (widget.selection.value?.isCollapsed == false) { + // The selection is expanded, show the toolbar. + _controlsController!.showToolbar(); + } else { + // The selection is collapsed, start the auto-hide countdown for the handle. + _controlsController!.startCollapsedHandleAutoHideCountdown(); + } } - void _onPanEnd(DragEndDetails details) { - editorGesturesLog.fine('_onPanEnd'); - _onHandleDragEnd(); + void _onDocumentScroll() { + if (_dragHandleType == null) { + // The user isn't dragging anything. We don't care that the document moved. Return. + return; + } + + // Update the selection based on the handle's offset in the document, now that the + // document has scrolled. + _moveSelectionAndMagnifierToDragHandleOffset(); } - void _onPanCancel() { - editorGesturesLog.fine('_onPanCancel'); - _onHandleDragEnd(); + void _moveSelectionAndMagnifierToDragHandleOffset({ + double dragDx = 0, + }) { + _moveSelectionToDragHandleOffset(); + _moveMagnifierToDragHandleOffset(dragDx: dragDx); } - void _onHandleDragEnd() { - editorGesturesLog.fine('_onHandleDragEnd()'); + void _moveMagnifierToDragHandleOffset({ + double dragDx = 0, + }) { + // Move the selection to the document position that's nearest the focal point. + final documentLayout = widget.getDocumentLayout(); + final nearestPosition = documentLayout.getDocumentPositionNearestToOffset( + documentLayout.getDocumentOffsetFromAncestorOffset(_dragHandleSelectionGlobalFocalPoint.value!), + )!; + + final centerOfContentInContentSpace = documentLayout.getRectForPosition(nearestPosition)!.center; + + // Move the magnifier focal point to match the drag x-offset, but always remain focused on the vertical + // center of the line. + final centerOfContentAtNearestPosition = + documentLayout.getAncestorOffsetFromDocumentOffset(centerOfContentInContentSpace); + _magnifierFocalPoint.value = Offset( + _magnifierFocalPoint.value!.dx + dragDx, + centerOfContentAtNearestPosition.dy, + ); - // TODO: ensure that extent is visible + // Update the auto-scroll focal point so that the viewport scrolls if we're + // close to the boundary. + widget.dragHandleAutoScroller.value?.updateAutoScrollHandleMonitoring( + dragEndInViewport: _contentOffsetInViewport(centerOfContentInContentSpace), + ); + } - setState(() { - _isDraggingExpandedHandle = false; - _isDraggingHandle = false; - _localDragOffset = null; - }); + void _moveSelectionToDragHandleOffset() { + // Move the selection to the document position that's nearest the focal point. + final documentLayout = widget.getDocumentLayout(); + final nearestPosition = documentLayout.getDocumentPositionNearestToOffset( + documentLayout.getDocumentOffsetFromAncestorOffset(_dragHandleSelectionGlobalFocalPoint.value!), + )!; - widget.onHandleDragEnd?.call(); + switch (_dragHandleType!) { + case HandleType.collapsed: + widget.setSelection(DocumentSelection.collapsed( + position: nearestPosition, + )); + case HandleType.upstream: + case HandleType.downstream: + switch (_dragHandleSelectionBound!) { + case SelectionBound.base: + widget.setSelection(DocumentSelection( + base: nearestPosition, + extent: widget.selection.value!.extent, + )); + case SelectionBound.extent: + widget.setSelection(DocumentSelection( + base: widget.selection.value!.base, + extent: nearestPosition, + )); + } + } + } + + /// Converts the [offset] in content space to an offset in the viewport space. + Offset _contentOffsetInViewport(Offset offset) { + final documentLayout = widget.getDocumentLayout(); + final globalOffset = documentLayout.getGlobalOffsetFromDocumentOffset(offset); + return viewportBox.globalToLocal(globalOffset); } @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: widget.editingController, - builder: (context) { - return Padding( - // Remove the keyboard from the space that we occupy so that - // clipping calculations apply to the expected visual borders, - // instead of applying underneath the keyboard. - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: ClipRect( - clipper: widget.createOverlayControlsClipper?.call(context), - child: SizedBox( - // ^ SizedBox tries to be as large as possible, because - // a Stack will collapse into nothing unless something - // expands it. - width: double.infinity, - height: double.infinity, - child: Stack( - children: [ - // Build the caret - _buildCaret(), - // Build the drag handles (if desired) - ..._buildHandles(), - // Build the focal point for the magnifier - if (_isDraggingHandle) _buildMagnifierFocalPoint(), - // Build the magnifier (this needs to be done before building - // the handles so that the magnifier doesn't show the handles - if (widget.editingController.shouldDisplayMagnifier) _buildMagnifier(), - // Build the editing toolbar - if (widget.editingController.shouldDisplayToolbar && widget.editingController.isToolbarPositioned) - _buildToolbar(context), - // Build a UI that's useful for debugging, if desired. - if (widget.showDebugPaint) - IgnorePointer( - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.yellow.withOpacity(0.2), - ), - ), - ], - ), - ), - ), - ); - }, + return SliverHybridStack( + children: [ + widget.child!, + OverlayPortal( + controller: _overlayController, + overlayChildBuilder: _buildOverlay, + child: const SizedBox(), + ), + ], ); } - Widget _buildCaret() { - if (!widget.editingController.hasCaret) { - return const SizedBox(); - } - - return CompositedTransformFollower( - link: widget.editingController.documentLayoutLink, - offset: widget.editingController.caretTop!, - child: IgnorePointer( - child: BlinkingCaret( - controller: _caretBlinkController, - caretOffset: const Offset(-1, 0), - caretHeight: widget.editingController.caretHeight!, - width: 2, - color: widget.showDebugPaint ? Colors.green : widget.handleColor, - borderRadius: BorderRadius.zero, - isTextEmpty: false, - showCaret: true, - ), + Widget _buildOverlay(BuildContext context) { + return TapRegion( + groupId: widget.tapRegionGroupId, + child: Stack( + key: _boundsKey, + children: [ + _buildMagnifierFocalPoint(), + if (widget.showDebugPaint) // + _buildDebugSelectionFocalPoint(), + _buildMagnifier(), + // Handles and toolbar are built after the magnifier so that they don't appear in the magnifier. + _buildCollapsedHandle(), + ..._buildExpandedHandles(), + _buildToolbar(), + ], ), ); } - List _buildHandles() { - if (!widget.editingController.shouldDisplayCollapsedHandle && - !widget.editingController.shouldDisplayExpandedHandles) { - editorGesturesLog.finer('Not building overlay handles because there is no selection'); - // There is no selection. Draw nothing. - return []; - } + Widget _buildCollapsedHandle() { + return ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowCollapsedHandle, + builder: (context, shouldShow, child) { + final selection = widget.selection.value; + if (selection == null || !selection.isCollapsed) { + // When the user double taps we first place a collapsed selection + // and then an expanded selection. + // Return a SizedBox to avoid flashing the collapsed drag handle. + return const SizedBox(); + } - if (widget.editingController.shouldDisplayCollapsedHandle && !_isDraggingExpandedHandle) { - // Note: we don't build the collapsed handle if we're currently dragging - // the base or extent because, if we did, then when the user drags - // crosses the base and extent, we'd suddenly jump from an expanded - // selection to a collapsed selection. - return [ - _buildCollapsedHandle(), - ]; - } else { - return _buildExpandedHandles(); - } - } + if (_controlsController!.collapsedHandleBuilder != null) { + return _controlsController!.collapsedHandleBuilder!( + context, + handleKey: DocumentKeys.androidCaretHandle, + focalPoint: _controlsController!.collapsedHandleFocalPoint, + shouldShow: shouldShow, + gestureDelegate: _collapsedHandleGestureDelegate, + ); + } - Widget _buildCollapsedHandle() { - return _buildHandle( - handleKey: _collapsedHandleKey, - handleOffset: widget.editingController.collapsedHandleOffset! + const Offset(0, 5), - handleFractionalTranslation: const Offset(-0.5, 0.0), - handleType: HandleType.collapsed, - debugColor: Colors.green, - onPanStart: _onCollapsedPanStart, + // Note: If we pass this widget as the `child` property, it causes repeated starts and stops + // of the pan gesture. By building it here, pan events work as expected. + return Follower.withOffset( + link: _controlsController!.collapsedHandleFocalPoint, + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + showWhenUnlinked: false, + // Use the offset to account for the invisible expanded touch region around the handle. + offset: -Offset(0, AndroidSelectionHandle.defaultTouchRegionExpansion.top) * + MediaQuery.devicePixelRatioOf(context), + child: AnimatedOpacity( + // When the controller doesn't want the handle to be visible, hide it. + opacity: shouldShow ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + child: IgnorePointer( + // Don't let the handle respond to touch events when the handle shouldn't + // be visible. This is needed because we don't remove the handle from the + // tree, we just make it invisible. In theory, invisible widgets aren't + // supposed to be hit-testable, but in tests I found that without this + // explicit IgnorePointer, gestures were still being captured by this handle. + ignoring: !shouldShow, + child: GestureDetector( + onTap: _collapsedHandleGestureDelegate.onTap, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..shouldAccept = () { + return true; + } + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _collapsedHandleGestureDelegate.onPanStart + ..onUpdate = _collapsedHandleGestureDelegate.onPanUpdate + ..onEnd = _collapsedHandleGestureDelegate.onPanEnd + ..onCancel = _collapsedHandleGestureDelegate.onPanCancel + ..gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + }, + ), + }, + child: AndroidSelectionHandle( + key: DocumentKeys.androidCaretHandle, + handleType: HandleType.collapsed, + color: _controlsController!.controlsColor ?? Theme.of(context).primaryColor, + ), + ), + ), + ), + ), + ); + }, ); } List _buildExpandedHandles() { + if (_controlsController!.expandedHandlesBuilder != null) { + return [ + ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowExpandedHandles, + builder: (context, shouldShow, child) { + return _controlsController!.expandedHandlesBuilder!( + context, + upstreamHandleKey: DocumentKeys.upstreamHandle, + upstreamFocalPoint: _controlsController!.upstreamHandleFocalPoint, + upstreamGestureDelegate: _upstreamHandleGesturesDelegate, + downstreamHandleKey: DocumentKeys.downstreamHandle, + downstreamFocalPoint: _controlsController!.downstreamHandleFocalPoint, + downstreamGestureDelegate: _downstreamHandleGesturesDelegate, + shouldShow: shouldShow, + ); + }, + ) + ]; + } + return [ - // upstream-bounding (left side of a RTL line of text) handle touch target - _buildHandle( - handleKey: _upstreamHandleKey, - handleOffset: widget.editingController.upstreamHandleOffset! + const Offset(0, 2), - handleFractionalTranslation: const Offset(-1.0, 0.0), - handleType: HandleType.upstream, - debugColor: Colors.green, - onPanStart: _onUpstreamHandlePanStart, + ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowExpandedHandles, + builder: (context, shouldShow, child) { + if (!shouldShow) { + return const SizedBox(); + } + + return Follower.withOffset( + link: _controlsController!.upstreamHandleFocalPoint, + leaderAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topRight, + showWhenUnlinked: false, + // Use the offset to account for the invisible expanded touch region around the handle. + offset: + -AndroidSelectionHandle.defaultTouchRegionExpansion.topRight * MediaQuery.devicePixelRatioOf(context), + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..shouldAccept = () { + return true; + } + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _upstreamHandleGesturesDelegate.onPanStart + ..onUpdate = _upstreamHandleGesturesDelegate.onPanUpdate + ..onEnd = _upstreamHandleGesturesDelegate.onPanEnd + ..onCancel = _upstreamHandleGesturesDelegate.onPanCancel + ..gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + }, + ), + }, + child: AndroidSelectionHandle( + key: DocumentKeys.upstreamHandle, + handleType: HandleType.upstream, + color: _controlsController!.controlsColor ?? Theme.of(context).primaryColor, + ), + ), + ); + }, ), - // downstream-bounding (right side of a RTL line of text) handle touch target - _buildHandle( - handleKey: _downstreamHandleKey, - handleOffset: widget.editingController.downstreamHandleOffset! + const Offset(0, 2), - handleType: HandleType.downstream, - debugColor: Colors.red, - onPanStart: _onDownstreamHandlePanStart, + ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowExpandedHandles, + builder: (context, shouldShow, child) { + if (!shouldShow) { + return const SizedBox(); + } + + return Follower.withOffset( + link: _controlsController!.downstreamHandleFocalPoint, + leaderAnchor: Alignment.bottomRight, + followerAnchor: Alignment.topLeft, + showWhenUnlinked: false, + // Use the offset to account for the invisible expanded touch region around the handle. + offset: + -AndroidSelectionHandle.defaultTouchRegionExpansion.topLeft * MediaQuery.devicePixelRatioOf(context), + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..shouldAccept = () { + return true; + } + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _downstreamHandleGesturesDelegate.onPanStart + ..onUpdate = _downstreamHandleGesturesDelegate.onPanUpdate + ..onEnd = _downstreamHandleGesturesDelegate.onPanEnd + ..onCancel = _downstreamHandleGesturesDelegate.onPanCancel + ..gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + }, + ), + }, + child: AndroidSelectionHandle( + key: DocumentKeys.downstreamHandle, + handleType: HandleType.downstream, + color: _controlsController!.controlsColor ?? Theme.of(context).primaryColor, + ), + ), + ); + }, ), ]; } - Widget _buildHandle({ - required Key handleKey, - required Offset handleOffset, - Offset handleFractionalTranslation = Offset.zero, - required HandleType handleType, - required Color debugColor, - required void Function(DragStartDetails) onPanStart, - }) { - return CompositedTransformFollower( - key: handleKey, - link: widget.editingController.documentLayoutLink, - offset: handleOffset, - child: FractionalTranslation( - translation: handleFractionalTranslation, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onPanStart: onPanStart, - onPanUpdate: _onPanUpdate, - onPanEnd: _onPanEnd, - onPanCancel: _onPanCancel, - child: Container( - color: widget.showDebugPaint ? Colors.green : Colors.transparent, - child: AnimatedOpacity( - opacity: handleType == HandleType.collapsed && widget.editingController.isCollapsedHandleAutoHidden - ? 0.0 - : 1.0, - duration: const Duration(milliseconds: 150), - child: AndroidSelectionHandle( - handleType: handleType, - color: widget.handleColor, - ), - ), - ), - ), + Widget _buildToolbar() { + return ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowToolbar, + builder: (context, shouldShow, child) { + return shouldShow ? child! : const SizedBox(); + }, + child: Follower.withAligner( + link: _controlsController!.toolbarFocalPoint, + aligner: _toolbarAligner, + boundary: const ScreenFollowerBoundary(), + child: _toolbarBuilder(context, DocumentKeys.mobileToolbar, _controlsController!.toolbarFocalPoint), ), ); } + DocumentFloatingToolbarBuilder get _toolbarBuilder { + return _controlsController!.toolbarBuilder ?? // + widget.defaultToolbarBuilder ?? + (_, __, ___) => const SizedBox(); + } + Widget _buildMagnifierFocalPoint() { - // When the user is dragging a handle in this overlay, we - // are responsible for positioning the focal point for the - // magnifier to follow. We do that here. - return Positioned( - left: _localDragOffset!.dx, - // TODO: select focal position based on type of content - top: _localDragOffset!.dy - 20, - child: CompositedTransformTarget( - link: widget.editingController.magnifierFocalPointLink, - child: const SizedBox(width: 1, height: 1), - ), + return ValueListenableBuilder( + valueListenable: _magnifierFocalPoint, + builder: (context, focalPoint, child) { + if (focalPoint == null) { + return const SizedBox(); + } + + return Positioned( + left: focalPoint.dx, + top: focalPoint.dy, + width: 1, + height: 1, + child: Leader( + link: _controlsController!.magnifierFocalPoint, + ), + ); + }, ); } Widget _buildMagnifier() { - // Display a magnifier that tracks a focal point. - // - // When the user is dragging an overlay handle, we place a LayerLink - // target. This magnifier follows that target. - return Center( - child: AndroidFollowingMagnifier( - layerLink: widget.editingController.magnifierFocalPointLink, - offsetFromFocalPoint: const Offset(0, -72), - ), + return ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowMagnifier, + builder: (context, shouldShow, child) { + return _controlsController!.magnifierBuilder != null // + ? _controlsController!.magnifierBuilder!( + context, + DocumentKeys.magnifier, + _controlsController!.magnifierFocalPoint, + shouldShow, + ) + : _buildDefaultMagnifier( + context, + DocumentKeys.magnifier, + _controlsController!.magnifierFocalPoint, + shouldShow, + ); + }, ); } - Widget _buildToolbar(BuildContext context) { - // TODO: figure out why this approach works. Why isn't the text field's - // RenderBox offset stale when the keyboard opens or closes? Shouldn't - // we end up with the previous offset because no rebuild happens? - // - // Disproven theory: CompositedTransformFollower's link causes a rebuild of its - // subtree whenever the linked transform changes. - // - // Theory: - // - Keyboard only effects vertical offsets, so global x offset - // was never at risk - // - The global y offset isn't used in the calculation at all - // - If this same approach were used in a situation where the - // distance between the left edge of the available space and the - // text field changed, I think it would fail. - return CustomSingleChildLayout( - delegate: ToolbarPositionDelegate( - // TODO: handle situation where document isn't full screen - textFieldGlobalOffset: Offset.zero, - desiredTopAnchorInTextField: widget.editingController.toolbarTopAnchor!, //toolbarTopAnchor, - desiredBottomAnchorInTextField: widget.editingController.toolbarBottomAnchor!, //toolbarBottomAnchor, - ), - child: IgnorePointer( - ignoring: !widget.editingController.shouldDisplayToolbar, - child: AnimatedOpacity( - opacity: widget.editingController.shouldDisplayToolbar ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), - child: Builder(builder: widget.popoverToolbarBuilder), - ), + Widget _buildDefaultMagnifier(BuildContext context, Key magnifierKey, LeaderLink focalPoint, bool isVisible) { + if (!isVisible) { + return const SizedBox(); + } + + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + return Follower.withOffset( + link: _controlsController!.magnifierFocalPoint, + offset: Offset(0, -54 * devicePixelRatio), + leaderAnchor: Alignment.center, + followerAnchor: Alignment.center, + boundary: const ScreenFollowerBoundary(), + child: AndroidMagnifyingGlass( + key: magnifierKey, + magnificationScale: 1.5, + offsetFromFocalPoint: const Offset(0, -54), ), ); } -} - -class HandleStartDragEvent { - const HandleStartDragEvent({ - required this.selectionType, - required this.globalHandleDragStartOffset, - required this.globalHandleDocPositionRect, - }); - - /// The type of selection that the user started to drag, e.g., collapsed, base, extent. - final SelectionHandleType selectionType; - /// The global offset where the user started dragging. - /// - /// This offset sits somewhere within the handle that the - /// user is dragging. - final Offset globalHandleDragStartOffset; - - /// The global rectangle that contains the content next to - /// the caret where the handle sits. - /// - /// This rectangle encapsulate a character, or an image, etc. - final Rect globalHandleDocPositionRect; -} - -class HandleUpdateDragEvent { - const HandleUpdateDragEvent({ - required this.selectionType, - required this.globalHandleDragOffset, - }); - - /// The type of selection that the user started to drag, e.g., collapsed, base, extent. - final SelectionHandleType selectionType; + Widget _buildDebugSelectionFocalPoint() { + return ValueListenableBuilder( + valueListenable: _dragHandleSelectionGlobalFocalPoint, + builder: (context, focalPoint, child) { + if (focalPoint == null) { + return const SizedBox(); + } - /// The current global offset of the user's pointer during - /// a handle drag event. - final Offset globalHandleDragOffset; + return Positioned( + left: focalPoint.dx, + top: focalPoint.dy, + child: FractionalTranslation( + translation: const Offset(-0.5, -0.5), + child: Container( + width: 5, + height: 5, + color: Colors.red, + ), + ), + ); + }, + ); + } } enum SelectionHandleType { collapsed, + upstream, + downstream, +} + +enum SelectionBound { base, extent, } diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart b/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart index db7dc04eb5..d2c7393d2a 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_ios.dart @@ -1,51 +1,326 @@ -import 'dart:math'; +import 'dart:async'; +import 'dart:ui'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/document_operations/selection_operations.dart'; -import 'package:super_editor/src/default_editor/document_selection_on_focus_mixin.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart'; +import 'package:super_editor/src/infrastructure/flutter/empty_box.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/floating_cursor.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/long_press_selection.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/selection_heuristics.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/signal_notifier.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; +import 'package:super_keyboard/super_keyboard.dart'; import '../infrastructure/document_gestures.dart'; -import 'document_gestures_touch.dart'; +import '../infrastructure/document_gestures_interaction_overrides.dart'; import 'selection_upstream_downstream.dart'; +/// An [InheritedWidget] that provides shared access to a [SuperEditorIosControlsController], +/// which coordinates the state of iOS controls like the caret, handles, magnifier, etc. +/// +/// This widget and its associated controller exist so that [SuperEditor] has maximum freedom +/// in terms of where to implement iOS gestures vs carets vs the floating cursor vs the +/// magnifier vs the toolbar. Each of these responsibilities have some unique differences, +/// which make them difficult or impossible to implement within a single widget. By sharing +/// a controller, a group of independent widgets can work together to cover those various +/// responsibilities. +/// +/// Centralizing a controller in an [InheritedWidget] also allows [SuperEditor] to share that +/// control with application code outside of [SuperEditor], by placing an [SuperEditorIosControlsScope] +/// above the [SuperEditor] in the widget tree. For this reason, [SuperEditor] should access +/// the [SuperEditorIosControlsScope] through [rootOf]. +class SuperEditorIosControlsScope extends InheritedWidget { + /// Finds the highest [SuperEditorIosControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperEditorIosControlsController]. + static SuperEditorIosControlsController rootOf(BuildContext context) { + final data = maybeRootOf(context); + + if (data == null) { + throw Exception("Tried to depend upon the root SuperEditorIosControlsScope but no such ancestor widget exists."); + } + + return data; + } + + static SuperEditorIosControlsController? maybeRootOf(BuildContext context) { + InheritedElement? root; + + context.visitAncestorElements((element) { + if (element is! InheritedElement || element.widget is! SuperEditorIosControlsScope) { + // Keep visiting. + return true; + } + + root = element; + + // Keep visiting, to ensure we get the root scope. + return true; + }); + + if (root == null) { + return null; + } + + // Create build dependency on the iOS controls context. + context.dependOnInheritedElement(root!); + + // Return the current iOS controls data. + return (root!.widget as SuperEditorIosControlsScope).controller; + } + + /// Finds the nearest [SuperEditorIosControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperEditorIosControlsController]. + static SuperEditorIosControlsController nearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()!.controller; + + static SuperEditorIosControlsController? maybeNearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.controller; + + const SuperEditorIosControlsScope({ + super.key, + required this.controller, + required super.child, + }); + + final SuperEditorIosControlsController controller; + + @override + bool updateShouldNotify(SuperEditorIosControlsScope oldWidget) { + return controller != oldWidget.controller; + } +} + +/// A controller, which coordinates the state of various iOS editor controls, including +/// the caret, handles, floating cursor, magnifier, and toolbar. +class SuperEditorIosControlsController { + SuperEditorIosControlsController({ + this.useIosSelectionHeuristics = true, + this.handleColor, + FloatingCursorController? floatingCursorController, + this.magnifierBuilder, + this.toolbarBuilder, + this.createOverlayControlsClipper, + }) : floatingCursorController = floatingCursorController ?? FloatingCursorController(); + + void dispose() { + floatingCursorController.dispose(); + _shouldCaretBlink.dispose(); + _shouldShowMagnifier.dispose(); + _shouldShowToolbar.dispose(); + } + + /// {@template ios_use_selection_heuristics} + /// Whether to adjust the user's selection similar to the way iOS does. + /// + /// For example: iOS doesn't let users tap directly on a text offset. Instead, + /// iOS places the caret at the end of the word, or beginning of the word, + /// based on how close the user is to those locations when he taps. + /// + /// When this property is `true`, iOS-style heuristics should be used. When + /// this value is `false`, the user's gestures should directly impact the + /// area they touched. + /// {@endtemplate} + final bool useIosSelectionHeuristics; + + /// Color of the text selection drag handles on iOS. + final Color? handleColor; + + /// Whether the caret (collapsed handle) should blink right now. + ValueListenable get shouldCaretBlink => _shouldCaretBlink; + final _shouldCaretBlink = ValueNotifier(true); + + /// Tells the caret to blink by setting [shouldCaretBlink] to `true`. + void blinkCaret() => _shouldCaretBlink.value = true; + + /// Tells the caret to stop blinking by setting [shouldCaretBlink] to `false`. + void doNotBlinkCaret() => _shouldCaretBlink.value = false; + + /// {@macro are_selection_handles_allowed} + ValueListenable get areSelectionHandlesAllowed => _areSelectionHandlesAllowed; + final _areSelectionHandlesAllowed = ValueNotifier(true); + + /// Temporarily prevents any selection handles from being displayed. + /// + /// Call this when you want to select some content, but don't want to show the drag handles. + /// [allowSelectionHandles] must be called to allow the drag handles to be displayed again. + void allowSelectionHandles() => _areSelectionHandlesAllowed.value = true; + + /// Allows the selection handles to be displayed after they have been temporarily + /// prevented by [preventSelectionHandles]. + void preventSelectionHandles() => _areSelectionHandlesAllowed.value = false; + + /// Reports the [HandleType] of the handle being dragged by the user. + /// + /// If no drag handle is being dragged, this value is `null`. + final ValueNotifier handleBeingDragged = ValueNotifier(null); + + /// Controls the iOS floating cursor. + late final FloatingCursorController floatingCursorController; + + /// Whether the iOS magnifier should be displayed right now. + ValueListenable get shouldShowMagnifier => _shouldShowMagnifier; + final _shouldShowMagnifier = ValueNotifier(false); + + /// Shows the magnifier by setting [shouldShowMagnifier] to `true`. + void showMagnifier() => _shouldShowMagnifier.value = true; + + /// Hides the magnifier by setting [shouldShowMagnifier] to `false`. + void hideMagnifier() => _shouldShowMagnifier.value = false; + + /// Toggles [shouldShowMagnifier]. + void toggleMagnifier() => _shouldShowMagnifier.value = !_shouldShowMagnifier.value; + + /// Link to a location where a magnifier should be focused. + final magnifierFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the magnifier. + /// + /// If [magnifierBuilder] is `null`, a default iOS magnifier is displayed. + final DocumentMagnifierBuilder? magnifierBuilder; + + /// Whether the iOS floating toolbar should be displayed right now. + ValueListenable get shouldShowToolbar => _shouldShowToolbar; + final _shouldShowToolbar = ValueNotifier(false); + + /// Shows the toolbar by setting [shouldShowToolbar] to `true`. + void showToolbar() => _shouldShowToolbar.value = true; + + /// Hides the toolbar by setting [shouldShowToolbar] to `false`. + void hideToolbar() => _shouldShowToolbar.value = false; + + /// Toggles [shouldShowToolbar]. + void toggleToolbar() => _shouldShowToolbar.value = !_shouldShowToolbar.value; + + /// Link to a location where a toolbar should be focused. + /// + /// This link probably points to a rectangle, such as a bounding rectangle + /// around the user's selection. Therefore, the toolbar builder shouldn't + /// assume that this focal point is a single pixel. + final toolbarFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the floating + /// toolbar. + /// + /// If [toolbarBuilder] is `null`, a default iOS toolbar is displayed. + final DocumentFloatingToolbarBuilder? toolbarBuilder; + + /// Creates a clipper that restricts where the toolbar and magnifier can + /// appear in the overlay. + /// + /// If no clipper factory method is provided, then the overlay controls + /// will be allowed to appear anywhere in the overlay in which they sit + /// (probably the entire screen). + final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; +} + /// Document gesture interactor that's designed for iOS touch input, e.g., -/// drag to scroll, and handles to control selection. -class IOSDocumentTouchInteractor extends StatefulWidget { - const IOSDocumentTouchInteractor({ +/// drag to scroll, tap to place the caret, double tap to select a word, +/// triple tap to select a paragraph. +/// +/// Depends upon an ancestor [SuperEditorIosControlsScope], which coordinates the +/// state of visual iOS controls, e.g., caret, handles, magnifier, toolbar. +/// +/// [IosDocumentTouchInteractor] coordinates half of the iOS floating cursor behavior. +/// This widget handles the following: +/// * Listens for the user to start moving the floating cursor, notifies the ancestor +/// [SuperEditorIosControlsScope] that the floating cursor is active, and starts +/// managing auto-scrolling based on the floating cursor offset in the viewport. +/// * Listens for all user movements of the floating cursor, maps the floating +/// cursor offset to a document position, chooses an appropriate size for the +/// floating cursor based on the content beneath it, and then notifies the ancestor +/// [SuperEditorIosControlsScope] of the new floating cursor position and size. +/// * Listens for the user to stop using the floating cursor, notifies the ancestor +/// [SuperEditorIosControlsScope] that the floating cursor is inactive, and stops +/// managing auto-scrolling based on the floating cursor offset in the viewport. +/// +/// This widget does NOT paint a floating cursor. That responsibility is left to +/// other widgets. +class IosDocumentTouchInteractor extends StatefulWidget { + const IosDocumentTouchInteractor({ Key? key, required this.focusNode, + required this.editor, required this.document, - required this.documentKey, required this.getDocumentLayout, required this.selection, - this.scrollController, + required this.isImeConnected, + this.isScribbleInProgress, + this.openKeyboardWhenTappingExistingSelection = true, + this.openKeyboardOnSelectionChange = true, + required this.openSoftwareKeyboard, + required this.scrollController, + required this.dragHandleAutoScroller, + required this.fillViewport, + this.contentTapHandlers, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), - required this.handleColor, - required this.popoverToolbarBuilder, - required this.floatingCursorController, - this.createOverlayControlsClipper, this.showDebugPaint = false, required this.child, }) : super(key: key); final FocusNode focusNode; + final Editor editor; final Document document; - final GlobalKey documentKey; final DocumentLayout Function() getDocumentLayout; - final ValueNotifier selection; + final ValueListenable selection; + + /// A listenable that reports whether the IME is currently connected to this + /// editor, which means either a software keyboard or hardware keyboard is + /// currently configured to edit the document in this editor. + /// + /// This signal is used to, for example, to decide whether we should show + /// the popover toolbar on tap. + final ValueListenable isImeConnected; + + /// A listenable that reports whether a Scribble (Apple Pencil handwriting) + /// or stylus writing interaction is currently in progress. + /// + /// When scribble is in progress, gesture handling avoids interfering with + /// the IME's scribble input. + final ValueListenable? isScribbleInProgress; + + /// {@macro openKeyboardWhenTappingExistingSelection} + final bool openKeyboardWhenTappingExistingSelection; + + /// {@macro openKeyboardOnSelectionChange} + final bool openKeyboardOnSelectionChange; + + /// A callback that should open the software keyboard when invoked. + final VoidCallback openSoftwareKeyboard; - final ScrollController? scrollController; + /// Optional list of handlers that respond to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List? contentTapHandlers; + + final ScrollController scrollController; + + final ValueNotifier dragHandleAutoScroller; /// The closest that the user's selection drag gesture can get to the /// document boundary before auto-scrolling. @@ -54,127 +329,74 @@ class IOSDocumentTouchInteractor extends StatefulWidget { /// edges. final AxisOffset dragAutoScrollBoundary; - /// Color the iOS-style text selection drag handles. - final Color handleColor; - - final WidgetBuilder popoverToolbarBuilder; - - /// Controller that reports the current offset of the iOS floating - /// cursor. - final FloatingCursorController floatingCursorController; - - /// Creates a clipper that applies to overlay controls, preventing - /// the overlay controls from appearing outside the given clipping - /// region. - /// - /// If no clipper factory method is provided, then the overlay controls - /// will be allowed to appear anywhere in the overlay in which they sit - /// (probably the entire screen). - final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; + /// Whether the document gesture detector should fill the entire viewport + /// even if the actual content is smaller. + final bool fillViewport; final bool showDebugPaint; final Widget child; @override - State createState() => _IOSDocumentTouchInteractorState(); + State createState() => _IosDocumentTouchInteractorState(); } -class _IOSDocumentTouchInteractorState extends State - with WidgetsBindingObserver, SingleTickerProviderStateMixin, DocumentSelectionOnFocusMixin { - // ScrollController used when this interactor installs its own Scrollable. - // The alternative case is the one in which this interactor defers to an - // ancestor scrollable. - late ScrollController _scrollController; +class _IosDocumentTouchInteractorState extends State + with WidgetsBindingObserver, SingleTickerProviderStateMixin { // The ScrollPosition attached to the _ancestorScrollable. ScrollPosition? _ancestorScrollPosition; - // The actual ScrollPosition that's used for the document layout, either - // the Scrollable installed by this interactor, or an ancestor Scrollable. - ScrollPosition? _activeScrollPosition; - - // OverlayEntry that displays editing controls, e.g., - // drag handles, magnifier, and toolbar. - OverlayEntry? _controlsOverlayEntry; - late IosDocumentGestureEditingController _editingController; - final _documentLayerLink = LayerLink(); - final _magnifierFocalPointLink = LayerLink(); - - late DragHandleAutoScroller _handleAutoScrolling; + + SuperEditorIosControlsController? _controlsController; + late FloatingCursorListener _floatingCursorListener; + Offset? _globalStartDragOffset; Offset? _dragStartInDoc; Offset? _startDragPositionOffset; double? _dragStartScrollOffset; Offset? _globalDragOffset; + + /// The [Offset] of the magnifier's focal point in the [DocumentLayout] coordinate space. + final _magnifierFocalPointInDocumentSpace = ValueNotifier(null); Offset? _dragEndInInteractor; DragMode? _dragMode; + // TODO: HandleType is the wrong type here, we need collapsed/base/extent, // not collapsed/upstream/downstream. Change the type once it's working. HandleType? _dragHandleType; - // Whether we're currently waiting to see if the user taps - // again on the document. - // - // We track this for the following reason: on iOS, there is - // no collapsed handle. Instead, the caret is the handle. This - // means that the caret must be draggable. But this creates an - // issue. If the user tries to double tap, first the user taps - // and places the caret and then the user taps again. But the - // 2nd tap gets consumed by the tappable caret, when instead the - // 2nd tap should hit the document again. To allow for double and - // triple taps on iOS, we explicitly tell the overlay controls to - // avoid handling gestures while we are `_waitingForMoreTaps`. - bool _waitingForMoreTaps = false; + Timer? _tapDownLongPressTimer; + Offset? _globalTapDownOffset; + bool get _isLongPressInProgress => _longPressStrategy != null; + IosLongPressSelectionStrategy? _longPressStrategy; + + // Cached view metrics to ignore unnecessary didChangeMetrics calls. + Size? _lastSize; + ViewPadding? _lastInsets; + + final _interactor = GlobalKey(); @override void initState() { super.initState(); - _handleAutoScrolling = DragHandleAutoScroller( + widget.dragHandleAutoScroller.value = DragHandleAutoScroller( vsync: this, dragAutoScrollBoundary: widget.dragAutoScrollBoundary, getScrollPosition: () => scrollPosition, getViewportBox: () => viewportBox, ); + widget.document.addListener(_onDocumentChange); + widget.focusNode.addListener(_onFocusChange); - if (widget.focusNode.hasFocus) { - // During Hot Reload, the gesture mode could be changed. - // If that's the case, initState is called while the Overlay is being - // built. This could crash the app. Because of that, we show the editing - // controls overlay in the next frame. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _showEditingControlsOverlay(); - }); - } - - _scrollController = _scrollController = (widget.scrollController ?? ScrollController()); - // I added this listener directly to our ScrollController because the listener we added - // to the ScrollPosition wasn't triggering once the user makes an initial selection. I'm - // not sure why that happened. It's as if the ScrollPosition was replaced, but I don't - // know why the ScrollPosition would be replaced. In the meantime, adding this listener - // keeps the toolbar positioning logic working. - // TODO: rely solely on a ScrollPosition listener, not a ScrollController listener. - _scrollController.addListener(_onScrollChange); - - _editingController = IosDocumentGestureEditingController( - documentLayoutLink: _documentLayerLink, - magnifierFocalPointLink: _magnifierFocalPointLink, - ); - widget.document.addListener(_onDocumentChange); - widget.selection.addListener(_onSelectionChange); + widget.isImeConnected.addListener(_onImeConnectionChange); - startSyncingSelectionWithFocus( - focusNode: widget.focusNode, - getDocumentLayout: widget.getDocumentLayout, - selection: widget.selection, + _floatingCursorListener = FloatingCursorListener( + onStart: _onFloatingCursorStart, + onStop: _onFloatingCursorStop, ); - // If we already have a selection, we need to display the caret. - if (widget.selection.value != null) { - _onSelectionChange(); - } - WidgetsBinding.instance.addObserver(this); } @@ -182,78 +404,43 @@ class _IOSDocumentTouchInteractorState extends State void didChangeDependencies() { super.didChangeDependencies(); - _ancestorScrollPosition = _findAncestorScrollable(context)?.position; + final view = View.of(context); + _lastSize = view.physicalSize; + _lastInsets = view.viewInsets; - // On the next frame, check if our active scroll position changed to a - // different instance. If it did, move our listener to the new one. - // - // This is posted to the next frame because the first time this method - // runs, we haven't attached to our own ScrollController yet, so - // this.scrollPosition might be null. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - final newScrollPosition = scrollPosition; - if (newScrollPosition != _activeScrollPosition) { - setState(() { - _activeScrollPosition?.removeListener(_onScrollChange); - newScrollPosition.addListener(_onScrollChange); - _activeScrollPosition = newScrollPosition; - }); - } - }); + if (_controlsController != null) { + _controlsController!.floatingCursorController.removeListener(_floatingCursorListener); + _controlsController!.floatingCursorController.cursorGeometryInViewport + .removeListener(_onFloatingCursorGeometryChange); + } + _controlsController = SuperEditorIosControlsScope.rootOf(context); + _controlsController!.floatingCursorController.addListener(_floatingCursorListener); + _controlsController!.floatingCursorController.cursorGeometryInViewport.addListener(_onFloatingCursorGeometryChange); + + _ancestorScrollPosition = context.findAncestorScrollableWithVerticalScroll?.position; } @override - void didUpdateWidget(IOSDocumentTouchInteractor oldWidget) { + void didUpdateWidget(IosDocumentTouchInteractor oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.focusNode != oldWidget.focusNode) { - oldWidget.focusNode.removeListener(_onFocusChange); - widget.focusNode.addListener(_onFocusChange); - onFocusNodeReplaced(widget.focusNode); - } - if (widget.document != oldWidget.document) { oldWidget.document.removeListener(_onDocumentChange); widget.document.addListener(_onDocumentChange); } - if (widget.selection != oldWidget.selection) { - oldWidget.selection.removeListener(_onSelectionChange); - widget.selection.addListener(_onSelectionChange); - onDocumentSelectionNotifierReplaced(widget.selection); - - // Selection has changed, we need to update the caret. - if (widget.selection.value != oldWidget.selection.value) { - _onSelectionChange(); - } + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_onFocusChange); + widget.focusNode.addListener(_onFocusChange); } - if (widget.getDocumentLayout != oldWidget.getDocumentLayout) { - onDocumentLayoutResolverReplaced(widget.getDocumentLayout); - } - } + if (widget.isImeConnected != oldWidget.isImeConnected) { + oldWidget.isImeConnected.removeListener(_onImeConnectionChange); + widget.isImeConnected.addListener(_onImeConnectionChange); - @override - void reassemble() { - super.reassemble(); - - if (widget.focusNode.hasFocus) { - // On Hot Reload we need to remove any visible overlay controls and then - // bring them back a frame later to avoid having the controls attempt - // to access the layout of the text. The text layout is not immediately - // available upon Hot Reload. Accessing it results in an exception. - // TODO: this was copied from Super Textfield, see if the timing - // problem exists for documents, too. - _removeEditingOverlayControls(); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // During Hot Reload, the gesture mode could be changed, - // so it's possible that we are no longer mounted after - // the post frame callback. - if (mounted) { - _showEditingControlsOverlay(); - } - }); + if (oldWidget.isImeConnected.value != widget.isImeConnected.value) { + _onImeConnectionChange(); + } } } @@ -261,123 +448,93 @@ class _IOSDocumentTouchInteractorState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); - widget.document.removeListener(_onDocumentChange); - widget.selection.removeListener(_onSelectionChange); - - _removeEditingOverlayControls(); - - if (widget.scrollController == null) { - _scrollController.dispose(); - } + _controlsController!.floatingCursorController.removeListener(_floatingCursorListener); + _controlsController!.floatingCursorController.cursorGeometryInViewport + .removeListener(_onFloatingCursorGeometryChange); - _handleAutoScrolling.dispose(); + widget.isImeConnected.removeListener(_onImeConnectionChange); widget.focusNode.removeListener(_onFocusChange); - stopSyncingSelectionWithFocus(); + widget.document.removeListener(_onDocumentChange); + + widget.dragHandleAutoScroller.value?.dispose(); super.dispose(); } @override void didChangeMetrics() { + // DidChangeMetrics is sometimes called even when metrics doesn't change + // (i.e. on iOS with keyboard visible) + final view = View.of(context); + final size = view.physicalSize; + final insets = view.viewInsets; + if (size == _lastSize && + _lastInsets?.left == insets.left && + _lastInsets?.right == insets.right && + _lastInsets?.top == insets.top && + _lastInsets?.bottom == insets.bottom) { + return; + } + _lastSize = size; + _lastInsets = insets; + // The available screen dimensions may have changed, e.g., due to keyboard - // appearance/disappearance. Reflow the layout. Use a post-frame callback - // to give the rest of the UI a chance to reflow, first. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - _ensureSelectionExtentIsVisible(); - _updateHandlesAfterSelectionOrLayoutChange(); - - setState(() { - // reflow document layout - }); - } + // appearance/disappearance. Ensure the extent is still visible. Use a + // post-frame callback to give the rest of the UI a chance to reflow, first. + onNextFrame((_) { + _ensureSelectionExtentIsVisible(); }); } + void _onFocusChange() { + if (!widget.focusNode.hasFocus) { + // Overlay controls shouldn't be visible when there's no focus. + _controlsController + ?..hideToolbar() + ..hideMagnifier(); + } + } + + void _onImeConnectionChange() { + if (!widget.isImeConnected.value) { + // Our editor doesn't have an IME connection. Ensure that our visual state + // is acceptable when we don't have an open IME connection. + _controlsController?.hideToolbar(); + } + } + void _ensureSelectionExtentIsVisible() { editorGesturesLog.fine("Ensuring selection extent is visible"); - final collapsedHandleOffset = _editingController.collapsedHandleOffset; - final extentHandleOffset = _editingController.downstreamHandleOffset; - if (collapsedHandleOffset == null && extentHandleOffset == null) { + final selection = widget.selection.value; + if (selection == null) { // There's no selection. We don't need to take any action. return; } - // Determines the offset of the editor in the viewport coordinate - final editorBox = widget.documentKey.currentContext!.findRenderObject() as RenderBox; - final editorInViewportOffset = viewportBox.localToGlobal(Offset.zero) - editorBox.localToGlobal(Offset.zero); - - // Determines the offset of the bottom of the handle in the viewport coordinate - late Offset handleInViewportOffset; - - if (collapsedHandleOffset != null) { - editorGesturesLog.fine("The selection is collapsed"); - handleInViewportOffset = collapsedHandleOffset - editorInViewportOffset; - } else { - editorGesturesLog.fine("The selection is expanded"); - handleInViewportOffset = extentHandleOffset! - editorInViewportOffset; - } - _handleAutoScrolling.ensureOffsetIsVisible(handleInViewportOffset); - } + // Calculate the y-value of the selection extent side of the selected content so that we + // can ensure they're visible. + final selectionRectInDocumentLayout = + widget.getDocumentLayout().getRectForSelection(selection.base, selection.extent)!; + final extentOffsetInViewport = widget.document.getAffinityForSelection(selection) == TextAffinity.downstream + ? _documentOffsetToViewportOffset(selectionRectInDocumentLayout.bottomCenter) + : _documentOffsetToViewportOffset(selectionRectInDocumentLayout.topCenter); - void _onFocusChange() { - if (widget.focusNode.hasFocus) { - // TODO: the text field only showed the editing controls if the text input - // client wasn't attached yet. Do we need a similar check here? - _showEditingControlsOverlay(); - } else { - _removeEditingOverlayControls(); - } + widget.dragHandleAutoScroller.value?.ensureOffsetIsVisible(extentOffsetInViewport); } - void _onDocumentChange() { - _editingController.hideToolbar(); + void _onDocumentChange(_) { + _controlsController!.hideToolbar(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + onNextFrame((_) { // The user may have changed the type of node, e.g., paragraph to - // blockquote, which impacts the caret size and position. Reposition - // the caret on the next frame. - // TODO: find a way to only do this when something relevant changes - _updateHandlesAfterSelectionOrLayoutChange(); - + // blockquote, which impacts the caret size and position. Ensure + // the extent is still visible. _ensureSelectionExtentIsVisible(); }); } - void _onSelectionChange() { - // The selection change might correspond to new content that's not - // laid out yet. Wait until the next frame to update visuals. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _updateHandlesAfterSelectionOrLayoutChange(); - }); - } - - void _updateHandlesAfterSelectionOrLayoutChange() { - final newSelection = widget.selection.value; - - if (newSelection == null) { - _editingController - ..removeCaret() - ..hideToolbar() - ..collapsedHandleOffset = null - ..upstreamHandleOffset = null - ..downstreamHandleOffset = null - ..collapsedHandleOffset = null; - } else if (newSelection.isCollapsed) { - _positionCaret(); - _positionCollapsedHandle(); - } else { - // The selection is expanded - _positionExpandedSelectionHandles(); - } - } - - void _onScrollChange() { - _positionToolbar(); - } - /// Returns the layout for the current document, which answers questions /// about the locations and sizes of visual components within the layout. DocumentLayout get _docLayout => widget.getDocumentLayout(); @@ -392,7 +549,7 @@ class _IOSDocumentTouchInteractorState extends State /// If this widget doesn't have an ancestor `Scrollable`, then this /// widget includes a `ScrollView` and the `ScrollView`'s position /// is returned. - ScrollPosition get scrollPosition => _ancestorScrollPosition ?? _scrollController.position; + ScrollPosition get scrollPosition => _ancestorScrollPosition ?? widget.scrollController.position; /// Returns the `RenderBox` for the scrolling viewport. /// @@ -402,21 +559,21 @@ class _IOSDocumentTouchInteractorState extends State /// If this widget doesn't have an ancestor `Scrollable`, then this /// widget includes a `ScrollView` and this `State`'s render object /// is the viewport `RenderBox`. - RenderBox get viewportBox => - (_findAncestorScrollable(context)?.context.findRenderObject() ?? context.findRenderObject()) as RenderBox; + RenderBox get viewportBox => context.findViewportBox(); - RenderBox get interactorBox => context.findRenderObject() as RenderBox; + Offset _documentOffsetToViewportOffset(Offset documentOffset) { + final globalOffset = _docLayout.getGlobalOffsetFromDocumentOffset(documentOffset); + return viewportBox.globalToLocal(globalOffset); + } + + /// Returns the render box for the interactor gesture detector. + RenderBox get interactorBox => _interactor.currentContext!.findRenderObject() as RenderBox; /// Converts the given [interactorOffset] from the [DocumentInteractor]'s coordinate /// space to the [DocumentLayout]'s coordinate space. - Offset _interactorOffsetToDocOffset(Offset interactorOffset) { - return _docLayout.getDocumentOffsetFromAncestorOffset(interactorOffset, context.findRenderObject()!); - } - - /// Converts the given [documentOffset] to an `Offset` in the interactor's - /// coordinate space. - Offset _docOffsetToInteractorOffset(Offset documentOffset) { - return _docLayout.getAncestorOffsetFromDocumentOffset(documentOffset, context.findRenderObject()!); + Offset _interactorOffsetToDocumentOffset(Offset interactorOffset) { + final globalOffset = interactorBox.localToGlobal(interactorOffset); + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); } /// Maps the given [interactorOffset] within the interactor's coordinate space @@ -427,7 +584,7 @@ class _IOSDocumentTouchInteractorState extends State /// /// When this interactor defers to an ancestor `Scrollable`, then the /// [interactorOffset] is transformed into the ancestor coordinate space. - Offset _interactorOffsetInViewport(Offset interactorOffset) { + Offset _interactorOffsetToViewportOffset(Offset interactorOffset) { // Viewport might be our box, or an ancestor box if we're inside someone // else's Scrollable. return viewportBox.globalToLocal( @@ -435,77 +592,229 @@ class _IOSDocumentTouchInteractorState extends State ); } + void _onTapDown(TapDownDetails details) { + _globalTapDownOffset = details.globalPosition; + _tapDownLongPressTimer?.cancel(); + if (!disableLongPressSelectionForSuperlist) { + _tapDownLongPressTimer = Timer(kLongPressTimeout, _onLongPressDown); + } + + // Stop the caret from blinking, in case this tap down turns into a long-press drag, + // or a caret drag. + _controlsController!.doNotBlinkCaret(); + } + + // Runs when a tap down has lasted long enough to signify a long-press. + void _onLongPressDown() { + final interactorOffset = interactorBox.globalToLocal(_globalTapDownOffset!); + final tapDownDocumentOffset = _interactorOffsetToDocumentOffset(interactorOffset); + final tapDownDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(tapDownDocumentOffset); + if (tapDownDocumentPosition == null) { + return; + } + + if (_isOverBaseHandle(interactorOffset) || + _isOverExtentHandle(interactorOffset) || + _isOverCollapsedHandle(interactorOffset)) { + // Don't do anything for long presses over the handles, because we want the user + // to be able to drag them without worrying about how long they've pressed. + return; + } + + _globalDragOffset = _globalTapDownOffset; + _longPressStrategy = IosLongPressSelectionStrategy( + document: widget.document, + documentLayout: _docLayout, + select: _select, + ); + final didLongPressSelectionStart = _longPressStrategy!.onLongPressStart( + tapDownDocumentOffset: tapDownDocumentOffset, + ); + if (!didLongPressSelectionStart) { + _longPressStrategy = null; + return; + } + + _placeFocalPointNearTouchOffset(); + _controlsController! + ..hideToolbar() + ..showMagnifier(); + + widget.focusNode.requestFocus(); + } + + void _onTapCancel() { + _tapDownLongPressTimer?.cancel(); + _tapDownLongPressTimer = null; + } + void _onTapUp(TapUpDetails details) { + // Stop waiting for a long-press to start. + _globalTapDownOffset = null; + _tapDownLongPressTimer?.cancel(); + _controlsController! + ..hideMagnifier() + ..blinkCaret(); + + editorGesturesLog.info("Tap down on document"); + final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); + editorGesturesLog.fine(" - document offset: $docOffset"); + + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + } + final selection = widget.selection.value; if (selection != null && !selection.isCollapsed && (_isOverBaseHandle(details.localPosition) || _isOverExtentHandle(details.localPosition))) { - _editingController.toggleToolbar(); - _positionToolbar(); + _controlsController!.toggleToolbar(); return; } - editorGesturesLog.info("Tap down on document"); - final docOffset = _interactorOffsetToDocOffset(details.localPosition); - editorGesturesLog.fine(" - document offset: $docOffset"); final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null && selection != null && !selection.isCollapsed && widget.document.doesSelectionContainPosition(selection, docPosition)) { - // The user tapped on an expanded selection. Toggle the toolbar. - _editingController.toggleToolbar(); - _positionToolbar(); + // The user tapped on an expanded selection. Toggle the toolbar and show + // the software keyboard. + _controlsController!.toggleToolbar(); + + if (widget.openKeyboardWhenTappingExistingSelection) { + widget.openSoftwareKeyboard(); + } + return; } - setState(() { - _waitingForMoreTaps = true; - _controlsOverlayEntry?.markNeedsBuild(); - }); - if (docPosition != null) { - final didTapOnExistingSelection = selection != null && selection.isCollapsed && selection.extent == docPosition; + late final DocumentPosition adjustedSelectionPosition; + if (docPosition.nodePosition is TextNodePosition) { + // The user tapped a text position. Adjust the position to the start + // or end of the word, as per iOS behavior. + adjustedSelectionPosition = _moveTapPositionToWordBoundary(docPosition); + } else { + // Selection isn't text. Don't adjust the position. + adjustedSelectionPosition = docPosition; + } - if (didTapOnExistingSelection) { + final didTapOnExistingSelection = selection != null && + selection.isCollapsed && + selection.extent.nodeId == docPosition.nodeId && + selection.extent.nodePosition.isEquivalentTo(docPosition.nodePosition); + + if (didTapOnExistingSelection && widget.isImeConnected.value) { // Toggle the toolbar display when the user taps on the collapsed caret, // or on top of an existing selection. - _editingController.toggleToolbar(); + // + // But we only do this when the keyboard is already open. This is because + // we don't want to show the toolbar when the user taps simply to open + // the keyboard. That would feel unintentional, like a bug. + _controlsController!.toggleToolbar(); } else { // The user tapped somewhere else in the document. Hide the toolbar. - _editingController.hideToolbar(); + _controlsController!.hideToolbar(); } - final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; - if (!tappedComponent.isVisualSelectionSupported()) { - // The user tapped a non-selectable component. - // Place the document selection at the nearest selectable node - // to the tapped component. - moveSelectionToNearestSelectableNode( - document: widget.document, - documentLayoutResolver: widget.getDocumentLayout, - selection: widget.selection, - startingNode: widget.document.getNodeById(docPosition.nodeId)!, - ); - return; + if (didTapOnExistingSelection) { + if (widget.openKeyboardWhenTappingExistingSelection) { + // The user tapped on the existing selection. Show the software keyboard. + // + // If the user didn't tap on an existing selection, the software keyboard will + // already be visible. + widget.openSoftwareKeyboard(); + } } else { - // Place the document selection at the location where the - // user tapped. - _selectPosition(docPosition); + final tappedComponent = _docLayout.getComponentByNodeId(adjustedSelectionPosition.nodeId)!; + if (!tappedComponent.isVisualSelectionSupported()) { + // The user tapped a non-selectable component. + // Place the document selection at the nearest selectable node + // to the tapped component. + moveSelectionToNearestSelectableNode( + editor: widget.editor, + document: widget.document, + documentLayoutResolver: widget.getDocumentLayout, + currentSelection: widget.selection.value, + startingNode: widget.document.getNodeById(adjustedSelectionPosition.nodeId)!, + ); + return; + } else { + // Place the document selection at the location where the + // user tapped. + _selectPosition(adjustedSelectionPosition); + + // Ensure the keyboard is visible. + if (widget.openKeyboardOnSelectionChange) { + widget.openSoftwareKeyboard(); + } + } } } else { - widget.selection.value = null; - _editingController.hideToolbar(); + widget.editor.execute([ + const ClearSelectionRequest(), + ]); + _controlsController!.hideToolbar(); } - _positionToolbar(); - widget.focusNode.requestFocus(); } + DocumentPosition _moveTapPositionToWordBoundary(DocumentPosition docPosition) { + if (!SuperEditorIosControlsScope.rootOf(context).useIosSelectionHeuristics) { + // iOS-style adjustments aren't desired. Don't adjust th given position. + return docPosition; + } + + final text = (widget.document.getNodeById(docPosition.nodeId) as TextNode).text; + final tapOffset = (docPosition.nodePosition as TextNodePosition).offset; + if (tapOffset == text.length) { + return docPosition; + } + final adjustedSelectionOffset = IosHeuristics.adjustTapOffset(text.toPlainText(), tapOffset); + + return DocumentPosition( + nodeId: docPosition.nodeId, + nodePosition: TextNodePosition(offset: adjustedSelectionOffset), + ); + } + void _onDoubleTapUp(TapUpDetails details) { + editorGesturesLog.info("Double tap down on document"); + final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); + editorGesturesLog.fine(" - document offset: $docOffset"); + + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onDoubleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + } + final selection = widget.selection.value; if (selection != null && !selection.isCollapsed && @@ -513,20 +822,14 @@ class _IOSDocumentTouchInteractorState extends State return; } - editorGesturesLog.info("Double tap down on document"); - final docOffset = _interactorOffsetToDocOffset(details.localPosition); - editorGesturesLog.fine(" - document offset: $docOffset"); final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null) { final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; if (!tappedComponent.isVisualSelectionSupported()) { return; } - widget.selection.value = null; - bool didSelectContent = _selectWordAt( docPosition: docPosition, docLayout: _docLayout, @@ -542,15 +845,16 @@ class _IOSDocumentTouchInteractorState extends State _selectPosition(docPosition); } } else { - widget.selection.value = null; + widget.editor.execute([ + const ClearSelectionRequest(), + ]); } final newSelection = widget.selection.value; if (newSelection == null || newSelection.isCollapsed) { - _editingController.hideToolbar(); + _controlsController!.hideToolbar(); } else { - _editingController.showToolbar(); - _positionToolbar(); + _controlsController!.showToolbar(); } widget.focusNode.requestFocus(); @@ -561,16 +865,23 @@ class _IOSDocumentTouchInteractorState extends State return false; } - widget.selection.value = DocumentSelection( - base: DocumentPosition( - nodeId: position.nodeId, - nodePosition: const UpstreamDownstreamNodePosition.upstream(), - ), - extent: DocumentPosition( - nodeId: position.nodeId, - nodePosition: const UpstreamDownstreamNodePosition.downstream(), + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: position.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: position.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, ), - ); + const ClearComposingRegionRequest(), + ]); return true; } @@ -578,19 +889,34 @@ class _IOSDocumentTouchInteractorState extends State void _onTripleTapUp(TapUpDetails details) { editorGesturesLog.info("Triple down down on document"); - final docOffset = _interactorOffsetToDocOffset(details.localPosition); + final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); editorGesturesLog.fine(" - document offset: $docOffset"); + + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onTripleTap( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + } + final docPosition = _docLayout.getDocumentPositionNearestToOffset(docOffset); editorGesturesLog.fine(" - tapped document position: $docPosition"); - if (docPosition != null) { final tappedComponent = _docLayout.getComponentByNodeId(docPosition.nodeId)!; if (!tappedComponent.isVisualSelectionSupported()) { return; } - widget.selection.value = null; - final didSelectParagraph = _selectParagraphAt( docPosition: docPosition, docLayout: _docLayout, @@ -601,15 +927,16 @@ class _IOSDocumentTouchInteractorState extends State _selectPosition(docPosition); } } else { - widget.selection.value = null; + widget.editor.execute([ + const ClearSelectionRequest(), + ]); } final selection = widget.selection.value; if (selection == null || selection.isCollapsed) { - _editingController.hideToolbar(); + _controlsController!.hideToolbar(); } else { - _editingController.showToolbar(); - _positionToolbar(); + _controlsController!.showToolbar(); } widget.focusNode.requestFocus(); @@ -621,6 +948,28 @@ class _IOSDocumentTouchInteractorState extends State } void _onPanStart(DragStartDetails details) { + // Stop waiting for a long-press to start, if a long press isn't already in-progress. + _globalTapDownOffset = null; + _tapDownLongPressTimer?.cancel(); + + if (widget.contentTapHandlers != null) { + final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); + for (final handler in widget.contentTapHandlers!) { + final result = handler.onPanStart( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + } + // TODO: to help the user drag handles instead of scrolling, try checking touch // placement during onTapDown, and then pick that up here. I think the little // bit of slop might be the problem. @@ -629,7 +978,11 @@ class _IOSDocumentTouchInteractorState extends State return; } - if (selection.isCollapsed && _isOverCollapsedHandle(details.localPosition)) { + if (_isLongPressInProgress) { + _dragMode = DragMode.longPress; + _dragHandleType = null; + _longPressStrategy!.onLongPressDragStart(); + } else if (selection.isCollapsed && _isOverCollapsedHandle(details.localPosition)) { _dragMode = DragMode.collapsed; _dragHandleType = HandleType.collapsed; } else if (_isOverBaseHandle(details.localPosition)) { @@ -642,33 +995,16 @@ class _IOSDocumentTouchInteractorState extends State return; } - _editingController.hideToolbar(); + _controlsController! + ..doNotBlinkCaret() + ..hideToolbar() + ..showMagnifier(); - _globalStartDragOffset = details.globalPosition; - final interactorBox = context.findRenderObject() as RenderBox; - final handleOffsetInInteractor = interactorBox.globalToLocal(details.globalPosition); - _dragStartInDoc = _interactorOffsetToDocOffset(handleOffsetInInteractor); + _updateDragStartLocation(details.globalPosition); - _startDragPositionOffset = _docLayout - .getRectForPosition( - _dragHandleType! == HandleType.upstream ? selection.base : selection.extent, - )! - .center; + widget.dragHandleAutoScroller.value?.startAutoScrollHandleMonitoring(); - // We need to record the scroll offset at the beginning of - // a drag for the case that this interactor is embedded - // within an ancestor Scrollable. We need to use this value - // to calculate a scroll delta on every scroll frame to - // account for the fact that this interactor is moving within - // the ancestor scrollable, despite the fact that the user's - // finger/mouse position hasn't changed. - _dragStartScrollOffset = scrollPosition.pixels; - - _handleAutoScrolling.startAutoScrollHandleMonitoring(); - - scrollPosition.addListener(_updateDragSelection); - - _controlsOverlayEntry!.markNeedsBuild(); + scrollPosition.addListener(_onAutoScrollChange); } bool _isOverCollapsedHandle(Offset interactorOffset) { @@ -678,10 +1014,15 @@ class _IOSDocumentTouchInteractorState extends State } final extentRect = _docLayout.getRectForPosition(collapsedPosition)!; - final caretRect = Rect.fromLTWH(extentRect.left - 1, extentRect.center.dy, 1, 1).inflate(24); + final caretHitArea = Rect.fromLTRB( + extentRect.left - 24, + extentRect.top, + extentRect.right + 24, + extentRect.bottom, + ); - final docOffset = _docLayout.getDocumentOffsetFromAncestorOffset(interactorOffset, context.findRenderObject()!); - return caretRect.contains(docOffset); + final docOffset = _interactorOffsetToDocumentOffset(interactorOffset); + return caretHitArea.contains(docOffset); } bool _isOverBaseHandle(Offset interactorOffset) { @@ -695,7 +1036,7 @@ class _IOSDocumentTouchInteractorState extends State // on trying to drag the handle from various locations near the handle. final caretRect = Rect.fromLTWH(baseRect.left - 24, baseRect.top - 24, 48, baseRect.height + 48); - final docOffset = _docLayout.getDocumentOffsetFromAncestorOffset(interactorOffset, context.findRenderObject()!); + final docOffset = _interactorOffsetToDocumentOffset(interactorOffset); return caretRect.contains(docOffset); } @@ -710,35 +1051,52 @@ class _IOSDocumentTouchInteractorState extends State // on trying to drag the handle from various locations near the handle. final caretRect = Rect.fromLTWH(extentRect.left - 24, extentRect.top, 48, extentRect.height + 32); - final docOffset = _docLayout.getDocumentOffsetFromAncestorOffset(interactorOffset, context.findRenderObject()!); + final docOffset = _interactorOffsetToDocumentOffset(interactorOffset); return caretRect.contains(docOffset); } void _onPanUpdate(DragUpdateDetails details) { - // If the user isn't dragging a handle, then the user is trying to - // scroll the document. Scroll it, accordingly. - if (_dragMode == null) { - scrollPosition.jumpTo(scrollPosition.pixels - details.delta.dy); - _positionToolbar(); - return; + if (widget.contentTapHandlers != null) { + final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); + for (final handler in widget.contentTapHandlers!) { + final result = handler.onPanUpdate( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } } - // The user is dragging a handle. Update the document selection, and - // auto-scroll, if needed. _globalDragOffset = details.globalPosition; - final interactorBox = context.findRenderObject() as RenderBox; - _dragEndInInteractor = interactorBox.globalToLocal(details.globalPosition); - final dragEndInViewport = _interactorOffsetInViewport(_dragEndInInteractor!); - _updateSelectionForNewDragHandleLocation(); + _dragEndInInteractor = interactorBox.globalToLocal(details.globalPosition); + final dragEndInViewport = _interactorOffsetToViewportOffset(_dragEndInInteractor!); + + if (_isLongPressInProgress) { + final fingerDragDelta = _globalDragOffset! - _globalStartDragOffset!; + final scrollDelta = _dragStartScrollOffset! - scrollPosition.pixels; + final fingerDocumentOffset = _docLayout.getDocumentOffsetFromAncestorOffset(details.globalPosition); + final fingerDocumentPosition = _docLayout.getDocumentPositionNearestToOffset( + _startDragPositionOffset! + fingerDragDelta - Offset(0, scrollDelta), + ); + _longPressStrategy!.onLongPressDragUpdate(fingerDocumentOffset, fingerDocumentPosition); + } else { + _updateSelectionForNewDragHandleLocation(); + } - _handleAutoScrolling.updateAutoScrollHandleMonitoring( + // Auto-scroll, if needed, for either handle dragging or long press dragging. + widget.dragHandleAutoScroller.value?.updateAutoScrollHandleMonitoring( dragEndInViewport: dragEndInViewport, ); - _editingController.showMagnifier(); - - _controlsOverlayEntry!.markNeedsBuild(); + _placeFocalPointNearTouchOffset(); } void _updateSelectionForNewDragHandleLocation() { @@ -746,75 +1104,151 @@ class _IOSDocumentTouchInteractorState extends State final dragScrollDelta = _dragStartScrollOffset! - scrollPosition.pixels; final docDragPosition = _docLayout .getDocumentPositionNearestToOffset(_startDragPositionOffset! + docDragDelta - Offset(0, dragScrollDelta)); - if (docDragPosition == null) { return; } if (_dragHandleType == HandleType.collapsed) { - widget.selection.value = DocumentSelection.collapsed( - position: docDragPosition, - ); + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: docDragPosition, + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); } else if (_dragHandleType == HandleType.upstream) { - widget.selection.value = widget.selection.value!.copyWith( - base: docDragPosition, - ); + _controlsController!.handleBeingDragged.value = HandleType.upstream; + widget.editor.execute([ + ChangeSelectionRequest( + widget.selection.value!.copyWith( + base: docDragPosition, + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); } else if (_dragHandleType == HandleType.downstream) { - widget.selection.value = widget.selection.value!.copyWith( - extent: docDragPosition, - ); + _controlsController!.handleBeingDragged.value = HandleType.downstream; + widget.editor.execute([ + ChangeSelectionRequest( + widget.selection.value!.copyWith( + extent: docDragPosition, + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); } } void _onPanEnd(DragEndDetails details) { - if (_dragMode == null) { - // User was dragging the scroll area. Go ballistic. - if (scrollPosition is ScrollPositionWithSingleContext) { - (scrollPosition as ScrollPositionWithSingleContext).goBallistic(-details.velocity.pixelsPerSecond.dy); - - // We add the scroll change listener again, because going ballistic - // seems to switch out the scroll position. - scrollPosition.addListener(_onScrollChange); + if (widget.contentTapHandlers != null) { + final docOffset = _interactorOffsetToDocumentOffset(details.localPosition); + for (final handler in widget.contentTapHandlers!) { + final result = handler.onPanEnd( + DocumentTapDetails( + documentLayout: _docLayout, + layoutOffset: docOffset, + globalOffset: details.globalPosition, + ), + ); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } } - } else { - // The user was dragging a handle. Stop any auto-scrolling that may have started. - _onHandleDragEnd(); + } + + _controlsController! + ..hideMagnifier() + ..blinkCaret() + ..handleBeingDragged.value = null; + + if (_dragMode != null) { + // The user was dragging a selection change in some way, either with handles + // or with a long-press. Finish that interaction. + _onDragSelectionEnd(); } } void _onPanCancel() { + if (widget.contentTapHandlers != null) { + for (final handler in widget.contentTapHandlers!) { + final result = handler.onPanCancel(); + if (result == TapHandlingInstruction.halt) { + // The custom tap handler doesn't want us to react at all + // to the tap. + return; + } + } + } + if (_dragMode != null) { + _onDragSelectionEnd(); + } + _controlsController!.handleBeingDragged.value = null; + } + + void _onDragSelectionEnd() { + if (_dragMode == DragMode.longPress) { + _onLongPressEnd(); + } else { _onHandleDragEnd(); } + + widget.dragHandleAutoScroller.value?.stopAutoScrollHandleMonitoring(); + scrollPosition.removeListener(_onAutoScrollChange); + } + + void _onLongPressEnd() { + _longPressStrategy!.onLongPressEnd(); + _longPressStrategy = null; + _dragMode = null; + + _updateOverlayControlsAfterFinishingDragSelection(); } void _onHandleDragEnd() { - _handleAutoScrolling.stopAutoScrollHandleMonitoring(); - scrollPosition.removeListener(_updateDragSelection); _dragMode = null; - _editingController.hideMagnifier(); + _updateOverlayControlsAfterFinishingDragSelection(); + } + + void _updateOverlayControlsAfterFinishingDragSelection() { + _controlsController!.hideMagnifier(); if (!widget.selection.value!.isCollapsed) { - _editingController.showToolbar(); - _positionToolbar(); + _controlsController!.showToolbar(); } + } - _controlsOverlayEntry!.markNeedsBuild(); + void _onAutoScrollChange() { + _updateDocumentSelectionOnAutoScrollFrame(); + _updateMagnifierFocalPointOnAutoScrollFrame(); } - void _onTapTimeout() { - setState(() { - _waitingForMoreTaps = false; - _controlsOverlayEntry?.markNeedsBuild(); - }); + void _updateMagnifierFocalPointOnAutoScrollFrame() { + if (_magnifierFocalPointInDocumentSpace.value != null) { + _placeFocalPointNearTouchOffset(); + } } - void _updateDragSelection() { + void _updateDocumentSelectionOnAutoScrollFrame() { if (_dragStartInDoc == null) { return; } - final dragEndInDoc = _interactorOffsetToDocOffset(_dragEndInInteractor!); + if (_dragHandleType == null) { + // The user is probably doing a long-press drag. Nothing for us to do here. + return; + } + + final dragEndInDoc = _interactorOffsetToDocumentOffset(_dragEndInInteractor!); final dragPosition = _docLayout.getDocumentPositionNearestToOffset(dragEndInDoc); editorGesturesLog.info("Selecting new position during drag: $dragPosition"); @@ -824,342 +1258,831 @@ class _IOSDocumentTouchInteractorState extends State late DocumentPosition basePosition; late DocumentPosition extentPosition; + late SelectionChangeType changeType; switch (_dragHandleType!) { case HandleType.collapsed: basePosition = dragPosition; extentPosition = dragPosition; + changeType = SelectionChangeType.placeCaret; break; case HandleType.upstream: basePosition = dragPosition; extentPosition = widget.selection.value!.extent; + changeType = SelectionChangeType.expandSelection; break; case HandleType.downstream: basePosition = widget.selection.value!.base; extentPosition = dragPosition; + changeType = SelectionChangeType.expandSelection; break; } - widget.selection.value = DocumentSelection( - base: basePosition, - extent: extentPosition, - ); + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: basePosition, + extent: extentPosition, + ), + changeType, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + editorGesturesLog.fine("Selected region: ${widget.selection.value}"); } - void _showEditingControlsOverlay() { - if (_controlsOverlayEntry != null) { - return; + bool _selectWordAt({ + required DocumentPosition docPosition, + required DocumentLayout docLayout, + }) { + final newSelection = getWordSelection(docPosition: docPosition, docLayout: docLayout); + if (newSelection != null) { + _select(newSelection); + return true; + } else { + return false; } + } - _controlsOverlayEntry = OverlayEntry(builder: (overlayContext) { - return IosDocumentTouchEditingControls( - editingController: _editingController, - floatingCursorController: widget.floatingCursorController, - documentLayout: _docLayout, - document: widget.document, - selection: widget.selection, - handleColor: widget.handleColor, - onDoubleTapOnCaret: _selectWordAtCaret, - onTripleTapOnCaret: _selectParagraphAtCaret, - onFloatingCursorStart: _onFloatingCursorStart, - onFloatingCursorMoved: _moveSelectionToFloatingCursor, - onFloatingCursorStop: _onFloatingCursorStop, - magnifierFocalPointOffset: _globalDragOffset, - popoverToolbarBuilder: widget.popoverToolbarBuilder, - createOverlayControlsClipper: widget.createOverlayControlsClipper, - disableGestureHandling: _waitingForMoreTaps, - showDebugPaint: false, - ); - }); + void _select(DocumentSelection newSelection) { + widget.editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + } - Overlay.of(context)!.insert(_controlsOverlayEntry!); + bool _selectParagraphAt({ + required DocumentPosition docPosition, + required DocumentLayout docLayout, + }) { + final newSelection = getParagraphSelection(docPosition: docPosition, docLayout: docLayout); + if (newSelection != null) { + widget.editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + return true; + } else { + return false; + } } - void _positionCaret() { - final extentRect = _docLayout.getRectForPosition(widget.selection.value!.extent)!; + void _onFloatingCursorStart() { + if (widget.selection.value == null) { + // The floating cursor doesn't mean anything when nothing is selected. + return; + } - _editingController.updateCaret( - top: extentRect.topLeft, - height: extentRect.height, - ); + widget.dragHandleAutoScroller.value?.startAutoScrollHandleMonitoring(); } - void _positionCollapsedHandle() { - final selection = widget.selection.value; - if (selection == null) { - editorGesturesLog.shout("Tried to update collapsed handle offset but there is no document selection"); + void _onFloatingCursorGeometryChange() { + final cursorGeometry = _controlsController!.floatingCursorController.cursorGeometryInViewport.value; + if (cursorGeometry == null) { return; } - if (!selection.isCollapsed) { - editorGesturesLog.shout("Tried to update collapsed handle offset but the selection is expanded"); - return; + + widget.dragHandleAutoScroller.value?.updateAutoScrollHandleMonitoring( + dragEndInViewport: cursorGeometry.center, + ); + } + + void _onFloatingCursorStop() { + widget.dragHandleAutoScroller.value?.stopAutoScrollHandleMonitoring(); + } + + void _selectPosition(DocumentPosition position) { + editorGesturesLog.fine("Setting document selection to $position"); + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: position, + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + } + + /// Updates the magnifier focal point in relation to the current drag position. + void _placeFocalPointNearTouchOffset() { + late DocumentPosition? docPositionToMagnify; + + if (_globalTapDownOffset != null) { + // A drag isn't happening. Magnify the position that the user tapped. + final interactorOffset = interactorBox.globalToLocal(_globalTapDownOffset!); + final tapDownDocumentOffset = _interactorOffsetToDocumentOffset(interactorOffset); + docPositionToMagnify = _docLayout.getDocumentPositionNearestToOffset(tapDownDocumentOffset); + } else { + final docDragDelta = _globalDragOffset! - _globalStartDragOffset!; + final dragScrollDelta = _dragStartScrollOffset! - scrollPosition.pixels; + docPositionToMagnify = _docLayout + .getDocumentPositionNearestToOffset(_startDragPositionOffset! + docDragDelta - Offset(0, dragScrollDelta)); } - // Calculate the new (x,y) offset for the collapsed handle. - final extentRect = _docLayout.getRectForPosition(selection.extent); - late Offset handleOffset = extentRect!.bottomLeft; + final centerOfContentAtOffset = _interactorOffsetToDocumentOffset( + _docLayout.getRectForPosition(docPositionToMagnify!)!.center, + ); - _editingController.collapsedHandleOffset = handleOffset; + _magnifierFocalPointInDocumentSpace.value = centerOfContentAtOffset; } - void _positionExpandedSelectionHandles() { + void _updateDragStartLocation(Offset globalOffset) { + _globalStartDragOffset = globalOffset; + final handleOffsetInInteractor = interactorBox.globalToLocal(globalOffset); + _dragStartInDoc = _interactorOffsetToDocumentOffset(handleOffsetInInteractor); + final selection = widget.selection.value; - if (selection == null) { - editorGesturesLog.shout("Tried to update expanded handle offsets but there is no document selection"); - return; + if (_dragHandleType != null && selection != null) { + _startDragPositionOffset = _docLayout + .getRectForPosition( + _dragHandleType == HandleType.upstream ? selection.base : selection.extent, + )! + .center; + } else { + // User is long-press dragging, which is why there's no drag handle type. + // In this case, the start drag offset is wherever the user touched. + _startDragPositionOffset = _dragStartInDoc!; } - if (selection.isCollapsed) { - editorGesturesLog.shout("Tried to update expanded handle offsets but the selection is collapsed"); - return; + + // We need to record the scroll offset at the beginning of + // a drag for the case that this interactor is embedded + // within an ancestor Scrollable. We need to use this value + // to calculate a scroll delta on every scroll frame to + // account for the fact that this interactor is moving within + // the ancestor scrollable, despite the fact that the user's + // finger/mouse position hasn't changed. + _dragStartScrollOffset = scrollPosition.pixels; + } + + @override + Widget build(BuildContext context) { + if (widget.scrollController.hasClients) { + if (widget.scrollController.positions.length > 1) { + // During Hot Reload, if the gesture mode was changed, + // the widget might be built while the old gesture interactor + // scroller is still attached to the _scrollController. + // + // Defer adding the listener to the next frame. + scheduleBuildAfterBuild(); + } } - // Calculate the new (x,y) offsets for the upstream and downstream handles. - final baseRect = _docLayout.getRectForPosition(selection.base)!; - final baseHandleOffset = baseRect.bottomLeft; + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + // PanGestureRecognizer is above contents to have first pass at gestures, but it only accepts + // gestures that are over caret or handles or when a long press is in progress. + // TapGestureRecognizer is below contents so that it doesn't interferes with buttons and other + // tappable widgets. + return SliverHybridStack( + fillViewport: widget.fillViewport, + children: [ + // Layer below + RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapCancel = _onTapCancel + ..onTapUp = _onTapUp + ..onDoubleTapUp = _onDoubleTapUp + ..onTripleTapUp = _onTripleTapUp + ..gestureSettings = gestureSettings; + }, + ), + }, + ), + widget.child, + // Layer above + RawGestureDetector( + key: _interactor, + behavior: HitTestBehavior.translucent, + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..shouldAccept = () { + if (widget.isScribbleInProgress?.value == true) { + // A Scribble/stylus writing interaction is in progress. + // Don't accept the pan so the IME can handle scribble input. + return false; + } + if (_globalTapDownOffset == null) { + return false; + } + final panDown = interactorBox.globalToLocal(_globalTapDownOffset!); + final isOverHandle = + _isOverBaseHandle(panDown) || _isOverExtentHandle(panDown) || _isOverCollapsedHandle(panDown); + final res = isOverHandle || _isLongPressInProgress; + return res; + } + ..dragStartBehavior = DragStartBehavior.down + ..onDown = _onPanDown + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + child: Stack( + children: [ + _buildMagnifierFocalPoint(), + ], + ), + ), + ], + ); + } + + Widget _buildMagnifierFocalPoint() { + return ValueListenableBuilder( + valueListenable: _magnifierFocalPointInDocumentSpace, + builder: (context, magnifierFocalPoint, child) { + if (magnifierFocalPoint == null) { + return const SizedBox(); + } - final extentRect = _docLayout.getRectForPosition(selection.extent)!; - final extentHandleOffset = extentRect.bottomRight; + // When the user is dragging a handle in this overlay, we + // are responsible for positioning the focal point for the + // magnifier to follow. We do that here. + return Positioned( + left: magnifierFocalPoint.dx, + top: magnifierFocalPoint.dy, + child: Leader( + link: _controlsController!.magnifierFocalPoint, + child: const SizedBox(width: 1, height: 1), + ), + ); + }, + ); + } +} - final affinity = widget.document.getAffinityForSelection(selection); +enum DragMode { + // Dragging the collapsed handle + collapsed, + // Dragging the base handle + base, + // Dragging the extent handle + extent, + // Dragging after a long-press, which selects by the word + // around the selected word. + longPress, +} - final upstreamHandleOffset = affinity == TextAffinity.downstream ? baseHandleOffset : extentHandleOffset; - final upstreamHandleHeight = affinity == TextAffinity.downstream ? baseRect.height : extentRect.height; +/// Adds and removes an iOS-style editor toolbar, as dictated by an ancestor +/// [SuperEditorIosControlsScope]. +class SuperEditorIosToolbarOverlayManager extends StatefulWidget { + const SuperEditorIosToolbarOverlayManager({ + super.key, + this.tapRegionGroupId, + this.defaultToolbarBuilder, + required this.child, + }); - final downstreamHandleOffset = affinity == TextAffinity.downstream ? extentHandleOffset : baseHandleOffset; - final downstreamHandleHeight = affinity == TextAffinity.downstream ? extentRect.height : baseRect.height; + /// {@macro super_editor_tap_region_group_id} + final String? tapRegionGroupId; - _editingController - ..removeCaret() - ..collapsedHandleOffset = null - ..upstreamHandleOffset = upstreamHandleOffset - ..upstreamCaretHeight = upstreamHandleHeight - ..downstreamHandleOffset = downstreamHandleOffset - ..downstreamCaretHeight = downstreamHandleHeight; - } + final DocumentFloatingToolbarBuilder? defaultToolbarBuilder; - void _positionToolbar() { - if (!_editingController.shouldDisplayToolbar) { - return; - } + final Widget child; - const toolbarGap = 24.0; - late Rect selectionRect; - Offset toolbarTopAnchor; - Offset toolbarBottomAnchor; + @override + State createState() => SuperEditorIosToolbarOverlayManagerState(); +} - final selection = widget.selection.value!; - if (selection.isCollapsed) { - final extentRectInDoc = _docLayout.getRectForPosition(selection.extent)!; - selectionRect = Rect.fromPoints( - _docLayout.getGlobalOffsetFromDocumentOffset(extentRectInDoc.topLeft), - _docLayout.getGlobalOffsetFromDocumentOffset(extentRectInDoc.bottomRight), - ); - } else { - final baseRectInDoc = _docLayout.getRectForPosition(selection.base)!; - final extentRectInDoc = _docLayout.getRectForPosition(selection.extent)!; - final selectionRectInDoc = Rect.fromPoints( - Offset( - min(baseRectInDoc.left, extentRectInDoc.left), - min(baseRectInDoc.top, extentRectInDoc.top), +@visibleForTesting +class SuperEditorIosToolbarOverlayManagerState extends State { + final OverlayPortalController _overlayPortalController = OverlayPortalController(); + SuperEditorIosControlsController? _controlsController; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsController = SuperEditorIosControlsScope.rootOf(context); + + // It's possible that `didChangeDependencies` is called during build when pushing a route + // that has a delegated transition. We need to wait until the next frame to show the overlay, + // otherwise this widget crashes, since we can't call `OverlayPortalController.show()` during build. + onNextFrame((timeStamp) { + _overlayPortalController.show(); + }); + } + + @visibleForTesting + bool get wantsToDisplayToolbar => _controlsController!.shouldShowToolbar.value; + + @override + Widget build(BuildContext context) { + return SliverHybridStack( + children: [ + widget.child, + OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: _buildToolbar, + child: const SizedBox(), ), - Offset( - max(baseRectInDoc.right, extentRectInDoc.right), - max(baseRectInDoc.bottom, extentRectInDoc.bottom), + ], + ); + } + + Widget _buildToolbar(BuildContext context) { + return TapRegion( + groupId: widget.tapRegionGroupId, + child: IosFloatingToolbarOverlay( + shouldShowToolbar: _controlsController!.shouldShowToolbar, + toolbarFocalPoint: _controlsController!.toolbarFocalPoint, + floatingToolbarBuilder: + _controlsController!.toolbarBuilder ?? widget.defaultToolbarBuilder ?? (_, __, ___) => const SizedBox(), + createOverlayControlsClipper: _controlsController!.createOverlayControlsClipper, + showDebugPaint: false, + ), + ); + } +} + +/// Adds and removes an iOS-style editor magnifier, as dictated by an ancestor +/// [SuperEditorIosControlsScope]. +class SuperEditorIosMagnifierOverlayManager extends StatefulWidget { + const SuperEditorIosMagnifierOverlayManager({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => SuperEditorIosMagnifierOverlayManagerState(); +} + +@visibleForTesting +class SuperEditorIosMagnifierOverlayManagerState extends State + with SingleTickerProviderStateMixin { + final OverlayPortalController _overlayPortalController = OverlayPortalController(); + SuperEditorIosControlsController? _controlsController; + + @visibleForTesting + bool get wantsToDisplayMagnifier => _controlsController!.shouldShowMagnifier.value; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _controlsController = SuperEditorIosControlsScope.rootOf(context); + + // It's possible that `didChangeDependencies` is called during build when pushing a route + // that has a delegated transition. We need to wait until the next frame to show the overlay, + // otherwise this widget crashes, since we can't call `OverlayPortalController.show` during build. + onNextFrame((timeStamp) { + _overlayPortalController.show(); + }); + } + + @override + Widget build(BuildContext context) { + return SliverHybridStack( + children: [ + widget.child, + OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: _buildMagnifier, + child: const SizedBox(), ), - ); - selectionRect = Rect.fromPoints( - _docLayout.getGlobalOffsetFromDocumentOffset(selectionRectInDoc.topLeft), - _docLayout.getGlobalOffsetFromDocumentOffset(selectionRectInDoc.bottomRight), - ); + ], + ); + } + + Widget _buildMagnifier(BuildContext context) { + // Display a magnifier that tracks a focal point. + // + // When the user is dragging an overlay handle, SuperEditor + // position a Leader with a LeaderLink. This magnifier follows that Leader + // via the LeaderLink. + return IgnorePointer( + child: ValueListenableBuilder( + valueListenable: _controlsController!.shouldShowMagnifier, + builder: (context, shouldShowMagnifier, child) { + return _controlsController!.magnifierBuilder != null // + ? _controlsController!.magnifierBuilder!( + context, + DocumentKeys.magnifier, + _controlsController!.magnifierFocalPoint, + shouldShowMagnifier, + ) + : _buildDefaultMagnifier( + context, + DocumentKeys.magnifier, + _controlsController!.magnifierFocalPoint, + shouldShowMagnifier, + ); + }, + ), + ); + } + + Widget _buildDefaultMagnifier( + BuildContext context, Key magnifierKey, LeaderLink magnifierFocalPoint, bool isVisible) { + if (CurrentPlatform.isWeb) { + // Defer to the browser to display overlay controls on mobile. + return const SizedBox(); } - // TODO: fix the horizontal placement - // The logic to position the toolbar horizontally is wrong. - // The toolbar should appear horizontally centered between the - // left-most and right-most edge of the selection. However, the - // left-most and right-most edge of the selection may not match - // the handle locations. Consider the situation where multiple - // lines/blocks of content are selected, but both handles sit near - // the left side of the screen. This logic will position the - // toolbar near the left side of the content, when the toolbar should - // instead be centered across the full width of the document. - toolbarTopAnchor = selectionRect.topCenter - const Offset(0, toolbarGap); - toolbarBottomAnchor = selectionRect.bottomCenter + const Offset(0, toolbarGap); - - _editingController.positionToolbar( - topAnchor: toolbarTopAnchor, - bottomAnchor: toolbarBottomAnchor, + return IOSFollowingMagnifier.roundedRectangle( + magnifierKey: magnifierKey, + leaderLink: magnifierFocalPoint, + show: isVisible, + // The magnifier is centered with the focal point. Translate it so that it sits + // above the focal point and leave a few pixels between the bottom of the magnifier + // and the focal point. This value was chosen empirically. + offsetFromFocalPoint: Offset(0, (-defaultIosMagnifierSize.height / 2) - 20), + handleColor: _controlsController!.handleColor, + ); + } +} + +/// Displays an iOS floating cursor for a document editor experience. +/// +/// An [EditorFloatingCursor] also tracks the floating cursor focal point, sets the +/// floating cursor geometry on an ancestor [SuperEditorIosControlsController], as well as +/// toggling the magnifier and toolbar, and updates the [Editor]s [DocumentSelection] +/// as the user moves the floating cursor, or scrolls the document. +/// +/// [EditorFloatingCursor] should wrap the editor's viewport (not the full document layout), +/// because the floating cursor moves around the visible area of the UI, it's position +/// is not tied directly to the document layout. +/// +/// [EditorFloatingCursor] must be a descendant of an ancestor [SuperEditorIosControlsScope]. +class EditorFloatingCursor extends StatefulWidget { + const EditorFloatingCursor({ + super.key, + required this.editor, + required this.document, + required this.getDocumentLayout, + required this.selection, + required this.scrollChangeSignal, + required this.child, + }); + + final Editor editor; + final Document document; + final DocumentLayoutResolver getDocumentLayout; + final ValueListenable selection; + final SignalNotifier scrollChangeSignal; + final Widget child; + + @override + State createState() => _EditorFloatingCursorState(); +} + +class _EditorFloatingCursorState extends State { + SuperEditorIosControlsController? _controlsContext; + late FloatingCursorListener _floatingCursorListener; + + Offset? _initialFloatingCursorOffsetInViewport; + Offset? _floatingCursorFocalPointInViewport; + Offset? _floatingCursorFocalPointInDocument; + double _floatingCursorHeight = FloatingCursorPolicies.defaultFloatingCursorHeight; + + @override + void initState() { + super.initState(); + + _floatingCursorListener = FloatingCursorListener( + onStart: _onFloatingCursorStart, + onMove: _onFloatingCursorMove, + onStop: _onFloatingCursorStop, ); + + widget.scrollChangeSignal.addListener(_onScrollChange); } - void _removeEditingOverlayControls() { - if (_controlsOverlayEntry != null) { - _controlsOverlayEntry!.remove(); - _controlsOverlayEntry = null; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (_controlsContext != null) { + _controlsContext!.floatingCursorController.removeListener(_floatingCursorListener); } + _controlsContext = SuperEditorIosControlsScope.rootOf(context); + _controlsContext!.floatingCursorController.addListener(_floatingCursorListener); } - void _selectWordAtCaret() { - final docSelection = widget.selection.value; - if (docSelection == null) { + @override + void didUpdateWidget(EditorFloatingCursor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.scrollChangeSignal != oldWidget.scrollChangeSignal) { + oldWidget.scrollChangeSignal.removeListener(_onScrollChange); + widget.scrollChangeSignal.addListener(_onScrollChange); + } + } + + @override + void dispose() { + widget.scrollChangeSignal.removeListener(_onScrollChange); + + super.dispose(); + } + + /// Returns the layout for the current document, which answers questions + /// about the locations and sizes of visual components within the layout. + DocumentLayout get _docLayout => widget.getDocumentLayout(); + + /// Returns the `RenderBox` for the scrolling viewport. + /// + /// This widget expects to wrap the viewport, so this widget's box is the same + /// place and size as the actual viewport. + RenderBox get viewportBox => context.findViewportBox(); + + Offset _documentOffsetToViewportOffset(Offset documentOffset) { + final globalOffset = _docLayout.getGlobalOffsetFromDocumentOffset(documentOffset); + return viewportBox.globalToLocal(globalOffset); + } + + Offset _viewportOffsetToDocumentOffset(Offset viewportOffset) { + final globalOffset = viewportBox.localToGlobal(viewportOffset); + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); + } + + void _onFloatingCursorStart() { + editorIosFloatingCursorLog.fine("Floating cursor started."); + if (widget.selection.value == null) { + // The floating cursor doesn't mean anything when nothing is selected. return; } - _selectWordAt( - docPosition: docSelection.extent, - docLayout: _docLayout, - ); + final initialSelectionExtent = widget.selection.value!.extent; + final nearestPositionRect = _docLayout.getRectForPosition(initialSelectionExtent)!; + final verticalCenterOfCaret = nearestPositionRect.center; + final initialFloatingCursorOffsetInDocument = verticalCenterOfCaret + const Offset(-1, 0); + _initialFloatingCursorOffsetInViewport = _documentOffsetToViewportOffset(initialFloatingCursorOffsetInDocument); + _floatingCursorFocalPointInViewport = _initialFloatingCursorOffsetInViewport!; + _floatingCursorFocalPointInDocument = _viewportOffsetToDocumentOffset(_floatingCursorFocalPointInViewport!); + + _controlsContext!.hideToolbar(); + _controlsContext!.hideMagnifier(); + + _updateFloatingCursorGeometryForCurrentFloatingCursorFocalPoint(); } - bool _selectWordAt({ - required DocumentPosition docPosition, - required DocumentLayout docLayout, - }) { - final newSelection = getWordSelection(docPosition: docPosition, docLayout: docLayout); - if (newSelection != null) { - widget.selection.value = newSelection; - return true; - } else { - return false; + void _onFloatingCursorMove(Offset? offset) { + editorIosFloatingCursorLog.finer("Floating cursor moved: $offset"); + if (offset == null) { + return; } + + if (widget.selection.value == null) { + // The floating cursor doesn't mean anything when nothing is selected. + return; + } + if (!widget.selection.value!.isCollapsed) { + // This shouldn't happen. An expanded selection should be collapsed for + // we get to movement methods. + editorIosFloatingCursorLog + .shout("Floating cursor move reported with an expanded selection. The selection should be collapsed!"); + } + + // Update our floating cursor focal point trackers. + final cursorViewportFocalPointUnbounded = _initialFloatingCursorOffsetInViewport! + offset; + editorIosFloatingCursorLog.finer(" - unbounded cursor focal point: $cursorViewportFocalPointUnbounded"); + + final viewportHeight = viewportBox.size.height; + _floatingCursorFocalPointInViewport = + Offset(cursorViewportFocalPointUnbounded.dx, cursorViewportFocalPointUnbounded.dy.clamp(0, viewportHeight)); + editorIosFloatingCursorLog.finer(" - bounded cursor focal point: $_floatingCursorFocalPointInViewport"); + + _floatingCursorFocalPointInDocument = _viewportOffsetToDocumentOffset(_floatingCursorFocalPointInViewport!); + editorIosFloatingCursorLog.finer(" - floating cursor offset in document: $_floatingCursorFocalPointInDocument"); + + // Calculate an updated floating cursor rectangle and document selection. + _updateFloatingCursorGeometryForCurrentFloatingCursorFocalPoint(); + _selectPositionUnderFloatingCursor(); } - void _selectParagraphAtCaret() { - final docSelection = widget.selection.value; - if (docSelection == null) { + void _onScrollChange() { + if (!_controlsContext!.floatingCursorController.isActive.value) { return; } - _selectParagraphAt( - docPosition: docSelection.extent, - docLayout: _docLayout, - ); + _updateFloatingCursorGeometryForCurrentFloatingCursorFocalPoint(); + _selectPositionUnderFloatingCursor(); } - bool _selectParagraphAt({ - required DocumentPosition docPosition, - required DocumentLayout docLayout, - }) { - final newSelection = getParagraphSelection(docPosition: docPosition, docLayout: docLayout); - if (newSelection != null) { - widget.selection.value = newSelection; - return true; + /// Updates the offset and height of the floating cursor, based on the current + /// floating cursor focal point. + /// + /// If anything impacted the focal point, such as user movement, or scroll changes, + /// those changes must be made to the focal point before calling this method. This + /// method doesn't update or alter the focal point. + void _updateFloatingCursorGeometryForCurrentFloatingCursorFocalPoint() { + final focalPointInDocument = _viewportOffsetToDocumentOffset(_floatingCursorFocalPointInViewport!); + final nearestDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(focalPointInDocument)!; + editorIosFloatingCursorLog.finer(" - nearest position to floating cursor: $nearestDocumentPosition"); + + if (nearestDocumentPosition.nodePosition is TextNodePosition) { + final nearestPositionRect = _docLayout.getRectForPosition(nearestDocumentPosition)!; + _floatingCursorHeight = nearestPositionRect.height; + + final distance = _floatingCursorFocalPointInDocument! - nearestPositionRect.topLeft + const Offset(1.0, 0.0); + _controlsContext!.floatingCursorController.isNearText.value = + distance.dx.abs() <= FloatingCursorPolicies.maximumDistanceToBeNearText; } else { - return false; + final nearestComponent = _docLayout.getComponentByNodeId(nearestDocumentPosition.nodeId)!; + _floatingCursorHeight = (nearestComponent.context.findRenderObject() as RenderBox).size.height; + _controlsContext!.floatingCursorController.isNearText.value = false; } - } - void _onFloatingCursorStart() { - _handleAutoScrolling.startAutoScrollHandleMonitoring(); + _controlsContext!.floatingCursorController.cursorGeometryInViewport.value = Rect.fromLTWH( + _floatingCursorFocalPointInViewport!.dx, + _floatingCursorFocalPointInViewport!.dy - (_floatingCursorHeight / 2), + FloatingCursorPolicies.defaultFloatingCursorWidth, + _floatingCursorHeight, + ); + + _controlsContext!.floatingCursorController.cursorGeometryInDocument.value = Rect.fromLTWH( + _floatingCursorFocalPointInDocument!.dx, + _floatingCursorFocalPointInDocument!.dy - (_floatingCursorHeight / 2), + FloatingCursorPolicies.defaultFloatingCursorWidth, + _floatingCursorHeight, + ); + + editorIosFloatingCursorLog.finer( + "Set floating cursor geometry to: ${_controlsContext!.floatingCursorController.cursorGeometryInViewport.value}"); } - void _moveSelectionToFloatingCursor(Offset documentOffset) { - final nearestDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(documentOffset)!; + /// Inspects the viewport focal point offset of the floating cursor, finds the nearest position + /// in the document, and moves the selection to that position. + void _selectPositionUnderFloatingCursor() { + editorIosFloatingCursorLog.finer("Updating document selection based on floating cursor focal point."); + final floatingCursorRectInViewport = _controlsContext!.floatingCursorController.cursorGeometryInViewport.value; + if (floatingCursorRectInViewport == null) { + editorIosFloatingCursorLog.finer(" - the floating cursor rect is null. Not selecting anything."); + return; + } + + final nearestDocumentPosition = _docLayout + .getDocumentPositionNearestToOffset(_viewportOffsetToDocumentOffset(floatingCursorRectInViewport.center))!; + + editorIosFloatingCursorLog.finer(" - selecting nearest position: $nearestDocumentPosition"); _selectPosition(nearestDocumentPosition); - _handleAutoScrolling.updateAutoScrollHandleMonitoring( - dragEndInViewport: _docOffsetToInteractorOffset(documentOffset), - ); + } + + void _selectPosition(DocumentPosition position) { + widget.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: position, + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); } void _onFloatingCursorStop() { - _handleAutoScrolling.stopAutoScrollHandleMonitoring(); + editorIosFloatingCursorLog.fine("Floating cursor stopped."); + _controlsContext!.floatingCursorController.isNearText.value = false; + _controlsContext!.floatingCursorController.cursorGeometryInViewport.value = null; + _controlsContext!.floatingCursorController.cursorGeometryInDocument.value = null; + + _floatingCursorFocalPointInDocument = null; + _floatingCursorFocalPointInViewport = null; + _floatingCursorHeight = FloatingCursorPolicies.defaultFloatingCursorHeight; } - void _selectPosition(DocumentPosition position) { - editorGesturesLog.fine("Setting document selection to $position"); - widget.selection.value = DocumentSelection.collapsed( - position: position, + @override + Widget build(BuildContext context) { + return SliverHybridStack( + children: [ + widget.child, + Stack( + clipBehavior: Clip.none, + children: [ + _buildFloatingCursor(), + ], + ) + ], ); } - ScrollableState? _findAncestorScrollable(BuildContext context) { - final ancestorScrollable = Scrollable.of(context); - if (ancestorScrollable == null) { - return null; - } - - final direction = ancestorScrollable.axisDirection; - // If the direction is horizontal, then we are inside a widget like a TabBar - // or a horizontal ListView, so we can't use the ancestor scrollable - if (direction == AxisDirection.left || direction == AxisDirection.right) { - return null; - } + Widget _buildFloatingCursor() { + return ValueListenableBuilder( + valueListenable: _controlsContext!.floatingCursorController.cursorGeometryInDocument, + builder: (context, floatingCursorRect, child) { + if (floatingCursorRect == null) { + return const SizedBox(); + } - return ancestorScrollable; + return Positioned.fromRect( + rect: floatingCursorRect, + child: IgnorePointer( + child: ColoredBox( + color: Colors.red.withValues(alpha: 0.75), + ), + ), + ); + }, + ); } +} + +/// A [SuperEditorDocumentLayerBuilder] that builds a [IosToolbarFocalPointDocumentLayer], which +/// positions a `Leader` widget around the document selection, as a focal point for an +/// iOS floating toolbar. +class SuperEditorIosToolbarFocalPointDocumentLayerBuilder implements SuperEditorLayerBuilder { + const SuperEditorIosToolbarFocalPointDocumentLayerBuilder({ + // ignore: unused_element + this.showDebugLeaderBounds = false, + }); + + /// Whether to paint colorful bounds around the leader widget. + final bool showDebugLeaderBounds; @override - Widget build(BuildContext context) { - if (_scrollController.hasClients) { - if (_scrollController.positions.length > 1) { - // During Hot Reload, if the gesture mode was changed, - // the widget might be built while the old gesture interactor - // scroller is still attached to the _scrollController. - // - // Defer adding the listener to the next frame. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - setState(() {}); - }); - } else { - if (scrollPosition != _activeScrollPosition) { - _activeScrollPosition = scrollPosition; - _activeScrollPosition?.addListener(_onScrollChange); - } - } + ContentLayerWidget build(BuildContext context, SuperEditorContext editorContext) { + if (defaultTargetPlatform != TargetPlatform.iOS || SuperEditorIosControlsScope.maybeNearestOf(context) == null) { + // There's no controls scope. This probably means SuperEditor is configured with + // a non-iOS gesture mode. Build nothing. + return const ContentLayerProxyWidget(child: EmptyBox()); } - return _buildGestureInput( - child: ScrollableDocument( - scrollController: _scrollController, - disableDragScrolling: true, - documentLayerLink: _documentLayerLink, - child: widget.child, - ), + return IosToolbarFocalPointDocumentLayer( + document: editorContext.document, + selection: editorContext.composer.selectionNotifier, + toolbarFocalPointLink: SuperEditorIosControlsScope.rootOf(context).toolbarFocalPoint, + showDebugLeaderBounds: showDebugLeaderBounds, ); } +} - Widget _buildGestureInput({ - required Widget child, - }) { - return RawGestureDetector( - behavior: HitTestBehavior.opaque, - gestures: { - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapUp = _onTapUp - ..onDoubleTapUp = _onDoubleTapUp - ..onTripleTapUp = _onTripleTapUp - ..onTimeout = _onTapTimeout; - }, - ), - // We use a VerticalDragGestureRecognizer instead of a PanGestureRecognizer - // because `Scrollable` also uses a VerticalDragGestureRecognizer and we - // need to beat out any ancestor `Scrollable` in the gesture arena. - VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => VerticalDragGestureRecognizer(), - (VerticalDragGestureRecognizer instance) { - instance - ..dragStartBehavior = DragStartBehavior.down - ..onDown = _onPanDown - ..onStart = _onPanStart - ..onUpdate = _onPanUpdate - ..onEnd = _onPanEnd - ..onCancel = _onPanCancel; - }, - ), +/// A [SuperEditorLayerBuilder], which builds a [IosHandlesDocumentLayer], +/// which displays iOS-style caret and handles. +class SuperEditorIosHandlesDocumentLayerBuilder implements SuperEditorLayerBuilder { + const SuperEditorIosHandlesDocumentLayerBuilder({ + this.handleColor, + this.caretWidth, + this.handleBallDiameter, + }); + + final Color? handleColor; + final double? caretWidth; + + /// The diameter of the small circle that appears on the top and bottom of + /// expanded iOS text handles. + final double? handleBallDiameter; + + @override + ContentLayerWidget build(BuildContext context, SuperEditorContext editContext) { + if (defaultTargetPlatform != TargetPlatform.iOS || SuperEditorIosControlsScope.maybeNearestOf(context) == null) { + // There's no controls scope. This probably means SuperEditor is configured with + // a non-iOS gesture mode. Build nothing. + return const ContentLayerProxyWidget(child: EmptyBox()); + } + + final controlsController = SuperEditorIosControlsScope.rootOf(context); + + return IosHandlesDocumentLayer( + document: editContext.document, + documentLayout: editContext.documentLayout, + selection: editContext.composer.selectionNotifier, + changeSelection: (newSelection, changeType, reason) { + editContext.editor.execute([ + ChangeSelectionRequest(newSelection, changeType, reason), + const ClearComposingRegionRequest(), + ]); }, - child: child, + areSelectionHandlesAllowed: controlsController.areSelectionHandlesAllowed, + handleBeingDragged: controlsController.handleBeingDragged, + handleColor: handleColor ?? controlsController.handleColor ?? Theme.of(context).primaryColor, + caretWidth: caretWidth ?? 2, + handleBallDiameter: handleBallDiameter ?? defaultIosHandleBallDiameter, + shouldCaretBlink: controlsController.shouldCaretBlink, + floatingCursorController: controlsController.floatingCursorController, ); } } -enum DragMode { - // Dragging the collapsed handle - collapsed, - // Dragging the base handle - base, - // Dragging the extent handle - extent, -} +const defaultIosMagnifierEnterAnimationDuration = Duration(milliseconds: 180); +const defaultIosMagnifierExitAnimationDuration = Duration(milliseconds: 150); +const defaultIosMagnifierAnimationCurve = Curves.easeInOut; +const defaultIosMagnifierSize = Size(133, 96); + +/// The diameter of the small circle that appears on the top and bottom of +/// expanded iOS text handles in dip. +const defaultIosHandleBallDiameter = 16.0; diff --git a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_input_keyboard.dart b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_input_keyboard.dart new file mode 100644 index 0000000000..eccfe9d7f2 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_input_keyboard.dart @@ -0,0 +1,10 @@ +export 'document_physical_keyboard.dart'; +export 'document_keyboard_actions.dart'; + +/// This file exports various document hardware keyboard tools. +/// +/// A document editor might be configured to work exclusively with +/// hardware keyboard keys. Or, what's more likely, is that a document +/// editor is configured to process Input Method Engine (IME) input +/// for most content editing, but the editor still receives and processes +/// hardware key events for things like arrow keys, tab keys, etc. diff --git a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart new file mode 100644 index 0000000000..ffca2c033a --- /dev/null +++ b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart @@ -0,0 +1,1031 @@ +import 'dart:math'; + +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; + +/// Scrolls up by the viewport height, or as high as possible, +/// when the user presses the Page Up key. +ExecutionInstruction scrollOnPageUpKeyPress({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey.keyId != LogicalKeyboardKey.pageUp.keyId) { + return ExecutionInstruction.continueExecution; + } + + final scroller = editContext.scroller; + + scroller.animateTo( + max(scroller.scrollOffset - scroller.viewportDimension, scroller.minScrollExtent), + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return ExecutionInstruction.haltExecution; +} + +/// Scrolls down by the viewport height, or as far as possible, +/// when the user presses the Page Down key. +ExecutionInstruction scrollOnPageDownKeyPress({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey.keyId != LogicalKeyboardKey.pageDown.keyId) { + return ExecutionInstruction.continueExecution; + } + + final scroller = editContext.scroller; + + scroller.animateTo( + min(scroller.scrollOffset + scroller.viewportDimension, scroller.maxScrollExtent), + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return ExecutionInstruction.haltExecution; +} + +/// Scrolls the viewport to the top of the content, when the user presses +/// CMD + HOME on Mac, or CTRL + HOME on all other platforms. +ExecutionInstruction scrollOnCtrlOrCmdAndHomeKeyPress({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.home) { + return ExecutionInstruction.continueExecution; + } + + if (CurrentPlatform.isApple && !HardwareKeyboard.instance.isMetaPressed) { + return ExecutionInstruction.continueExecution; + } + + if (!CurrentPlatform.isApple && !HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + + final scroller = editContext.scroller; + + scroller.animateTo( + scroller.minScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return ExecutionInstruction.haltExecution; +} + +/// Scrolls the viewport to the bottom of the content, when the user presses +/// CMD + END on Mac, or CTRL + END on all other platforms. +ExecutionInstruction scrollOnCtrlOrCmdAndEndKeyPress({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.end) { + return ExecutionInstruction.continueExecution; + } + + if (CurrentPlatform.isApple && !HardwareKeyboard.instance.isMetaPressed) { + return ExecutionInstruction.continueExecution; + } + + if (!CurrentPlatform.isApple && !HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + + final scroller = editContext.scroller; + + if (!scroller.maxScrollExtent.isFinite) { + // Can't scroll to infinity, but we technically handled the task. + return ExecutionInstruction.haltExecution; + } + + scroller.animateTo( + scroller.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return ExecutionInstruction.haltExecution; +} + +/// Halt execution of the current key event if the key pressed is one of +/// the functions keys (F1, F2, F3, etc.), or the Page Up/Down, Home/End key. +/// +/// Without this action in place pressing one of the above mentioned keys +/// would display an unknown '?' character in the document. +ExecutionInstruction blockControlKeys({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent.logicalKey == LogicalKeyboardKey.escape || + keyEvent.logicalKey == LogicalKeyboardKey.pageUp || + keyEvent.logicalKey == LogicalKeyboardKey.pageDown || + keyEvent.logicalKey == LogicalKeyboardKey.home || + keyEvent.logicalKey == LogicalKeyboardKey.end || + (keyEvent.logicalKey.keyId >= LogicalKeyboardKey.f1.keyId && + keyEvent.logicalKey.keyId <= LogicalKeyboardKey.f23.keyId)) { + return ExecutionInstruction.haltExecution; + } + + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction toggleInteractionModeWhenCmdOrCtrlPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent.isPrimaryShortcutKeyPressed && !editContext.composer.isInInteractionMode.value) { + editorKeyLog.fine("Activating editor interaction mode"); + editContext.editor.execute([ + const ChangeInteractionModeRequest( + isInteractionModeDesired: true, + ), + ]); + } else if (editContext.composer.isInInteractionMode.value) { + editorKeyLog.fine("De-activating editor interaction mode"); + editContext.editor.execute([ + const ChangeInteractionModeRequest( + isInteractionModeDesired: false, + ), + ]); + } + + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction doNothingWhenThereIsNoSelection({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (editContext.composer.selection == null) { + return ExecutionInstruction.haltExecution; + } else { + return ExecutionInstruction.continueExecution; + } +} + +ExecutionInstruction sendKeyEventToMacOs({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (defaultTargetPlatform == TargetPlatform.macOS && !CurrentPlatform.isWeb) { + // On macOS, we let the IME handle all key events. Then, the IME might generate + // selectors which express the user intent, e.g, moveLeftAndModifySelection:. + // + // For the full list of selectors handled by SuperEditor, see the MacOsSelectors class. + // + // This is needed for the interaction with the accent panel to work. + return ExecutionInstruction.blocked; + } + + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteDownstreamCharacterWithCtrlDeleteOnMac({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (!CurrentPlatform.isApple) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.delete || !HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + + final didDelete = editContext.commonOps.deleteDownstream(); + + return didDelete ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; +} + +ExecutionInstruction pasteWhenCmdVIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyV) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + editContext.commonOps.paste(); + + return ExecutionInstruction.haltExecution; +} + +ExecutionInstruction selectAllWhenCmdAIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyA) { + return ExecutionInstruction.continueExecution; + } + + final didSelectAll = editContext.commonOps.selectAll(); + return didSelectAll ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; +} + +ExecutionInstruction copyWhenCmdCIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyC) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection!.isCollapsed) { + // Nothing to copy, but we technically handled the task. + return ExecutionInstruction.haltExecution; + } + + editContext.commonOps.copy(); + + return ExecutionInstruction.haltExecution; +} + +ExecutionInstruction cutWhenCmdXIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyX) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection!.isCollapsed) { + // Nothing to cut, but we technically handled the task. + return ExecutionInstruction.haltExecution; + } + + editContext.commonOps.cut(); + + return ExecutionInstruction.haltExecution; +} + +ExecutionInstruction cmdBToToggleBold({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyB) { + return ExecutionInstruction.continueExecution; + } + + if (editContext.composer.selection!.isCollapsed) { + editContext.commonOps.toggleComposerAttributions({boldAttribution}); + return ExecutionInstruction.haltExecution; + } else { + editContext.commonOps.toggleAttributionsOnSelection({boldAttribution}); + return ExecutionInstruction.haltExecution; + } +} + +ExecutionInstruction cmdIToToggleItalics({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyI) { + return ExecutionInstruction.continueExecution; + } + + if (editContext.composer.selection!.isCollapsed) { + editContext.commonOps.toggleComposerAttributions({italicsAttribution}); + return ExecutionInstruction.haltExecution; + } else { + editContext.commonOps.toggleAttributionsOnSelection({italicsAttribution}); + return ExecutionInstruction.haltExecution; + } +} + +ExecutionInstruction anyCharacterOrDestructiveKeyToDeleteSelection({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (editContext.composer.selection == null || editContext.composer.selection!.isCollapsed) { + return ExecutionInstruction.continueExecution; + } + + // Do nothing if CMD or CTRL are pressed because this signifies an attempted + // shortcut. + if (HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) { + return ExecutionInstruction.continueExecution; + } + + // Flutter reports a character for ESC, but we don't want to add a character + // for ESC. Ignore this key press + if (keyEvent.logicalKey == LogicalKeyboardKey.escape) { + return ExecutionInstruction.continueExecution; + } + + // Specifically exclude situations where shift is pressed because shift + // needs to alter the selection, not delete content. We have to explicitly + // look for this because when shift is pressed along with an arrow key, + // Flutter reports a non-null character. + if (HardwareKeyboard.instance.isShiftPressed) { + return ExecutionInstruction.continueExecution; + } + + final isDestructiveKey = + keyEvent.logicalKey == LogicalKeyboardKey.backspace || keyEvent.logicalKey == LogicalKeyboardKey.delete; + final isCharacterKey = + keyEvent.character != null && keyEvent.character != '' && !isKeyEventCharacterBlacklisted(keyEvent.character); + + final shouldDeleteSelection = isDestructiveKey || isCharacterKey; + if (!shouldDeleteSelection) { + return ExecutionInstruction.continueExecution; + } + + editContext.commonOps.deleteSelection( + keyEvent.logicalKey == LogicalKeyboardKey.backspace ? TextAffinity.upstream : TextAffinity.downstream, + ); + + if (isCharacterKey) { + // We continue handler execution even though we deleted the selection. + // If the user pressed a character key, we want to let the character entry + // behavior run. + return ExecutionInstruction.continueExecution; + } + + // We deleted a selection in response to an explicit deletion key, e.g., + // BACKSPACE or DELETE. We don't want any other handlers to respond to + // this key. + return ExecutionInstruction.haltExecution; +} + +ExecutionInstruction deleteUpstreamContentWithBackspace({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + + final didDelete = editContext.commonOps.deleteUpstream(); + + return didDelete ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; +} + +ExecutionInstruction mergeNodeWithNextWhenDeleteIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.delete) { + return ExecutionInstruction.continueExecution; + } + + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); + if (node is! TextNode) { + return ExecutionInstruction.continueExecution; + } + + final nextNode = editContext.document.getNodeAfter(node); + if (nextNode == null) { + return ExecutionInstruction.continueExecution; + } + if (nextNode is! TextNode) { + return ExecutionInstruction.continueExecution; + } + + final currentParagraphLength = node.text.length; + + // Send edit command. + editContext.editor.execute([ + CombineParagraphsRequest( + firstNodeId: node.id, + secondNodeId: nextNode.id, + ), + // Place the cursor at the point where the text came together. + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: node.id, + nodePosition: TextNodePosition(offset: currentParagraphLength), + ), + ), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ]); + + return ExecutionInstruction.haltExecution; +} + +ExecutionInstruction moveUpAndDownWithArrowKeys({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + const arrowKeys = [ + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + ]; + if (!arrowKeys.contains(keyEvent.logicalKey)) { + return ExecutionInstruction.continueExecution; + } + + if (CurrentPlatform.isWeb && (editContext.composer.composingRegion.value != null)) { + // We are composing a character on web. It's possible that a native element is being displayed, + // like an emoji picker or a character selection panel. + // We need to let the OS handle the key so the user can navigate + // on the list of possible characters. + // TODO: update this after https://github.com/flutter/flutter/issues/134268 is resolved. + return ExecutionInstruction.blocked; + } + + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform == TargetPlatform.linux && HardwareKeyboard.instance.isAltPressed) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + if (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp) { + if (CurrentPlatform.isApple && HardwareKeyboard.instance.isAltPressed) { + didMove = editContext.commonOps.moveCaretUpstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: MovementModifier.paragraph, + ); + } else if (CurrentPlatform.isApple && HardwareKeyboard.instance.isMetaPressed) { + didMove = + editContext.commonOps.moveSelectionToBeginningOfDocument(expand: HardwareKeyboard.instance.isShiftPressed); + } else { + didMove = editContext.commonOps.moveCaretUp(expand: HardwareKeyboard.instance.isShiftPressed); + } + } else { + if (CurrentPlatform.isApple && HardwareKeyboard.instance.isAltPressed) { + didMove = editContext.commonOps.moveCaretDownstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: MovementModifier.paragraph, + ); + } else if (CurrentPlatform.isApple && HardwareKeyboard.instance.isMetaPressed) { + didMove = editContext.commonOps.moveSelectionToEndOfDocument(expand: HardwareKeyboard.instance.isShiftPressed); + } else { + didMove = editContext.commonOps.moveCaretDown(expand: HardwareKeyboard.instance.isShiftPressed); + } + } + + return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; +} + +ExecutionInstruction moveLeftAndRightWithArrowKeys({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + const arrowKeys = [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + ]; + if (!arrowKeys.contains(keyEvent.logicalKey)) { + return ExecutionInstruction.continueExecution; + } + + if (CurrentPlatform.isWeb && (editContext.composer.composingRegion.value != null)) { + // We are composing a character on web. It's possible that a native element is being displayed, + // like an emoji picker or a character selection panel. + // We need to let the OS handle the key so the user can navigate + // on the list of possible characters. + // TODO: update this after https://github.com/flutter/flutter/issues/134268 is resolved. + return ExecutionInstruction.blocked; + } + + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + MovementModifier? movementModifier; + if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && + HardwareKeyboard.instance.isControlPressed) { + movementModifier = MovementModifier.word; + } else if (CurrentPlatform.isApple && HardwareKeyboard.instance.isMetaPressed) { + movementModifier = MovementModifier.line; + } else if (CurrentPlatform.isApple && HardwareKeyboard.instance.isAltPressed) { + movementModifier = MovementModifier.word; + } + + if (keyEvent.logicalKey == LogicalKeyboardKey.arrowLeft) { + // Move the caret left/upstream. + didMove = editContext.commonOps.moveCaretUpstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: movementModifier, + ); + } else { + // Move the caret right/downstream. + didMove = editContext.commonOps.moveCaretDownstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: movementModifier, + ); + } + + return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; +} + +ExecutionInstruction doNothingWithLeftRightArrowKeysAtMiddleOfTextOnWeb({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (!CurrentPlatform.isWeb) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + const arrowKeys = [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + ]; + if (!arrowKeys.contains(keyEvent.logicalKey)) { + return ExecutionInstruction.continueExecution; + } + + if ((defaultTargetPlatform == TargetPlatform.windows || CurrentPlatform.isApple) && + HardwareKeyboard.instance.isAltPressed) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform == TargetPlatform.linux && + HardwareKeyboard.instance.isAltPressed && + (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp || keyEvent.logicalKey == LogicalKeyboardKey.arrowDown)) { + return ExecutionInstruction.continueExecution; + } + + // On web, pressing left or right arrow keys generates non-text deltas. + // We handle those deltas to change the selection. However, if the caret sits at the beginning + // or end of a node, pressing these arrow keys doesn't generate any deltas. + // Therefore, we need to handle the key events to move the selection to the previous/next node. + + final currentExtent = editContext.composer.selection!.extent; + final nodeId = currentExtent.nodeId; + final node = editContext.document.getNodeById(nodeId); + if (node == null) { + return ExecutionInstruction.continueExecution; + } + + if (node is! TextNode) { + return ExecutionInstruction.continueExecution; + } + + if (currentExtent.nodePosition is! TextNodePosition) { + return ExecutionInstruction.continueExecution; + } + + final textNodePosition = currentExtent.nodePosition as TextNodePosition; + if (keyEvent.logicalKey == LogicalKeyboardKey.arrowLeft && textNodePosition.offset > 0) { + // We are not at the beginning of the node. + // Let the IME handle the key event. + return ExecutionInstruction.blocked; + } + + if (keyEvent.logicalKey == LogicalKeyboardKey.arrowRight && textNodePosition.offset < node.text.length) { + // We are not at the end of the node. + // Let the IME handle the key event. + return ExecutionInstruction.blocked; + } + + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction moveToStartOrEndOfLineWithArrowKeysOnWeb({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (!CurrentPlatform.isWeb) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + const arrowKeys = [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + ]; + if (!arrowKeys.contains(keyEvent.logicalKey)) { + return ExecutionInstruction.continueExecution; + } + + if ((CurrentPlatform.isApple && !HardwareKeyboard.instance.isMetaPressed) || + (const [TargetPlatform.windows, TargetPlatform.linux].contains(defaultTargetPlatform) && + !HardwareKeyboard.instance.isControlPressed)) { + // CMD or CTRL is not pressed. + return ExecutionInstruction.continueExecution; + } + + // Pressing the arrow keys generates non-text deltas on web. However, when pressing CMD + RIGHT + // to move the selection to the end of the line, the selection change sometimes reports an offset which + // isn't at the end of the line. This seems to be an issue where our text layout is different + // from the browser's text layout. For example, the browser breaks the line at a different point + // than we do, so pressing CMD + RIGHT moves the selection to where the browser thinks the end of + // the line is. + // + // See https://github.com/superlistapp/super_editor/issues/2304 for more information. + // + // Move the selection mannually. + + bool didMove; + if (keyEvent.logicalKey == LogicalKeyboardKey.arrowLeft) { + // Move the caret left/upstream. + didMove = editContext.commonOps.moveCaretUpstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: MovementModifier.line, + ); + } else { + // Move the caret right/downstream. + didMove = editContext.commonOps.moveCaretDownstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: MovementModifier.line, + ); + } + + return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; +} + +ExecutionInstruction moveToLineStartOrEndWithCtrlAOrE({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform == TargetPlatform.macOS) { + return ExecutionInstruction.continueExecution; + } + + if (!HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + bool didMove = false; + + if (keyEvent.logicalKey == LogicalKeyboardKey.keyA) { + didMove = editContext.commonOps.moveCaretUpstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: MovementModifier.line, + ); + } + + if (keyEvent.logicalKey == LogicalKeyboardKey.keyE) { + didMove = editContext.commonOps.moveCaretDownstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: MovementModifier.line, + ); + } + + return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; +} + +ExecutionInstruction moveToLineStartWithHome({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + if (keyEvent.logicalKey == LogicalKeyboardKey.home) { + didMove = editContext.commonOps.moveCaretUpstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: MovementModifier.line, + ); + } + + return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; +} + +ExecutionInstruction moveToLineEndWithEnd({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + if (keyEvent.logicalKey == LogicalKeyboardKey.end) { + didMove = editContext.commonOps.moveCaretDownstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: MovementModifier.line, + ); + } + + return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteToStartOfLineWithCmdBackspaceOnMac({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!CurrentPlatform.isApple) { + return ExecutionInstruction.continueExecution; + } + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + + didMove = editContext.commonOps.moveCaretUpstream( + expand: true, + movementModifier: MovementModifier.line, + ); + + if (didMove) { + return editContext.commonOps.deleteSelection(TextAffinity.upstream) + ? ExecutionInstruction.haltExecution + : ExecutionInstruction.continueExecution; + } + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteToEndOfLineWithCmdDeleteOnMac({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!CurrentPlatform.isApple) { + return ExecutionInstruction.continueExecution; + } + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.delete) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + + didMove = editContext.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.line, + ); + + if (didMove) { + return editContext.commonOps.deleteSelection(TextAffinity.downstream) + ? ExecutionInstruction.haltExecution + : ExecutionInstruction.continueExecution; + } + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteWordUpstreamWithAltBackspaceOnMac({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!CurrentPlatform.isApple) { + return ExecutionInstruction.continueExecution; + } + if (!HardwareKeyboard.instance.isAltPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + + didMove = editContext.commonOps.moveCaretUpstream( + expand: true, + movementModifier: MovementModifier.word, + ); + + if (didMove) { + return editContext.commonOps.deleteSelection(TextAffinity.upstream) + ? ExecutionInstruction.haltExecution + : ExecutionInstruction.continueExecution; + } + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteWordUpstreamWithControlBackspaceOnWindowsAndLinux({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { + return ExecutionInstruction.continueExecution; + } + if (!HardwareKeyboard.instance.isControlPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + + didMove = editContext.commonOps.moveCaretUpstream( + expand: true, + movementModifier: MovementModifier.word, + ); + + if (didMove) { + return editContext.commonOps.deleteSelection(TextAffinity.upstream) + ? ExecutionInstruction.haltExecution + : ExecutionInstruction.continueExecution; + } + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteWordDownstreamWithAltDeleteOnMac({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!CurrentPlatform.isApple) { + return ExecutionInstruction.continueExecution; + } + if (!HardwareKeyboard.instance.isAltPressed || keyEvent.logicalKey != LogicalKeyboardKey.delete) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + + didMove = editContext.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.word, + ); + + if (didMove) { + return editContext.commonOps.deleteSelection(TextAffinity.downstream) + ? ExecutionInstruction.haltExecution + : ExecutionInstruction.continueExecution; + } + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteWordDownstreamWithControlDeleteOnWindowsAndLinux({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { + return ExecutionInstruction.continueExecution; + } + if (!HardwareKeyboard.instance.isControlPressed || keyEvent.logicalKey != LogicalKeyboardKey.delete) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + + didMove = editContext.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.word, + ); + + if (didMove) { + return editContext.commonOps.deleteSelection(TextAffinity.downstream) + ? ExecutionInstruction.haltExecution + : ExecutionInstruction.continueExecution; + } + return ExecutionInstruction.continueExecution; +} + +/// When the ESC key is pressed, the editor should collapse the expanded selection. +/// +/// Do nothing if selection is already collapsed. +ExecutionInstruction collapseSelectionWhenEscIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.escape) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null || editContext.composer.selection!.isCollapsed) { + return ExecutionInstruction.continueExecution; + } + + editContext.commonOps.collapseSelection(); + return ExecutionInstruction.haltExecution; +} diff --git a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart new file mode 100644 index 0000000000..6753c8fb69 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart @@ -0,0 +1,142 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; + +/// Applies appropriate edits to a document and selection when the user presses +/// hardware keys. +/// +/// Hardware key events are dispatched through [FocusNode]s, therefore, this +/// widget's [FocusNode] needs to be focused for key events to be applied. A +/// [FocusNode] can be provided, or this widget will create its own [FocusNode] +/// internally, which is wrapped around the given [child]. +/// +/// [keyboardActions] determines the mapping from keyboard key presses +/// to document editing behaviors. [keyboardActions] operates as a +/// Chain of Responsibility. +class SuperEditorHardwareKeyHandler extends StatefulWidget { + const SuperEditorHardwareKeyHandler({ + Key? key, + this.focusNode, + required this.editContext, + this.keyboardActions = const [], + this.autofocus = false, + required this.child, + }) : super(key: key); + + /// The source of all key events. + final FocusNode? focusNode; + + /// Service locator for document editing dependencies. + final SuperEditorContext editContext; + + /// All the actions that the user can execute with keyboard keys. + /// + /// [keyboardActions] operates as a Chain of Responsibility. Starting + /// from the beginning of the list, a [SuperEditorKeyboardAction] is + /// given the opportunity to handle the currently pressed keys. If that + /// [SuperEditorKeyboardAction] reports the keys as handled, then execution + /// stops. Otherwise, execution continues to the next [SuperEditorKeyboardAction]. + final List keyboardActions; + + /// Whether or not the [SuperEditorHardwareKeyHandler] should autofocus + final bool autofocus; + + /// The [child] widget, which is expected to include the document UI + /// somewhere in the sub-tree. + final Widget child; + + @override + State createState() => _SuperEditorHardwareKeyHandlerState(); +} + +class _SuperEditorHardwareKeyHandlerState extends State { + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = (widget.focusNode ?? FocusNode()); + } + + @override + void dispose() { + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + KeyEventResult _onKeyPressed(FocusNode node, KeyEvent keyEvent) { + if (!node.hasPrimaryFocus) { + // The editor is focused, but doesn't have primary focus. For example: + // - The editor has a node with a focused widget. + // - There is a focused widget somewhere else in the tree which shares + // focus with the editor, typically a popover toolbar. + // Don't run any of the editor key handlers and let the event bubble up. + return KeyEventResult.ignored; + } + editorKeyLog.info("Handling key press: $keyEvent"); + ExecutionInstruction instruction = ExecutionInstruction.continueExecution; + int index = 0; + while (instruction == ExecutionInstruction.continueExecution && index < widget.keyboardActions.length) { + instruction = widget.keyboardActions[index]( + editContext: widget.editContext, + keyEvent: keyEvent, + ); + index += 1; + } + + switch (instruction) { + case ExecutionInstruction.haltExecution: + return KeyEventResult.handled; + case ExecutionInstruction.continueExecution: + case ExecutionInstruction.blocked: + return KeyEventResult.ignored; + } + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: _focusNode, + onKeyEvent: widget.keyboardActions.isEmpty ? null : _onKeyPressed, + autofocus: widget.autofocus, + // Semantics node would create a RenderBox, which does not work within + // a sliver. + includeSemantics: false, + child: widget.child, + ); + } +} + +/// Executes this action, if the action wants to run, and returns +/// a desired `ExecutionInstruction` to either continue or halt +/// execution of actions. +/// +/// It is possible that an action makes changes and then returns +/// `ExecutionInstruction.continueExecution` to continue execution. +/// +/// It is possible that an action does nothing and then returns +/// `ExecutionInstruction.haltExecution` to prevent further execution. +typedef SuperEditorKeyboardAction = ExecutionInstruction Function({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}); + +/// A [SuperEditorKeyboardAction] that reports [ExecutionInstruction.blocked] +/// for any key combination that matches one of the given [keys]. +SuperEditorKeyboardAction ignoreKeyCombos(List keys) { + return ({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, + }) { + for (final key in keys) { + if (key.accepts(keyEvent, HardwareKeyboard.instance)) { + return ExecutionInstruction.blocked; + } + } + return ExecutionInstruction.continueExecution; + }; +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart b/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart new file mode 100644 index 0000000000..7f9319816f --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart @@ -0,0 +1,784 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/common_editor_operations.dart'; +import 'package:super_editor/src/default_editor/document_ime/document_serialization.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; + +/// Applies software keyboard text deltas to a document. +class TextDeltasDocumentEditor { + TextDeltasDocumentEditor({ + required this.editor, + required this.document, + required this.documentLayoutResolver, + required this.selection, + required this.composerPreferences, + required this.composingRegion, + required this.commonOps, + required this.onPerformAction, + this.log, + }); + + final Editor editor; + final Document document; + final DocumentLayoutResolver documentLayoutResolver; + final ValueListenable selection; + final ValueListenable composingRegion; + final ComposerPreferences composerPreferences; + final CommonEditorOperations commonOps; + + /// Handles newlines that are inserted as text, e.g., "\n" in deltas. + final void Function(TextInputAction) onPerformAction; + + /// A logger that is notified of events specifically to [TextDeltasDocumentEditor], + /// which lets apps report those specific events to their own issue tracker. + final TextDeltasDocumentEditorLog? log; + + late DocumentImeSerializer _serializedDoc; + late TextEditingValue _previousImeValue; + TextEditingValue? _nextImeValue; + + /// Applies the given [textEditingDeltas] to the [Document]. + void applyDeltas(List textEditingDeltas) { + // Check for GBoard aggressive trailing space deletion, which prevents + // inserting a paragraph after an empty paragraph. + final isGBoardNewlineSpaceRemoval = _isGBoardTrailingSpaceRemoval(textEditingDeltas); + if (isGBoardNewlineSpaceRemoval) { + log?.onGBoardNewlineTrailingSpaceRemoval(textEditingDeltas); + return; + } + + editorImeLog.info("Applying ${textEditingDeltas.length} IME deltas to document"); + editorImeDeltasLog.fine("Incoming deltas:"); + for (final delta in textEditingDeltas) { + editorImeDeltasLog.fine(delta); + } + + // Apply deltas to the document. + editorImeLog.fine("Serializing document to perform IME operations"); + _serializedDoc = DocumentImeSerializer( + document, + selection.value!, + composingRegion.value, + ); + + _previousImeValue = TextEditingValue( + text: _serializedDoc.imeText, + selection: selection.value != null + ? _serializedDoc.documentToImeSelection(selection.value!) + : const TextSelection.collapsed(offset: -1), + composing: _serializedDoc.documentToImeRange(_serializedDoc.composingRegion), + ); + + // Start an editor transaction so that all changes made during this delta + // application is considered a single undo-able change. + editor.startTransaction(); + + try { + for (final delta in textEditingDeltas) { + editorImeLog.info("---------------------------------------------------"); + + editorImeLog.info("Applying delta: $delta"); + + _nextImeValue = delta.apply(_previousImeValue); + if (delta is TextEditingDeltaInsertion) { + _applyInsertion(delta); + } else if (delta is TextEditingDeltaReplacement) { + _applyReplacement(delta); + } else if (delta is TextEditingDeltaDeletion) { + _applyDeletion(delta); + } else if (delta is TextEditingDeltaNonTextUpdate) { + _applyNonTextChange(delta); + } else { + editorImeLog.shout("Unknown IME delta type: ${delta.runtimeType}"); + } + + editorImeLog.info("---------------------------------------------------"); + } + } on FailedToMapImePositionToDocumentPositionException catch (exception, stacktrace) { + // We fail silently on this error due to a Samsung delta ordering issue. When the user + // presses `newline` button, we receive 2 events, but in the wrong order. We *should* + // receive: + // + // 1. Text replacement for upstream word auto-correction + // 2. `ENTER` key pressed + // + // But what we get is: + // + // 1. `ENTER` key pressed + // 2. Text replacement for upstream word auto-correction (which is now in previous paragraph) + // + // The best we can do is stop the delta applications right now, serialize what we've got + // and send our current state to the IME to get us back in sync. This might still erase + // some changes that the user applied, but hopefully the editor will continue to respond + // to future input. + // + // Note: We added this specifically to get around a Samsung bug (https://github.com/Flutter-Bounty-Hunters/super_editor/issues/2979) + // but this error could appear for any number of reasons, and so there may be situations in which + // we shouldn't swallow this error. If we find those, update this logic accordingly. + log?.onFailedToMapImePositionToDocumentPosition(exception, stacktrace); + } on FailedToMapDocumentPositionToImePositionException catch (exception, stacktrace) { + // We added this catch at the same time as `FailedToMapImePositionToDocumentPositionException`. + // The other catch was added for a specific Samsung bug. That bug doesn't trigger this + // exception, nor have we seen this exception elsewhere, but I thought that if we're going + // to swallow and short-circuit deltas when the mapping fails one direction, then we should + // do the same thing in the other direction. + // + // The hope is that if we ever fail to map from the document to the IME, we short-circuit + // here and immediately send our current document to the IME, thus returning the IME to a + // valid state for future editing. Similar to the other error that we swallow, by swallowing + // this one, there might be missed content changes from the user's perspective. However, it's + // more important that we keep a working editor than that we avoid the appearance of buggy input. + log?.onFailedToMapDocumentPositionToImePosition(exception, stacktrace); + } catch (exception, stacktrace) { + // This is an unknown error, and therefore it would be dangerous to swallow it. Rethrow it + // so that apps can catch this issue, and also maybe report it to their issue tracker. + log?.onFailedToApplyDeltasForUnknownReason(exception, stacktrace); + rethrow; + } finally { + // We must always end the transaction, even if an error occurred. Otherwise, we'll get + // stuck with an open transaction and the editor will never stabilize. + // + // End the editor transaction for all deltas in this call. + editorImeLog.info("Done with deltas. Ending transaction."); + editor.endTransaction(); + } + + // Re-serialize document after end of transaction because reactions may have + // run and further changed it. + _serializedDoc = DocumentImeSerializer( + document, + selection.value!, + composingRegion.value, + ); + + // Update the editor's IME composing region based on the composing region + // for the last delta. If the version of our document serialized hidden + // characters in the IME, adjust for those hidden characters before setting + // the IME composing region. + editorImeLog.fine("After applying all deltas, converting the final composing region to a document range."); + editorImeLog.fine("Raw IME delta composing region: ${textEditingDeltas.last.composing}"); + + DocumentRange? docComposingRegion = _calculateNewComposingRegion(textEditingDeltas); + + if (docComposingRegion != composingRegion.value) { + editor.execute([ + ChangeComposingRegionRequest( + docComposingRegion, + ), + ]); + } + editorImeLog.fine("Document composing region: ${composingRegion.value}"); + + _nextImeValue = null; + } + + /// Returns `true` if we believe the given batch of [deltas] represent a GBoard + /// automatically deleting the trailing space of an empty paragraph when the user + /// presses "newline". + /// + /// ## Explanation of Problem + /// We encode empty paragraphs as ". ". We do this for two reasons: + /// + /// 1. We need some non-empty content so we can detect a "backspace" at the beginning + /// of a paragraph. + /// 2. We need to trick the IME into giving us auto-capitalization, which happens + /// naturally after the end of a sentence, hence the ". ". + /// + /// Our hidden ". " encoding works fine almost everywhere, and it almost call cases. + /// The except is when the user tries press "newline" on a GBoard. the GBoard interprets + /// this situation as the user going to a new line while leaving a dangling space after + /// the end of a sentence, and therefore the GBoard tries to remove that space. + /// + /// Example of deltas in a real interaction: + /// + /// ``` + /// TextEditingDeltaNonTextUpdate - old text: '. ', offset: 2 + /// TextEditingDeltaNonTextUpdate - old text: '. ', offset: 2 + /// TextEditingDeltaNonTextUpdate - old text: '. ', offset: 2 + /// TextEditingDeltaNonTextUpdate - old text: '. ', offset: 2 + /// TextEditingDeltaNonTextUpdate - old text: '. ', offset: 2 + /// TextEditingDeltaNonTextUpdate - old text: '. ', offset: 2 + /// TextEditingDeltaDeletion - old text: '. ', offset: 1 + /// ``` + /// + /// We don't know for sure why there are so many repeat non-text updates. This might be + /// a consequence of how the GBoard IME reporting implementation works with Flutter's + /// batching system. + bool _isGBoardTrailingSpaceRemoval(List deltas) { + if (defaultTargetPlatform != TargetPlatform.android) { + // As far as we know, this issue only happens on GBoards, which only runs + // on Android. iOS legitimately uses deltas for almost everything, including + // backspace deletion, so we have to very careful if we don't gate this on + // the platform. + return false; + } + + if (deltas.length < 2) { + // We expect at least 2 deltas when the GBoard tries to remove a trailing space. + return false; + } + + final lastDelta = deltas.last; + if (lastDelta is! TextEditingDeltaDeletion) { + // We expect the last delta to be a trailing space deletion, but this one isn't + // a deletion at all. + return false; + } + + final deltasBeforeDeletion = deltas.sublist(0, deltas.length - 1); + if (deltasBeforeDeletion.firstWhereOrNull((delta) => delta is! TextEditingDeltaNonTextUpdate) != null) { + // We expect all deltas before the deletion to be repeated composing region changes. + // But in this case, there are some mutating deltas (insert, replace, delete) before the + // final deletion. So this isn't an automatic GBoard space removal in an empty paragraph. + return false; + } + + for (final nonTextDelta in deltasBeforeDeletion) { + // We expect the reported text in every non-text delta to be an empty paragraph encoding. + if (nonTextDelta.oldText != ". ") { + // This delta has a text value that isn't an empty paragraph, so this looks like + // some other kind of delta batch. + return false; + } + + if (!nonTextDelta.selection.isCollapsed) { + // When iOS backspaces with a delta at the start of a paragraph, it reports + // a selection from offset 1 -> 2. Even though we're gating this whole method + // on the Android platform, we explicitly exclude this situation as well, just + // in case. + return false; + } + } + + // We believe that this situation represents the GBoard auto-deleting the trailing + // space in an empty paragraph, because we encode an empty paragraph as ". ". + return true; + } + + void _applyInsertion(TextEditingDeltaInsertion delta) { + editorImeLog.fine('Inserted text: "${delta.textInserted}"'); + editorImeLog.fine("Insertion offset: ${delta.insertionOffset}"); + editorImeLog.fine("Selection: ${delta.selection}"); + editorImeLog.fine("Composing: ${delta.composing}"); + editorImeLog.fine('Old text: "${delta.oldText}"'); + + if (delta.textInserted == "\n") { + // On iOS native and Android Web, newlines are reported here and also to performAction(). + // On Android native, newlines are only reported here. So, on Android native, + // we forward the newline action to performAction. + if (defaultTargetPlatform == TargetPlatform.android && !CurrentPlatform.isWeb) { + editorImeLog.fine("Received a newline insertion on Android. Forwarding to newline input action."); + onPerformAction(TextInputAction.newline); + } else { + editorImeLog.fine("Skipping insertion delta because its a newline"); + } + + // Update the local IME value that changes with each delta. + _previousImeValue = delta.apply(_previousImeValue); + + return; + } + + if (delta.textInserted == "\t" && (defaultTargetPlatform == TargetPlatform.iOS)) { + // On iOS, tabs pressed at the the software keyboard are reported here. + commonOps.indentListItem(); + + // Update the local IME value that changes with each delta. + _previousImeValue = delta.apply(_previousImeValue); + + return; + } + + editorImeLog.fine( + "Inserting text: '${delta.textInserted}', at insertion offset: ${delta.insertionOffset}, with ime selection: ${delta.selection}"); + + final insertionPosition = TextPosition( + offset: delta.insertionOffset, + affinity: delta.selection.affinity, + ); + + if (delta.textInserted == ' ' && _serializedDoc.isPositionInsidePlaceholder(insertionPosition)) { + // The IME is trying to insert a space inside the invisible range. This is a situation that happens + // on iOS when the user is composing a character at the beginning of a node using a korean keyboard. + // The IME deletes the first visible character and the space from the invisible characters, + // them it inserts the space back. We already adjust the deletion to avoid deleting the invisible space, + // so we should ignore this insertion. + // + // For more information, see #1828. + return; + } + + editorImeLog.fine("Converting IME insertion offset into a DocumentSelection"); + final insertionSelection = _serializedDoc.imeToDocumentSelection( + TextSelection.fromPosition(insertionPosition), + )!; + // FIXME: ClickUp is getting NPE's on this line ^ (from Sentry error reports) + + // Update the local IME value that changes with each delta. + _previousImeValue = delta.apply(_previousImeValue); + + insert(insertionSelection, delta.textInserted); + + // Update the IME to document serialization based on the insertion changes. + _serializedDoc = DocumentImeSerializer( + document, + selection.value!, + composingRegion.value, + _serializedDoc.didPrependPlaceholder ? PrependedCharacterPolicy.include : PrependedCharacterPolicy.exclude, + ); + } + + void _applyReplacement(TextEditingDeltaReplacement delta) { + editorImeLog.fine("Text replaced: '${delta.textReplaced}'"); + editorImeLog.fine("Replacement text: '${delta.replacementText}'"); + editorImeLog.fine("Replaced range: ${delta.replacedRange}"); + editorImeLog.fine("Selection: ${delta.selection}"); + editorImeLog.fine("Composing: ${delta.composing}"); + editorImeLog.fine('Old text: "${delta.oldText}"'); + + if (delta.replacementText == "\n") { + // On iOS native and Android Web, newlines are reported here and also to performAction(). + // On Android native, newlines are only reported here. So, on Android native, + // we forward the newline action to performAction. + if (defaultTargetPlatform == TargetPlatform.android && !CurrentPlatform.isWeb) { + editorImeLog.fine("Received a newline replacement on Android. Forwarding to newline input action."); + onPerformAction(TextInputAction.newline); + } else { + editorImeLog.fine("Skipping replacement delta because its a newline"); + } + return; + } + + if (delta.replacementText == "\t" && (defaultTargetPlatform == TargetPlatform.iOS)) { + // On iOS, tabs pressed at the the software keyboard are reported here. + commonOps.indentListItem(); + return; + } + + replace(delta.replacedRange, delta.replacementText); + + // Update the local IME value that changes with each delta. + _previousImeValue = delta.apply(_previousImeValue); + + // Update the IME to document serialization based on the replacement changes. + // It's possible that the replacement text have a different length from the replaced text. + // Therefore, we need to update our mapping from the IME positions to document positions. + _serializedDoc = DocumentImeSerializer( + document, + selection.value!, + composingRegion.value, + _serializedDoc.didPrependPlaceholder ? PrependedCharacterPolicy.include : PrependedCharacterPolicy.exclude, + ); + } + + void _applyDeletion(TextEditingDeltaDeletion delta) { + editorImeLog.fine("Delete delta:\n" + "Text deleted: '${delta.textDeleted}'\n" + "Deleted Range: ${delta.deletedRange}\n" + "Selection: ${delta.selection}\n" + "Composing: ${delta.composing}\n" + "Old text: '${delta.oldText}'"); + + delete(delta.deletedRange); + + // Update the local IME value that changes with each delta. + _previousImeValue = delta.apply(_previousImeValue); + + editorImeLog.fine("Deletion operation complete"); + } + + void _applyNonTextChange(TextEditingDeltaNonTextUpdate delta) { + editorImeLog.fine("Non-text change:"); + editorImeLog.fine("OS-side selection - ${delta.selection}"); + editorImeLog.fine("OS-side composing - ${delta.composing}"); + + var docSelection = _calculateNewDocumentSelection(delta); + DocumentRange? docComposingRegion = _calculateNewComposingRegion([delta]); + + if (docSelection != null) { + // We got a selection from the platform. + // This could happen in some software keyboards, like GBoard, + // where the user can swipe over the spacebar to change the selection. This also happens + // when the app uses the native iOS text selection toolbar and the user presses "Select all". + + docSelection = _maybeSelectAllOnIos(docSelection); + + editor.execute([ + ChangeSelectionRequest( + docSelection, + docSelection.isCollapsed ? SelectionChangeType.placeCaret : SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ChangeComposingRegionRequest(docComposingRegion), + ]); + } + + // Update the local IME value that changes with each delta. + _previousImeValue = delta.apply(_previousImeValue); + } + + /// Performs a workaround to select all text in the document on iOS when the user presses "Select all". + /// + /// On iOS, when the app uses the native text selection toolbar and the user presses "Select all", + /// Flutter sends us a non-text delta with the selection change. However, since we only send to the IME the text + /// of the currently selected nodes, the delta reports the node being selected, not the entire + /// document. + /// + /// To workaroud this, whenever iOS reports a selection change that selects an entire node, + /// we select the entire document instead. + DocumentSelection _maybeSelectAllOnIos(DocumentSelection documentSelection) { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return documentSelection; + } + + final extentNode = document.getNodeById(documentSelection.extent.nodeId)!; + final isWholeNodeSelected = documentSelection.start.nodeId == documentSelection.end.nodeId && + documentSelection.start.nodePosition == extentNode.beginningPosition && + documentSelection.end.nodePosition == extentNode.endPosition; + + if (!isWholeNodeSelected) { + // The selection is either across multiple nodes or not the entire node. + // The user didn't press "Select all". + return documentSelection; + } + + // The IME reported a selection that selects an entire node. Select the entire document instead. + return DocumentSelection( + base: DocumentPosition( + nodeId: document.first.id, + nodePosition: document.first.beginningPosition, + ), + extent: DocumentPosition( + nodeId: document.last.id, + nodePosition: document.last.endPosition, + ), + ); + } + + void insert(DocumentSelection insertionSelection, String textInserted) { + editorImeLog.fine('Inserting "$textInserted" at position "$insertionSelection"'); + editorImeLog + .fine("Updating the Document Composer's selection to place caret at insertion offset:\n$insertionSelection"); + final selectionBeforeInsertion = selection.value; + + editorImeLog.fine("Inserting the text at the Document Composer's selection"); + final didInsert = _insertPlainText( + insertionSelection.extent, + textInserted, + ); + editorImeLog.fine("Insertion successful? $didInsert"); + + if (!didInsert) { + editorImeLog.fine("Failed to insert characters. Restoring previous selection."); + editor.execute([ + ChangeSelectionRequest( + selectionBeforeInsertion, + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ]); + } + } + + bool _insertPlainText( + DocumentPosition insertionPosition, + String text, + ) { + editorOpsLog.fine('Attempting to insert "$text" at position: $insertionPosition'); + + DocumentNode? insertionNode = document.getNodeById(insertionPosition.nodeId); + if (insertionNode == null) { + editorOpsLog.warning('Attempted to insert text using a non-existing node'); + return false; + } + + if (insertionPosition.nodePosition is UpstreamDownstreamNodePosition) { + editorOpsLog.fine("The selected position is an UpstreamDownstreamPosition. Inserting new paragraph first."); + editor.execute([InsertNewlineAtCaretRequest()]); + + // After inserting a block level new line, the selection changes to another node. + // Therefore, we need to update the insertion position. + insertionNode = document.getNodeById(selection.value!.extent.nodeId)!; + insertionPosition = DocumentPosition(nodeId: insertionNode.id, nodePosition: insertionNode.endPosition); + } + + if (insertionNode is! TextNode || insertionPosition.nodePosition is! TextNodePosition) { + editorOpsLog.fine( + "Couldn't insert text because Super Editor doesn't know how to handle a node of type: $insertionNode, with position: ${insertionPosition.nodePosition}"); + return false; + } + + editorOpsLog.fine("Executing text insertion command."); + editorOpsLog.finer("Text before insertion: '${insertionNode.text.toPlainText()}'"); + editor.execute([ + if (selection.value != DocumentSelection.collapsed(position: insertionPosition)) + ChangeSelectionRequest( + DocumentSelection.collapsed(position: insertionPosition), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + InsertTextRequest( + documentPosition: insertionPosition, + textToInsert: text, + attributions: composerPreferences.currentAttributions, + ), + ]); + editorOpsLog.finer("Text after insertion: '${insertionNode.text.toPlainText()}'"); + + return true; + } + + void replace(TextRange replacedRange, String replacementText) { + editorImeLog.fine("Replacing content in IME range: $replacedRange, with new text: '$replacementText'"); + final replacementSelection = _serializedDoc.imeToDocumentSelection(TextSelection( + baseOffset: replacedRange.start, + // TODO: the delta API is wrong for TextRange.end, it should be exclusive, + // but it's implemented as inclusive. Change this code when Flutter + // fixes the problem. + extentOffset: replacedRange.end, + )); + + if (replacementSelection != null) { + editor.execute([ + ChangeSelectionRequest( + replacementSelection, + SelectionChangeType.expandSelection, + SelectionReason.contentChange, + ), + ]); + } + editorImeLog.fine("Replacing selection: $replacementSelection"); + editorImeLog.fine('With text: "$replacementText"'); + + if (replacementText == "\n") { + onPerformAction(TextInputAction.newline); + return; + } + + editor.execute([ + // This request automatically deletes the currently selected text. + InsertPlainTextAtCaretRequest(replacementText), + ]); + } + + void delete(TextRange deletedRange) { + final rangeToDelete = deletedRange; + final docSelectionToDelete = _serializedDoc.imeToDocumentSelection(TextSelection( + baseOffset: rangeToDelete.start, + extentOffset: rangeToDelete.end, + )); + editorImeLog.fine("Doc selection to delete: $docSelectionToDelete"); + + if (docSelectionToDelete == null) { + // The user is trying to delete upstream at the start of a node. + // This action requires intervention because the IME doesn't know + // that there's more content before this node. Instruct the editor + // to run a delete action upstream, which will take the desired + // "backspace" behavior at the start of this node. + editor.execute([ + DeleteUpstreamAtBeginningOfNodeRequest( + document.getNodeById(selection.value!.extent.nodeId)!, + ), + ]); + return; + } + + editorImeLog.fine("Running selection deletion operation"); + editor.execute([ + ChangeSelectionRequest( + docSelectionToDelete, + docSelectionToDelete.isCollapsed ? SelectionChangeType.collapseSelection : SelectionChangeType.expandSelection, + SelectionReason.contentChange, + ), + const DeleteSelectionRequest(TextAffinity.upstream), + ]); + } + + void insertNewline() { + if (!_isCurrentlyApplyingDeltas) { + // This newline came from a hardware key, or somewhere other than + // IME deltas. We can safely run a regular newline insertion. + editor.execute([ + InsertNewlineAtCaretRequest(Editor.createNodeId()), + ]); + return; + } + + // We received a newline in the middle of IME deltas. Due to the two-way + // communication between the IME and the app text editing state, we have + // to handle the update with a bit of extra tracking. See below... + editorOpsLog.fine("Inserting block-level newline"); + assert(selection.value != null && selection.value!.isCollapsed); + + // Log the node that will receive the newline. We only care about node + // splits, which only happens with text nodes. + final selectedNode = document.getNodeById(selection.value!.base.nodeId)!; + final isSplittingText = selectedNode is TextNode; + + // Run the newline insertion. + editor.execute([ + InsertNewlineAtCaretRequest(Editor.createNodeId()), + ]); + + // If the newline split a text node, find the newly insert node and update + // the IME <-> Document mapping for those two nodes. This is the special + // accounting that's required to prevent us from trying to send invalid selection + // or composing regions to the IME. See `supereditor_input_ime_test.dart` for + // a couple of examples of IME deltas that demonstrate this issue. + if (isSplittingText) { + final nextNode = document.getNodeAfterById(selectedNode.id); + if (nextNode != null) { + _updateImeRangeMappingAfterNodeSplit(originNode: selectedNode, newNode: nextNode); + } + } + } + + bool get _isCurrentlyApplyingDeltas => _nextImeValue != null; + + /// Updates mappings from Document nodes to IME ranges and IME ranges to Document nodes, + /// after splitting an [originNode] text node, and inserting [newNode]. + void _updateImeRangeMappingAfterNodeSplit({ + required DocumentNode originNode, + required DocumentNode newNode, + }) { + final newImeValue = _nextImeValue!; + final imeNewlineIndex = newImeValue.text.indexOf("\n"); + final topImeToDocTextRange = TextRange(start: 0, end: imeNewlineIndex); + final bottomImeToDocTextRange = TextRange(start: imeNewlineIndex + 1, end: newImeValue.text.length); + + // Update mapping from Document nodes to IME ranges. + _serializedDoc.docTextNodesToImeRanges[originNode.id] = topImeToDocTextRange; + _serializedDoc.docTextNodesToImeRanges[newNode.id] = bottomImeToDocTextRange; + + // Remove old mapping from IME TextRange to Document node. + late final MapEntry oldImeToDoc; + for (final entry in _serializedDoc.imeRangesToDocTextNodes.entries) { + if (entry.value != originNode.id) { + continue; + } + + oldImeToDoc = entry; + break; + } + _serializedDoc.imeRangesToDocTextNodes.remove(oldImeToDoc.key); + + // Update and add mapping from IME TextRanges to Document nodes. + _serializedDoc.imeRangesToDocTextNodes[topImeToDocTextRange] = originNode.id; + _serializedDoc.imeRangesToDocTextNodes[bottomImeToDocTextRange] = newNode.id; + } + + DocumentSelection? _calculateNewDocumentSelection(TextEditingDelta delta) { + if (CurrentPlatform.isWeb && + delta.selection.isCollapsed && + _serializedDoc.isPositionInsidePlaceholder(delta.selection.extent)) { + // On web, pressing CMD + LEFT ARROW generates a non-text delta moving + // the selection to the first character. However, the first character is in a region + // invisible to the user. Adjust the document selection to be the first visible character. + // Expanded selection are already adjusted by the serializer. + return _serializedDoc.imeToDocumentSelection( + TextSelection.collapsed( + offset: _serializedDoc.firstVisiblePosition.offset, + ), + ); + } + return _serializedDoc.imeToDocumentSelection(delta.selection); + } + + DocumentRange? _calculateNewComposingRegion(List deltas) { + final lastDelta = deltas.last; + if (CurrentPlatform.isWeb && + lastDelta.composing.isCollapsed && + _serializedDoc.isPositionInsidePlaceholder(TextPosition(offset: lastDelta.composing.end))) { + // On web, pressing CMD + LEFT ARROW generates a non-text delta moving + // the selection, and possibly the composing region to the first character. However, the first character + // is in a region invisible to the user. Adjust the document composing region to be the first visible character. + // Expanded regions are already adjusted by the serializer. + return _serializedDoc.imeToDocumentRange( + TextRange.collapsed( + _serializedDoc.firstVisiblePosition.offset, + ), + ); + } + + if (_serializedDoc.imeText.length < lastDelta.composing.end) { + // The IME is composing, but the composing region is out of our text bounds. This can happen if the delta + // handling causes our text to be smaller than the IME's text. + // + // For example, when using the markdown plugin, typing "~b~" causes the text to be converted to "b" + // with strikethrough. The ~ character triggers a composition start, but the IME's composing region is still + // at the end of "~b~", which is out of our text bounds. This out of bounds index causes our IME range + // mapping to fail. + // + // Clear the composing region. + return null; + } + + return _serializedDoc.imeToDocumentRange(lastDelta.composing); + } +} + +abstract class TextDeltasDocumentEditorLog { + void onGBoardNewlineTrailingSpaceRemoval(List textEditingDeltas); + + void onFailedToMapImePositionToDocumentPosition( + FailedToMapImePositionToDocumentPositionException exception, + StackTrace stacktrace, + ); + + void onFailedToMapDocumentPositionToImePosition( + FailedToMapDocumentPositionToImePositionException exception, + StackTrace stacktrace, + ); + + void onFailedToApplyDeltasForUnknownReason(Object exception, StackTrace stacktrace); +} + +class ConsolePrintTextDeltasDocumentEditorLog implements TextDeltasDocumentEditorLog { + const ConsolePrintTextDeltasDocumentEditorLog(); + + @override + void onGBoardNewlineTrailingSpaceRemoval(List textEditingDeltas) { + editorImeLog.fine("Detected a GBoard newline trailing space removal. Deltas:"); + for (final delta in textEditingDeltas) { + editorImeLog + .fine(" > ${delta.runtimeType} - old text: ${delta.oldText}, selection: ${delta.selection.extentOffset}"); + } + } + + @override + void onFailedToMapImePositionToDocumentPosition( + FailedToMapImePositionToDocumentPositionException exception, + StackTrace stacktrace, + ) { + editorImeLog.warning("Failed to apply some deltas due to bad IME-to-document mapping."); + editorImeLog.warning(exception); + editorImeLog.warning("$stacktrace"); + } + + @override + void onFailedToMapDocumentPositionToImePosition( + FailedToMapDocumentPositionToImePositionException exception, + StackTrace stacktrace, + ) { + editorImeLog.warning("Failed to apply some deltas due to bad document-to-IME mapping."); + editorImeLog.warning(exception); + editorImeLog.warning("$stacktrace"); + } + + @override + void onFailedToApplyDeltasForUnknownReason(Object exception, StackTrace stacktrace) { + editorImeLog.shout("Unknown exception while applying deltas:"); + editorImeLog.shout(exception); + editorImeLog.shout(stacktrace); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart b/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart new file mode 100644 index 0000000000..cf8620eb35 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart @@ -0,0 +1,362 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; + +import 'document_delta_editing.dart'; +import 'document_serialization.dart'; +import 'ime_decoration.dart'; + +/// Sends messages to, and receives messages from, the platform Input Method Engine (IME), +/// for the purpose of document editing. + +/// A [TextInputClient] that applies IME operations to a [Document]. +/// +/// Ideally, this class *wouldn't* implement [TextInputConnection], but there are situations +/// where this class needs to care about what's sent to the IME. For more information, see +/// the [setEditingState] override in this class. +class DocumentImeInputClient extends TextInputConnectionDecorator with TextInputClient, DeltaTextInputClient { + DocumentImeInputClient({ + required this.selection, + required this.composingRegion, + required this.textDeltasDocumentEditor, + required this.imeConnection, + required this.onPerformSelector, + this.floatingCursorController, + }) : super(imeConnection.value) { + // Note: we don't listen to document changes because we expect that any change during IME + // editing will also include a selection change. If we listen to documents and selections, then + // we'll attempt to serialize the document change before the selection change is made. This + // results in a new document with an old selection and blows up the serializer. By listening + // only to selection, we void this race condition. + selection.addListener(_onContentChange); + composingRegion.addListener(_onContentChange); + imeConnection.addListener(_onImeConnectionChange); + + if (attached) { + _sendDocumentToIme(); + } + } + + void dispose() { + selection.removeListener(_onContentChange); + composingRegion.removeListener(_onContentChange); + imeConnection.removeListener(_onImeConnectionChange); + } + + /// The document's current selection. + final ValueListenable selection; + + /// The document's current composing region, which represents a section + /// of content that the platform IME is thinking about changing, such as spelling + /// autocorrection. + final ValueListenable composingRegion; + + final TextDeltasDocumentEditor textDeltasDocumentEditor; + + final ValueListenable imeConnection; + + /// Handles a selector generated by the IME. + /// + /// For the list of selectors, see [MacOsSelectors]. + final void Function(String selectorName) onPerformSelector; + + // TODO: get floating cursor out of here. Use a multi-client IME decorator to split responsibilities + late FloatingCursorController? floatingCursorController; + + /// Whether a Scribble (Apple Pencil handwriting) or stylus writing interaction + /// is currently in progress. + /// + /// This value is updated when the platform calls [insertTextPlaceholder] (start) + /// and [removeTextPlaceholder] (end). + ValueListenable get isScribbleInProgress => _isScribbleInProgress; + final ValueNotifier _isScribbleInProgress = ValueNotifier(false); + + /// Whether the floating cursor is being displayed. + /// + /// This value is updated on [updateFloatingCursor]. + bool _isFloatingCursorVisible = false; + + /// Whether or not a `TextInputAction.newline` was performed on the current frame. + bool _hasPerformedNewLineActionThisFrame = false; + + void _onContentChange() { + if (!attached) { + return; + } + if (_isApplyingDeltas) { + return; + } + + _sendDocumentToIme(); + } + + void _onImeConnectionChange() { + client = imeConnection.value; + + if (attached) { + // This is a new IME connection for us. As far as we're concerned, there is no current + // IME value. + _currentTextEditingValue = const TextEditingValue(); + _platformTextEditingValue = const TextEditingValue(); + + _sendDocumentToIme(); + } + } + + /// Override on [TextInputConnection] base class. + /// + /// This method is the reason that this class extends [TextInputConnectionDecorator]. + /// Ideally, this object would be exclusively responsible for responding to IME + /// deltas, and some other object would be exclusively responsible for sending the + /// document to the IME. However, in certain situations, the decision to send the + /// document to the IME depends upon knowledge of recent deltas received from the + /// IME. As a result, this class is not only responsible for applying deltas to + /// the editor, but also making some decisions about when to send new values to the + /// IME. This method provides an override to do that, with minimal impact on other + /// areas of responsibility. + @override + void setEditingState(TextEditingValue newValue) { + if (_isApplyingDeltas) { + // We're in the middle of applying a series of text deltas. Don't + // send any updates to the IME because it will conflict with the + // changes we're actively processing. + editorImeLog.fine("Ignoring new TextEditingValue because we're applying deltas"); + return; + } + + editorImeLog.fine("Wants to send a value to IME: $newValue"); + editorImeLog.fine("The current local IME value: $_currentTextEditingValue"); + editorImeLog.fine("The current platform IME value: $_currentTextEditingValue"); + if (newValue != _platformTextEditingValue) { + // We've been given a new IME value. We compare its value to _platformTextEditingValue + // instead of _currentTextEditingValue. Why is that? + // + // Sometimes the IME reports changes to us, but our document doesn't change + // in ways that's reflected in the IME. + // + // Example: The user has a caret in an empty paragraph. That empty paragraph + // includes a couple hidden characters, so the IME value might look like: + // + // ". |" + // + // The ". " substring is invisible to the user and the "|" represents the caret at + // the beginning of the empty paragraph. + // + // Then the user inserts a newline "\n". This causes Super Editor to insert a new, + // empty paragraph node, and place the caret in the new, empty paragraph. At this + // point, we have an issue: + // + // This class still sees the TextEditingValue as: ". |" + // + // However, the OS IME thinks the TextEditingValue is: ". |\n" + // + // In this situation, even though our desired TextEditingValue looks identical to what it + // was before, it's not identical to what the operating system thinks it is. We need to + // send our TextEditingValue back to the OS so that the OS doesn't think there's a "\n" + // sitting in the edit region. + editorImeLog.fine( + "Sending forceful update to IME because our local TextEditingValue didn't change, but the IME may have:"); + editorImeLog.fine("$newValue"); + imeConnection.value?.setEditingState(newValue); + } else { + editorImeLog.fine("Ignoring new TextEditingValue because it's the same as the existing one: $newValue"); + } + + _currentTextEditingValue = newValue; + _platformTextEditingValue = newValue; + } + + @override + AutofillScope? get currentAutofillScope => null; + + @override + TextEditingValue get currentTextEditingValue => _currentTextEditingValue; + + TextEditingValue _currentTextEditingValue = const TextEditingValue(); + + // What the platform IME *thinks* the current value is. + TextEditingValue _platformTextEditingValue = const TextEditingValue(); + + void _updatePlatformImeValueWithDeltas(List textEditingDeltas) { + // Apply the deltas to the previous platform-side IME value, to find out + // what the platform thinks the IME value is, right now. + for (final delta in textEditingDeltas) { + _platformTextEditingValue = delta.apply(_platformTextEditingValue); + } + } + + bool _isApplyingDeltas = false; + + @override + void updateEditingValue(TextEditingValue value) { + editorImeLog.shout("Delta text input client received a non-delta TextEditingValue from OS: $value"); + } + + @override + void updateEditingValueWithDeltas(List textEditingDeltas) { + if (textEditingDeltas.isEmpty) { + return; + } + + if (_isFloatingCursorVisible && textEditingDeltas.every((e) => e is TextEditingDeltaNonTextUpdate)) { + // On iOS, dragging the floating cursor generates non-text deltas to update the selection. + // + // When dragging the floating cursor between paragraphs, we receive a non-text delta for the previously + // selected paragraph when our selection already changed to another paragraph. If the previously selected + // paragraph is bigger than the newly selected paragraph, a mapping error occurs, because we try + // to select an offset bigger than the paragraph's length. + // + // As we already change the selection when the floating cursor moves, we ignore these deltas. + return; + } + + if (_hasPerformedNewLineActionThisFrame && defaultTargetPlatform == TargetPlatform.iOS) { + // On iOS, pressing the new line action button can trigger the IME to try to apply suggestions + // after we have already processed the new line insertion. This causes the deltas related to suggestions + // to have offsets that are invalid for us. Ignore any new deltas on the same frame and forcefully + // update the IME with our current state. + imeConnection.value?.setEditingState(_currentTextEditingValue); + return; + } + + editorImeLog.fine("Received edit deltas from platform: ${textEditingDeltas.length} deltas"); + for (final delta in textEditingDeltas) { + editorImeLog.fine("$delta"); + } + + final imeValueBeforeChange = currentTextEditingValue; + editorImeLog.fine("IME value before applying deltas: $imeValueBeforeChange"); + + _isApplyingDeltas = true; + editorImeLog.fine("==================================================="); + // Update our local knowledge of what the platform thinks the IME value is right now. + _updatePlatformImeValueWithDeltas(textEditingDeltas); + + // Apply the deltas to our document, selection, and composing region. + textDeltasDocumentEditor.applyDeltas(textEditingDeltas); + editorImeLog.fine("==================================================="); + _isApplyingDeltas = false; + + // Send latest doc and selection to IME + _sendDocumentToIme(); + } + + bool _isSendingToIme = false; + + void _sendDocumentToIme() { + if (_isApplyingDeltas) { + editorImeLog + .fine("[DocumentImeInputClient] - Tried to send document to IME, but we're applying deltas. Fizzling."); + return; + } + + if (_isSendingToIme) { + editorImeLog + .warning("[DocumentImeInputClient] - Tried to send document to IME, while we're sending document to IME."); + return; + } + + if (textDeltasDocumentEditor.selection.value == null) { + // There's no selection, which means there's nothing to edit. Return. + editorImeLog.fine("[DocumentImeInputClient] - There's no document selection. Not sending anything to IME."); + return; + } + + _isSendingToIme = true; + editorImeLog.fine("[DocumentImeInputClient] - Serializing and sending document and selection to IME"); + editorImeLog.fine("[DocumentImeInputClient] - Selection: ${textDeltasDocumentEditor.selection.value}"); + editorImeLog.fine("[DocumentImeInputClient] - Composing region: ${textDeltasDocumentEditor.composingRegion.value}"); + final imeSerialization = DocumentImeSerializer( + textDeltasDocumentEditor.document, + textDeltasDocumentEditor.selection.value!, + textDeltasDocumentEditor.composingRegion.value, + ); + + editorImeLog + .fine("[DocumentImeInputClient] - Adding invisible characters?: ${imeSerialization.didPrependPlaceholder}"); + TextEditingValue textEditingValue = imeSerialization.toTextEditingValue(); + + editorImeLog.fine("[DocumentImeInputClient] - Sending IME serialization:"); + editorImeLog.fine("[DocumentImeInputClient] - $textEditingValue"); + setEditingState(textEditingValue); + editorImeLog.fine("[DocumentImeInputClient] - Done sending document to IME"); + + _isSendingToIme = false; + } + + @override + void insertTextPlaceholder(Size size) { + editorImeLog.fine("[DocumentImeInputClient] - Scribble: insertTextPlaceholder($size)"); + _isScribbleInProgress.value = true; + } + + @override + void removeTextPlaceholder() { + editorImeLog.fine("[DocumentImeInputClient] - Scribble: removeTextPlaceholder"); + _isScribbleInProgress.value = false; + } + + @override + void performAction(TextInputAction action) { + editorImeLog.fine("IME says to perform action: $action"); + if (action == TextInputAction.newline) { + textDeltasDocumentEditor.insertNewline(); + + // Keep track that we have performed a new line action on this frame to work around an iOS timing issue, + // where the iOS IME might report text deltas related to keyboard suggestions after we already processed + // the new line action. + // + // See https://github.com/superlistapp/super_editor/issues/2007 for more information. + _hasPerformedNewLineActionThisFrame = true; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _hasPerformedNewLineActionThisFrame = false; + }); + } + } + + @override + void performSelector(String selectorName) { + editorImeLog.fine("IME says to perform selector: $selectorName"); + + onPerformSelector(selectorName); + } + + @override + void performPrivateCommand(String action, Map data) {} + + @override + void showAutocorrectionPromptRect(int start, int end) {} + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + if (floatingCursorController == null) { + return; + } + + switch (point.state) { + case FloatingCursorDragState.Start: + _isFloatingCursorVisible = true; + floatingCursorController! + ..onStart() + ..onMove(point.offset); + break; + case FloatingCursorDragState.Update: + floatingCursorController!.onMove(point.offset); + break; + case FloatingCursorDragState.End: + _isFloatingCursorVisible = false; + floatingCursorController!.onStop(); + break; + } + } + + @override + void connectionClosed() { + editorImeLog.info("IME connection was closed"); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_ime_interaction_policies.dart b/super_editor/lib/src/default_editor/document_ime/document_ime_interaction_policies.dart new file mode 100644 index 0000000000..31149e3a59 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/document_ime_interaction_policies.dart @@ -0,0 +1,473 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/document_ime/shared_ime.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; + +/// Widget that opens and closes an [imeConnection] based on the [focusNode] gaining +/// and losing primary focus. +class ImeFocusPolicy extends StatefulWidget { + const ImeFocusPolicy({ + Key? key, + this.focusNode, + required this.inputId, + required this.imeClientFactory, + required this.imeConfiguration, + this.openImeOnPrimaryFocusGain = true, + this.closeImeOnPrimaryFocusLost = false, + this.openImeOnNonPrimaryFocusGain = true, + this.closeImeOnNonPrimaryFocusLost = true, + required this.child, + }) : super(key: key); + + /// The document editor's [FocusNode], which is watched for changes based + /// on this widget's [closeImeOnPrimaryFocusLost] policy. + final FocusNode? focusNode; + + /// The input ID of the widget that owns this [ImeFocusPolicy]. + /// + /// For example, this [ImeFocusPolicy] might be inside of a chat message + /// editor - this [inputId] would uniquely identify the chat message editor + /// vs any other input in the widget tree. It's used to manage IME ownership. + final SuperImeInputId inputId; + + /// Factory method that creates a [TextInputClient], which is used to + /// attach to the platform IME based on this widget's policy. + final TextInputClient Function() imeClientFactory; + + /// The desired [TextInputConfiguration] for the IME connection, used + /// when this widget attaches to the platform IME based on this widget's + /// policy. + final TextInputConfiguration imeConfiguration; + + /// Whether to open an [imeConnection] when the [FocusNode] gains primary focus. + /// + /// Defaults to `false`. + final bool openImeOnPrimaryFocusGain; + + /// Whether to close the [imeConnection] when the [FocusNode] loses primary focus. + /// + /// Defaults to `false`. + final bool closeImeOnPrimaryFocusLost; + + /// Whether to open an [imeConnection] when the [FocusNode] gains NON-primary focus. + /// + /// Defaults to `true`. + final bool openImeOnNonPrimaryFocusGain; + + /// Whether to close the [imeConnection] when the [FocusNode] loses NON-primary focus. + /// + /// Defaults to `true`. + final bool closeImeOnNonPrimaryFocusLost; + + final Widget child; + + @override + State createState() => _ImeFocusPolicyState(); +} + +class _ImeFocusPolicyState extends State { + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); + + // Sync the keyboard with initial focus status. Do this at the end of the + // frame just to make sure that any downstream code that runs when we open/close + // the IME doesn't blow up by calling setState() during the build process. + WidgetsBinding.instance.addPostFrameCallback((_) { + _onFocusChange(); + }); + } + + @override + void didUpdateWidget(ImeFocusPolicy oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.focusNode != oldWidget.focusNode) { + _focusNode.removeListener(_onFocusChange); + _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); + } + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + void _onFocusChange() { + if (!mounted) { + return; + } + + var didTakeOwnership = false; + if (_focusNode.hasFocus && !SuperIme.instance.isOwner(widget.inputId)) { + // We have focus but we don't own the IME. Take it over. + SuperIme.instance.takeOwnership(widget.inputId); + didTakeOwnership = true; + } + + bool shouldOpenIme = false; + if (_focusNode.hasPrimaryFocus && + widget.openImeOnPrimaryFocusGain && + (!SuperIme.instance.isInputAttachedToOS(widget.inputId) || didTakeOwnership)) { + editorPoliciesLog + .info("[${widget.runtimeType}] - Document editor gained primary focus. Opening an IME connection."); + shouldOpenIme = true; + } else if (!_focusNode.hasPrimaryFocus && + _focusNode.hasFocus && + widget.openImeOnNonPrimaryFocusGain && + (!SuperIme.instance.isInputAttachedToOS(widget.inputId) || didTakeOwnership)) { + editorPoliciesLog + .info("[${widget.runtimeType}] - Document editor gained non-primary focus. Opening an IME connection."); + shouldOpenIme = true; + } + + if (shouldOpenIme) { + WidgetsBinding.instance.runAsSoonAsPossible(() { + if (!mounted) { + return; + } + + editorImeLog.finer("[${widget.runtimeType}] - creating new TextInputConnection to IME"); + SuperIme.instance.openConnection( + widget.inputId, + widget.imeClientFactory(), + widget.imeConfiguration, + showKeyboard: true, + ); + }, debugLabel: 'Open IME Connection on Primary Focus Change'); + } + + bool shouldCloseIme = false; + if (!_focusNode.hasPrimaryFocus && widget.closeImeOnPrimaryFocusLost && SuperIme.instance.isOwner(widget.inputId)) { + editorPoliciesLog + .info("[${widget.runtimeType}] - Document editor lost primary focus. Closing the IME connection."); + shouldCloseIme = true; + } else if (!_focusNode.hasFocus && + widget.closeImeOnNonPrimaryFocusLost && + SuperIme.instance.isOwner(widget.inputId)) { + editorPoliciesLog.info("[${widget.runtimeType}] - Document editor lost all focus. Closing the IME connection."); + shouldCloseIme = true; + } + + if (shouldCloseIme) { + SuperIme.instance + ..clearConnection(widget.inputId) + ..releaseOwnership(widget.inputId); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// Widget that enforces policies between IME connections, focus, and document selections. +/// +/// This widget can automatically open and close the software keyboard when the document +/// selection changes, such as when the user places the caret in the middle of a +/// paragraph. +/// +/// This widget can automatically remove the document selection when the editor loses focus. +class DocumentSelectionOpenAndCloseImePolicy extends StatefulWidget { + const DocumentSelectionOpenAndCloseImePolicy({ + Key? key, + required this.focusNode, + this.isEnabled = true, + required this.editor, + required this.selection, + required this.inputId, + required this.imeClientFactory, + required this.imeConfiguration, + this.openKeyboardOnSelectionChange = true, + this.closeKeyboardOnSelectionLost = true, + this.clearSelectionWhenEditorLosesFocus = true, + this.clearSelectionWhenImeConnectionCloses = true, + required this.child, + }) : super(key: key); + + /// The document editor's [FocusNode]. + /// + /// Focus plays a role in multiple policies: + /// + /// * When focus is lost, this widget may clear the editor's selection. + /// + /// * When this widget closes the IME connection, it unfocuses this [focusNode]. + final FocusNode focusNode; + + /// Whether this widget's policies should be enabled. + /// + /// When `false`, this widget does nothing. + final bool isEnabled; + + /// The [Editor] that alters the [selection]. + final Editor editor; + + /// The document editor's current selection. + final ValueListenable selection; + + final SuperImeInputId inputId; + + /// Factory method that creates a [TextInputClient], which is used to + /// attach to the platform IME based on this widget's selection policy. + final TextInputClient Function() imeClientFactory; + + /// The desired [TextInputConfiguration] for the IME connection, used + /// when this widget attaches to the platform IME based on this widget's + /// selection policy. + final TextInputConfiguration imeConfiguration; + + /// Whether the software keyboard should be raised whenever the editor's selection + /// changes, such as when a user taps to place the caret. + /// + /// In a typical app, this property should be `true`. In some apps, the keyboard + /// needs to be closed and opened to reveal special editing controls. In those cases + /// this property should probably be `false`, and the app should take responsibility + /// for opening and closing the keyboard. + final bool openKeyboardOnSelectionChange; + + /// Whether the software keyboard should be closed whenever the editor goes from + /// having a selection to not having a selection. + /// + /// In a typical app, this property should be `true`, because there's no place to + /// apply IME input when there's no editor selection. + final bool closeKeyboardOnSelectionLost; + + /// Whether the document's selection should be removed when the editor loses + /// all focus (not just primary focus). + /// + /// If `true`, when focus moves to a different subtree, such as a popup text + /// field, or a button somewhere else on the screen, the editor will remove + /// its selection. When focus returns to the editor, the previous selection can + /// be restored, but that's controlled by other policies. + /// + /// If `false`, the editor will retain its selection, including a visual caret + /// and selected content, even when the editor doesn't have any focus, and can't + /// process any input. + final bool clearSelectionWhenEditorLosesFocus; + + /// Whether the editor's selection should be removed when the editor closes or loses + /// its IME connection. + /// + /// Defaults to `true`. + /// + /// Apps that include a custom input mode, such as an editing panel that sometimes + /// replaces the software keyboard, should set this to `false` and instead control the + /// IME connection manually. + final bool clearSelectionWhenImeConnectionCloses; + + final Widget child; + + @override + State createState() => _DocumentSelectionOpenAndCloseImePolicyState(); +} + +class _DocumentSelectionOpenAndCloseImePolicyState extends State { + bool _wasAttached = false; + + @override + void initState() { + super.initState(); + + _wasAttached = SuperIme.instance.isInputAttachedToOS(widget.inputId); + SuperIme.instance.addListener(_onConnectionChange); + + widget.focusNode.addListener(_onFocusChange); + + widget.selection.addListener(_onSelectionChange); + if (widget.selection.value != null) { + _onSelectionChange(); + _onConnectionChange(); + } + } + + @override + void didUpdateWidget(DocumentSelectionOpenAndCloseImePolicy oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_onFocusChange); + widget.focusNode.addListener(_onFocusChange); + _onFocusChange(); + } + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + _onSelectionChange(); + } + + if (widget.inputId != oldWidget.inputId) { + onNextFrame((_) { + // We switched IME connection references, which means we may have switched + // from one with a connection to one without a connection, or vis-a-versa. + // Run our connection change check. + // + // Also, we run this at the end of the frame, because this call might clear + // the document selection, which might cause other widgets in the tree + // to call setState(), which would cause an exception during didUpdateWidget(). + _onConnectionChange(); + }); + } + } + + @override + void dispose() { + widget.focusNode.removeListener(_onFocusChange); + widget.selection.removeListener(_onSelectionChange); + SuperIme.instance.removeListener(_onConnectionChange); + super.dispose(); + } + + void _onFocusChange() { + if (!widget.isEnabled) { + return; + } + + if (!widget.focusNode.hasFocus && widget.clearSelectionWhenEditorLosesFocus) { + editorPoliciesLog.info("[${widget.runtimeType}] - clearing editor selection because the editor lost all focus"); + widget.editor.execute([ + const ClearSelectionRequest(), + ]); + } + + if (!widget.focusNode.hasFocus) { + widget.editor.execute([ + const ClearComposingRegionRequest(), + ]); + } + } + + void _onSelectionChange() { + if (!widget.isEnabled) { + return; + } + + if (widget.selection.value != null && widget.focusNode.hasPrimaryFocus && widget.openKeyboardOnSelectionChange) { + // There's a new document selection, and our policy wants the keyboard to be + // displayed whenever the selection changes. Show the keyboard. + var didTakeOwnership = false; + if (!SuperIme.instance.isOwner(widget.inputId)) { + SuperIme.instance.takeOwnership(widget.inputId); + didTakeOwnership = true; + } + + if (!SuperIme.instance.isInputAttachedToOS(widget.inputId) || didTakeOwnership) { + WidgetsBinding.instance.runAsSoonAsPossible(() { + if (!mounted) { + return; + } + // Ensure we didn't lose ownership across frame boundaries. + if (!SuperIme.instance.isOwner(widget.inputId)) { + return; + } + // Ensure that a connection wasn't opened between frames. + if (SuperIme.instance.isInputAttachedToOS(widget.inputId)) { + return; + } + + editorPoliciesLog + .info("[${widget.runtimeType}] - opening the IME keyboard because the document selection changed"); + editorImeConnectionLog.finer("[${widget.runtimeType}] - creating new TextInputConnection to IME"); + SuperIme.instance.openConnection( + widget.inputId, + widget.imeClientFactory(), + widget.imeConfiguration, + showKeyboard: true, + ); + }, debugLabel: 'Open IME Connection on Selection Change'); + } else { + SuperIme.instance.getImeConnectionForOwner(widget.inputId)!.show(); + } + } else if (SuperIme.instance.isInputAttachedToOS(widget.inputId) && + widget.selection.value == null && + widget.closeKeyboardOnSelectionLost) { + // There's no document selection, and our policy wants the keyboard to be + // closed whenever the editor loses its selection. Close the keyboard. + editorPoliciesLog + .info("[${widget.runtimeType}] - closing the IME keyboard because the document selection was cleared"); + SuperIme.instance.clearConnection(widget.inputId); + } + } + + void _onConnectionChange() { + if (!mounted) { + return; + } + + _clearSelectionIfDesired(); + + _wasAttached = SuperIme.instance.isInputAttachedToOS(widget.inputId); + } + + void _clearSelectionIfDesired() { + if (!widget.isEnabled) { + // None of this widget's policies are activated. + return; + } + + if (!widget.clearSelectionWhenImeConnectionCloses) { + // This policy isn't activated. + return; + } + + if (!_wasAttached || SuperIme.instance.isInputAttachedToOS(widget.inputId)) { + // We didn't go from closed to open. Our policy doesn't apply. + return; + } + + if (SuperIme.instance.owner != widget.inputId && SuperIme.instance.owner?.role == widget.inputId.role) { + // Our SuperEditor has been replaced by a different one, which now owns the IME, + // but the other SuperEditor is playing the same role. Our widget tree got + // disposed and replaced by another widget tree. + // + // Since the role of the owning SuperEditor didn't change, we don't want to + // mess with selection, IME, or anything else. Leave it alone for the new + // version of us. + return; + } + + final hasNonPrimaryFocus = widget.focusNode.hasFocus && !widget.focusNode.hasPrimaryFocus; + if (hasNonPrimaryFocus) { + // We don't want to mess with selection when the editor has non-primary focus. Non-primary + // focus means that the editor is in the focus path, but isn't receiving input. The editor + // might currently be deferring to something like a URL toolbar, where the user is typing + // a URL. The user expects the editor to keep its current selection while they type the URL. + editorPoliciesLog.info( + "[${widget.runtimeType}] - policy wants to clear selection because IME closed, but the editor has non-primary focus, so we aren't clearing the selection"); + return; + } + + // The IME connection closed and our policy wants us to clear the document + // selection when that happens. + editorPoliciesLog.info( + "[${widget.runtimeType}] - clearing document selection because the IME closed and the editor didn't have non-primary focus"); + widget.editor.execute([ + const ClearSelectionRequest(), + ]); + + // If we clear SuperEditor's selection, but leave SuperEditor with primary focus, + // then SuperEditor will automatically place the caret at the end of the document. + // This is because SuperEditor always expects a place for text input when it + // has primary focus. To prevent this from happening, we explicitly remove focus + // from SuperEditor. + widget.focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_input_ime.dart b/super_editor/lib/src/default_editor/document_ime/document_input_ime.dart new file mode 100644 index 0000000000..7a791b0a0a --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/document_input_ime.dart @@ -0,0 +1,26 @@ +export 'document_delta_editing.dart'; +export 'document_ime_communication.dart'; +export 'document_ime_interaction_policies.dart'; +export 'document_serialization.dart'; +export 'ime_decoration.dart'; +export 'ime_keyboard_control.dart'; +export 'mobile_toolbar.dart'; +export 'supereditor_ime_interactor.dart'; + +/// This file exports various document IME tools. +/// +/// The term Input Method Engine (IME) refers to an operating system's +/// intermediary between the user's input, such as through a software +/// keyboard, and the app that receives the input. The IME might make +/// changes to the user's input, such as correcting spelling, or +/// inserting emojis. +/// +/// IME input is the only form of input available on mobile devices, +/// unless the user connects a physical keyboard. For example, the +/// software keyboard that appears on the screen of a mobile device +/// talks to the OS, not the app. Once the OS receives input from +/// the user through the software keyboard, the OS forwards a version +/// of that input to the appropriate app. +/// +/// The tools in this package are all about enabling various behaviors +/// and policies for receiving and applying IME input. diff --git a/super_editor/lib/src/default_editor/document_ime/document_serialization.dart b/super_editor/lib/src/default_editor/document_ime/document_serialization.dart new file mode 100644 index 0000000000..6c558542ec --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/document_serialization.dart @@ -0,0 +1,471 @@ +import 'dart:math'; + +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// Serializes a [Document] and [DocumentSelection] into a form that's understood by +/// the Input Method Engine (IME), and vis-a-versa. +/// +/// The IME only understands strings of plain text. Therefore, to make [Document] content +/// available for IME editing, the [Document] structure needs to be serialized into a run of text. +/// +/// When the IME alters the given content, that plain text needs to be deserialized back into +/// a [Document] structure. +/// +/// This class implements both [Document] serialization and deserialization for the IME. +class DocumentImeSerializer { + static const _leadingCharacter = '. '; + + DocumentImeSerializer( + this._doc, + this.selection, + this.composingRegion, [ + // Always prepend the placeholder characters because + // changing the editing value when the IME is composing + // causes the IME composition to restart. + // + // See https://github.com/superlistapp/super_editor/issues/1641 for details. + this._prependedCharacterPolicy = PrependedCharacterPolicy.include, + ]) { + _serialize(); + } + + final Document _doc; + DocumentSelection selection; + DocumentRange? composingRegion; + + /// Maps sub-strings of the IME value to node IDs. + final imeRangesToDocTextNodes = {}; + + /// Maps node IDs to sub-strings of the IME value. + final docTextNodesToImeRanges = {}; + + final selectedNodes = []; + late String imeText; + final PrependedCharacterPolicy _prependedCharacterPolicy; + String _prependedPlaceholder = ''; + + void _serialize() { + editorImeLog.fine("Creating an IME model from document, selection, and composing region"); + final buffer = StringBuffer(); + int characterCount = 0; + + if (_shouldPrependPlaceholder()) { + // Put an arbitrary character at the front of the text so that + // the IME will report backspace buttons when the caret sits at + // the beginning of the node. For example, the caret is at the + // beginning of some text and we want to combine this text with + // the text above it when the user presses backspace. + // + // Text above... + // |The selected text node. + _prependedPlaceholder = _leadingCharacter; + buffer.write(_prependedPlaceholder); + characterCount = _prependedPlaceholder.length; + } else { + _prependedPlaceholder = ''; + } + + selectedNodes.clear(); + selectedNodes.addAll(_doc.getNodesInContentOrder(selection)); + for (int i = 0; i < selectedNodes.length; i += 1) { + // Append a newline character before appending another node's text. + // + // The choice to separate each node with a newline was a judgement call. + // There is no OS-level expectation for how structured content should + // collapse down to IME content. + if (i != 0) { + buffer.write('\n'); + characterCount += 1; + } + + final node = selectedNodes[i]; + if (node is! TextNode) { + buffer.write('~'); + characterCount += 1; + + final imeRange = TextRange(start: characterCount - 1, end: characterCount); + imeRangesToDocTextNodes[imeRange] = node.id; + docTextNodesToImeRanges[node.id] = imeRange; + + continue; + } + + // Cache mappings between the IME text range and the document position + // so that we can easily convert between the two, when requested. + final imeRange = TextRange(start: characterCount, end: characterCount + node.text.length); + editorImeLog.finer("IME range $imeRange -> text node content '${node.text.toPlainText()}'"); + imeRangesToDocTextNodes[imeRange] = node.id; + docTextNodesToImeRanges[node.id] = imeRange; + + // Concatenate this node's text with the previous nodes. + buffer.write(node.text.toPlainText()); + characterCount += node.text.length; + } + + imeText = buffer.toString(); + editorImeLog.fine("IME serialization:\n'$imeText'"); + } + + bool _shouldPrependPlaceholder() { + if (_prependedCharacterPolicy == PrependedCharacterPolicy.include) { + // The client explicitly requested prepended characters. This is + // useful, for example, when a client has an existing serialization that + // includes prepended characters and wants to compare that serialization + // to a new serialization. The client wants to ensure that the new + // serialization has prepended characters, too. + return true; + } else if (_prependedCharacterPolicy == PrependedCharacterPolicy.exclude) { + return false; + } + + // We want to prepend an arbitrary placeholder character whenever the + // user's selection is collapsed at the beginning of a node. Without the + // arbitrary character, the IME would assume that there's no content + // before the current node and therefore it wouldn't report the backspace + // button. + final selectedNode = _doc.getNode(selection.extent)!; + return selection.isCollapsed && selection.extent.nodePosition == selectedNode.beginningPosition; + } + + bool get didPrependPlaceholder => _prependedPlaceholder.isNotEmpty; + + DocumentSelection? imeToDocumentSelection(TextSelection imeSelection) { + editorImeLog.fine("Creating doc selection from IME selection: $imeSelection"); + if (!imeSelection.isValid) { + editorImeLog.fine("The IME selection is empty. Returning a null document selection."); + return null; + } + + if (didPrependPlaceholder) { + // The IME might be trying to select our invisible prepended characters. + // If so, we need to adjust the IME selection bounds. + if ((imeSelection.isCollapsed && imeSelection.extentOffset < _prependedPlaceholder.length) || + (imeSelection.start < _prependedPlaceholder.length && imeSelection.end == _prependedPlaceholder.length)) { + // The IME is only trying to select our invisible characters. Return null + // for an empty document selection. + editorImeLog.fine("The IME only selected invisible characters. Returning a null document selection."); + return null; + } else if (imeSelection.start < _prependedPlaceholder.length) { + // The IME is trying to select some invisible characters and some real + // characters. Remove the invisible characters from the IME selection before + // converting it to a document selection. + editorImeLog.fine("Removing invisible characters from IME selection."); + imeSelection = imeSelection.copyWith( + baseOffset: max(imeSelection.baseOffset, _prependedPlaceholder.length), + extentOffset: max(imeSelection.extentOffset, _prependedPlaceholder.length), + ); + editorImeLog.fine("Adjusted IME selection is: $imeSelection"); + } else { + editorImeLog.fine("The IME only selected visible characters. No adjustment necessary."); + } + } else { + editorImeLog.fine("The serialization doesn't have any invisible characters. No adjustment necessary."); + } + + editorImeLog.fine("Calculating the base DocumentPosition for the DocumentSelection"); + final base = _imeToDocumentPosition( + imeSelection.base, + isUpstream: imeSelection.base.affinity == TextAffinity.upstream, + ); + editorImeLog.fine("Selection base: $base"); + + editorImeLog.fine("Calculating the extent DocumentPosition for the DocumentSelection"); + final extent = _imeToDocumentPosition( + imeSelection.extent, + isUpstream: imeSelection.extent.affinity == TextAffinity.upstream, + ); + editorImeLog.fine("Selection extent: $extent"); + + return DocumentSelection(base: base, extent: extent); + } + + DocumentRange? imeToDocumentRange(TextRange imeRange) { + editorImeLog.fine("Creating doc range from IME range: $imeRange"); + if (!imeRange.isValid) { + editorImeLog.fine("The IME range is empty. Returning null document range."); + // The range is empty. Return null. + return null; + } + + if (didPrependPlaceholder) { + // The IME might be trying to select our invisible prepended characters. + // If so, we need to adjust the IME selection bounds. + if ((imeRange.isCollapsed && imeRange.end < _prependedPlaceholder.length) || + (imeRange.start < _prependedPlaceholder.length && imeRange.end == _prependedPlaceholder.length)) { + // The IME is only trying to select our invisible characters. Return null + // for an empty document range. + editorImeLog + .fine("The IME tried to create a range around invisible characters. Returning null document range."); + return null; + } else { + // The IME is trying to select some invisible characters and some real + // characters. Remove the invisible characters from the IME range before + // converting it to a document range. + editorImeLog.fine("Removing arbitrary character from IME range."); + editorImeLog.fine("Before adjustment, range: $imeRange"); + editorImeLog.fine("Prepended characters length: ${_prependedPlaceholder.length}"); + imeRange = TextRange( + start: max(imeRange.start, _prependedPlaceholder.length), + end: max(imeRange.end, _prependedPlaceholder.length), + ); + editorImeLog.fine("Adjusted IME range to: $imeRange"); + } + } else { + editorImeLog.fine("The IME is only composing visible characters. No adjustment necessary."); + } + + return DocumentRange( + start: _imeToDocumentPosition( + TextPosition(offset: imeRange.start), + isUpstream: false, + ), + end: _imeToDocumentPosition( + TextPosition(offset: imeRange.end), + isUpstream: false, + ), + ); + } + + /// Returns `true` if the [imePosition] is inside the prepended placeholder, + /// or `false` otherwise. + /// + /// The placeholder is a sequence of characters that are sent to the IME, but are + /// invisible to the user. + bool isPositionInsidePlaceholder(TextPosition imePosition) { + if (!didPrependPlaceholder) { + return false; + } + + if (imePosition.offset <= -1) { + // The given imePosition might be a selection or a composing region. + // The IME composing position always has a value. When the IME wants + // to describe the absence of a composing region, the offset is set to -1. + // Therefore, this position refers to the absence of a composing region, so + // this position isn't sitting in the placeholder. + return false; + } + + if (imePosition.offset >= _prependedPlaceholder.length) { + return false; + } + + return true; + } + + /// Returns the first visible position in the IME content. + /// + /// If a placeholder is prepended, returns the first position after the placeholder, + /// otherwise, returns the first position. + TextPosition get firstVisiblePosition { + return didPrependPlaceholder // + ? TextPosition(offset: _prependedPlaceholder.length) + : const TextPosition(offset: 0); + } + + DocumentPosition _imeToDocumentPosition(TextPosition imePosition, {required bool isUpstream}) { + for (final range in imeRangesToDocTextNodes.keys) { + if (range.start <= imePosition.offset && imePosition.offset <= range.end) { + final node = _doc.getNodeById(imeRangesToDocTextNodes[range]!)!; + + if (node is TextNode) { + return DocumentPosition( + nodeId: imeRangesToDocTextNodes[range]!, + nodePosition: TextNodePosition(offset: imePosition.offset - range.start), + ); + } else { + if (imePosition.offset <= range.start) { + // Return a position at the start of the node. + return DocumentPosition( + nodeId: node.id, + nodePosition: node.beginningPosition, + ); + } else { + // Return a position at the end of the node. + return DocumentPosition( + nodeId: node.id, + nodePosition: node.endPosition, + ); + } + } + } + } + + editorImeLog.shout("---------------DocumentImeSerializer----------------------"); + editorImeLog.shout("Couldn't map an IME position to a document position."); + editorImeLog.shout("Desired IME position: '$imePosition'"); + editorImeLog.shout(""); + editorImeLog.shout("IME text: '$imeText'"); + editorImeLog.shout("IME prepended placeholder: '$_prependedPlaceholder'"); + editorImeLog.shout(""); + editorImeLog.shout("Document selection: $selection"); + editorImeLog.shout("Document composing region: $composingRegion"); + editorImeLog.shout(""); + editorImeLog.shout("IME Ranges to text nodes:"); + for (final entry in imeRangesToDocTextNodes.entries) { + editorImeLog.shout(" - IME range: ${entry.key} -> Text node: ${entry.value}"); + editorImeLog.shout(" ^ node content: '${(_doc.getNodeById(entry.value) as TextNode).text.toPlainText()}'"); + } + editorImeLog.shout("-----------------------------------------------------------"); + throw FailedToMapImePositionToDocumentPositionException( + imeText: imeText, + imePosition: imePosition, + imeRangesToDocumentRanges: Map.from(imeRangesToDocTextNodes), + ); + } + + TextSelection documentToImeSelection(DocumentSelection docSelection) { + editorImeLog.fine("Converting doc selection to ime selection: $docSelection"); + final selectionAffinity = _doc.getAffinityForSelection(docSelection); + final startImePosition = _documentToImePosition(docSelection.base); + final endImePosition = _documentToImePosition(docSelection.extent); + + editorImeLog.fine("Start IME position: $startImePosition"); + editorImeLog.fine("End IME position: $endImePosition"); + return TextSelection( + baseOffset: startImePosition.offset, + extentOffset: endImePosition.offset, + affinity: selectionAffinity, + ); + } + + TextRange documentToImeRange(DocumentRange? documentRange) { + editorImeLog.fine("Converting doc range to ime range: $documentRange"); + if (documentRange == null) { + editorImeLog.fine("The document range is null. Returning an empty IME range."); + return const TextRange(start: -1, end: -1); + } + + final startImePosition = _documentToImePosition(documentRange.start); + final endImePosition = _documentToImePosition(documentRange.end); + + editorImeLog.fine("After converting DocumentRange to TextRange:"); + editorImeLog.fine("Start IME position: $startImePosition"); + editorImeLog.fine("End IME position: $endImePosition"); + return TextRange( + start: startImePosition.offset, + end: endImePosition.offset, + ); + } + + TextPosition _documentToImePosition(DocumentPosition docPosition) { + editorImeLog.fine("Converting DocumentPosition to IME TextPosition: $docPosition"); + final imeRange = docTextNodesToImeRanges[docPosition.nodeId]; + if (imeRange == null) { + throw Exception("No such document position in the IME content: $docPosition"); + } + + final nodePosition = docPosition.nodePosition; + + if (nodePosition is UpstreamDownstreamNodePosition) { + if (nodePosition.affinity == TextAffinity.upstream) { + editorImeLog.fine("The doc position is an upstream position on a block."); + // Return the text position before the special character, + // e.g., "|~". + return TextPosition(offset: imeRange.start); + } else { + editorImeLog.fine("The doc position is a downstream position on a block."); + // Return the text position after the special character, + // e.g., "~|". + return TextPosition(offset: imeRange.start + 1); + } + } + + if (nodePosition is TextNodePosition) { + return TextPosition(offset: imeRange.start + (docPosition.nodePosition as TextNodePosition).offset); + } + + throw FailedToMapDocumentPositionToImePositionException( + document: _doc, + selection: selection, + documentNodesToImeRanges: docTextNodesToImeRanges, + ); + } + + TextEditingValue toTextEditingValue() { + editorImeLog.fine("Creating TextEditingValue from document. Selection: $selection"); + editorImeLog.fine("Text:\n'$imeText'"); + final imeSelection = documentToImeSelection(selection); + editorImeLog.fine("Selection: $imeSelection"); + final imeComposingRegion = documentToImeRange(composingRegion); + editorImeLog.fine("Composing region: $imeComposingRegion"); + + return TextEditingValue( + text: imeText, + selection: imeSelection, + composing: imeComposingRegion, + ); + } +} + +enum PrependedCharacterPolicy { + automatic, + include, + exclude, +} + +class FailedToMapImePositionToDocumentPositionException implements Exception { + const FailedToMapImePositionToDocumentPositionException({ + required this.imeText, + required this.imePosition, + required this.imeRangesToDocumentRanges, + }); + + final String imeText; + final TextPosition imePosition; + final Map imeRangesToDocumentRanges; + + @override + String toString() { + final buffer = StringBuffer("Couldn't map an IME position to a document position.\n") + ..writeln(" • IME text: '$imeText'") + ..writeln(" • IME position: $imePosition"); + + buffer.writeln(" • IME to Doc Ranges:"); + for (final entry in imeRangesToDocumentRanges.entries) { + buffer.writeln(" > IME range: ${entry.key} -> Node: '${entry.value}'"); + } + + return buffer.toString(); + } +} + +class FailedToMapDocumentPositionToImePositionException implements Exception { + const FailedToMapDocumentPositionToImePositionException({ + required this.document, + required this.selection, + required this.documentNodesToImeRanges, + }); + + final Document document; + final DocumentSelection selection; + final Map documentNodesToImeRanges; + + @override + String toString() { + final buffer = StringBuffer("Couldn't map a document position to an IME position.\n"); + + buffer.writeln(" • Document:"); + for (final node in document) { + buffer.writeln(" > node ID: ${node.id}, type: ${node.runtimeType}"); + if (node is TextNode) { + buffer.writeln(" - text in node: '${node.text.toPlainText()}'"); + } + } + + buffer.writeln(" • Document selection:"); + buffer.writeln(" > base: ${selection.base}"); + buffer.writeln(" > extent: ${selection.extent}"); + + buffer.writeln(" • IME to Doc Ranges:"); + for (final entry in documentNodesToImeRanges.entries) { + buffer.writeln(" > Node: ${entry.key} -> IME range: '${entry.value}'"); + } + + return buffer.toString(); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/ime_decoration.dart b/super_editor/lib/src/default_editor/document_ime/ime_decoration.dart new file mode 100644 index 0000000000..174e38d1be --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/ime_decoration.dart @@ -0,0 +1,172 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// Base class for [TextInputConnection] decorators. +/// +/// A decorator is an object that forwards calls to another, existing implementation +/// of a given interface, but adds or alters some of those behaviors. +abstract class TextInputConnectionDecorator implements TextInputConnection { + TextInputConnectionDecorator([this.client]); + + TextInputConnection? client; + + @override + bool get attached => client?.attached ?? false; + + @override + bool get scribbleInProgress => client?.scribbleInProgress ?? false; + + @override + void show() => client?.show(); + + @override + void setEditingState(TextEditingValue value) => client?.setEditingState(value); + + @override + void updateConfig(TextInputConfiguration configuration) => client?.updateConfig(configuration); + + @override + void setCaretRect(Rect rect) => client?.setCaretRect(rect); + + @override + void setSelectionRects(List selectionRects) => client?.setSelectionRects(selectionRects); + + @override + void setComposingRect(Rect rect) => client?.setComposingRect(rect); + + @override + void setStyle( + {required String? fontFamily, + required double? fontSize, + required FontWeight? fontWeight, + required TextDirection textDirection, + required TextAlign textAlign}) => + client?.setStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + textDirection: textDirection, + textAlign: textAlign); + + @override + void requestAutofill() => client?.requestAutofill(); + + @override + void setEditableSizeAndTransform(Size editableBoxSize, Matrix4 transform) => + client?.setEditableSizeAndTransform(editableBoxSize, transform); + + @override + void connectionClosedReceived() => client?.connectionClosedReceived(); + + @override + void close() => client?.close(); +} + +/// A [DeltaTextInputClient] that forwards all calls to the given [_client]. +/// +/// Subclass [DeltaTextInputClientDecorator] to override specific +/// [DeltaTextInputClient] messages. To add behavior, instead of replacing it, +/// call the `super` method within an override. +class DeltaTextInputClientDecorator with DeltaTextInputClient, TextInputClient { + DeltaTextInputClientDecorator([this._client]); + + /// Returns `true` if [client] is the current client for this decorator. + /// + /// This check is provided to users so that users can check if they're still + /// the client before `null`'ing it out. E.g., Client1 registers itself as + /// the client, then Client2 takes over and registers itself as the client, + /// and then finally Client1 disposes and needs to know whether to remove + /// itself as the client, or not. + bool isCurrentClient(DeltaTextInputClient client) => _client == client; + + set client(DeltaTextInputClient? client) { + _client = client; + } + + DeltaTextInputClient? _client; + + @override + AutofillScope? get currentAutofillScope => _client?.currentAutofillScope; + + @override + TextEditingValue? get currentTextEditingValue => _client?.currentTextEditingValue; + + @override + void insertTextPlaceholder(Size size) { + _client?.insertTextPlaceholder(size); + } + + @override + void performAction(TextInputAction action) { + _client?.performAction(action); + } + + @override + void performPrivateCommand(String action, Map data) { + _client?.performPrivateCommand(action, data); + } + + @override + void performSelector(String selectorName) { + _client?.performSelector(selectorName); + } + + @override + void removeTextPlaceholder() { + _client?.removeTextPlaceholder(); + } + + @override + void showAutocorrectionPromptRect(int start, int end) { + _client?.showAutocorrectionPromptRect(start, end); + } + + @override + void showToolbar() { + _client?.showToolbar(); + } + + @override + void updateEditingValue(TextEditingValue value) { + _client?.updateEditingValue(value); + } + + @override + void updateEditingValueWithDeltas(List textEditingDeltas) { + _client?.updateEditingValueWithDeltas(textEditingDeltas); + } + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + _client?.updateFloatingCursor(point); + } + + @override + void connectionClosed() { + _client?.connectionClosed(); + } +} + +/// A [DeltaTextInputClientDecorator] that notifies [_onConnectionClosed] when +/// the IME connection closes. +/// +/// This decorator is needed because [TextInputConnection] has no way to listen +/// for when its connection is closed. By wrapping a [TextInputClient] with +/// this decorator, the code that owns the [TextInputConnection] can receive +/// a notification when the connection closes. +class ClosureAwareDeltaTextInputClientDecorator extends DeltaTextInputClientDecorator { + ClosureAwareDeltaTextInputClientDecorator( + this._onConnectionClosed, [ + DeltaTextInputClient? client, + ]) : super(client); + + final VoidCallback _onConnectionClosed; + + @override + void connectionClosed() { + editorImeLog.fine("[ClosureAwareDeltaTextInputClientDecorator] - IME connection was closed"); + _onConnectionClosed(); + _client?.connectionClosed(); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart b/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart new file mode 100644 index 0000000000..a0d32135ad --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart @@ -0,0 +1,199 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/default_editor/document_ime/shared_ime.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// Widget that opens and closes the software keyboard, when requested. +/// +/// This widget's [State] object implements [SoftwareKeyboardControllerDelegate], +/// which can be controlled with a [SoftwareKeyboardController]. +/// +/// Opening the software keyboard requires that a connection be established to the +/// platform IME. Therefore, this widget requires [createImeClient] and [createImeConfiguration] +/// to establish that connection, if it doesn't exist already. +class SoftwareKeyboardOpener extends StatefulWidget { + const SoftwareKeyboardOpener({ + Key? key, + required this.controller, + required this.inputId, + required this.createImeClient, + required this.createImeConfiguration, + required this.child, + }) : super(key: key); + + final SoftwareKeyboardController? controller; + + final SuperImeInputId inputId; + + final TextInputClient Function() createImeClient; + + final TextInputConfiguration Function() createImeConfiguration; + + final Widget child; + + @override + State createState() => _SoftwareKeyboardOpenerState(); +} + +class _SoftwareKeyboardOpenerState extends State implements SoftwareKeyboardControllerDelegate { + @override + void initState() { + super.initState(); + widget.controller?.attach(this); + } + + @override + void didUpdateWidget(SoftwareKeyboardOpener oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.controller != oldWidget.controller) { + oldWidget.controller?.detach(); + widget.controller?.attach(this); + } + } + + @override + void dispose() { + // Detach from the controller at the end of the frame, so that + // ancestor widgets can still call `close()` on the keyboard in + // their `dispose()` methods. If we `detach()` right now, the + // ancestor widgets would cause errors in their `dispose()` methods. + WidgetsBinding.instance.scheduleFrameCallback((timeStamp) { + // Check that we're still the delegate at the end of the frame, because + // some other widget may have replaced us as the delegate. + if (widget.controller?._delegate == this) { + widget.controller?.detach(); + } + }); + super.dispose(); + } + + bool get _ownsIme => SuperIme.instance.isOwner(widget.inputId); + + @override + bool get isConnectedToIme => SuperIme.instance.isInputAttachedToOS(widget.inputId); + + @override + void open({ + required int viewId, + }) { + if (!_ownsIme) { + SuperIme.instance.takeOwnership(widget.inputId); + } + + editorImeLog.info("[SoftwareKeyboard] - showing keyboard"); + SuperIme.instance.openConnection( + widget.inputId, + widget.createImeClient(), + widget.createImeConfiguration(), + showKeyboard: true, + ); + } + + @override + void hide() { + // Wait until end of frame to try to hide the keyboard so that all IME ownership + // changes have time to finish, and we can check if we're the final owner. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_ownsIme) { + editorImeLog.info("[SoftwareKeyboard] - tried to hide keyboard, but we don't own IME (${widget.inputId})"); + return; + } + + SystemChannels.textInput.invokeListMethod("TextInput.hide"); + }); + } + + @override + void close() { + if (!_ownsIme) { + editorImeLog.info("[SoftwareKeyboard] - tried to close keyboard, but we don't own IME (${widget.inputId})"); + return; + } + + editorImeLog.info("[SoftwareKeyboard] - closing IME connection."); + SuperIme.instance.clearConnection(widget.inputId); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// `SuperEditor` controller that opens and closes the software keyboard. +/// +/// A [SoftwareKeyboardController] must be attached to a +/// [SoftwareKeyboardControllerDelegate] to open and close the software keyboard. +class SoftwareKeyboardController { + SoftwareKeyboardControllerDelegate? _delegate; + + /// Whether this controller is currently attached to a delegate that + /// knows how to open and close the software keyboard. + bool get hasDelegate => _delegate != null; + + /// Attaches this controller to a delegate that knows how to open and + /// close the software keyboard. + void attach(SoftwareKeyboardControllerDelegate delegate) { + editorImeLog.finer("[SoftwareKeyboardController] - Attaching to delegate: $delegate"); + _delegate = delegate; + } + + /// Detaches this controller from its delegate. + /// + /// This controller can't open or close the software keyboard while + /// detached from a delegate that knows how to make that happen. + void detach() { + editorImeLog.finer("[SoftwareKeyboardController] - Detaching from delegate: $_delegate"); + _delegate = null; + } + + /// Whether the delegate is currently connected to the platform IME. + bool get isConnectedToIme { + assert(hasDelegate); + return _delegate?.isConnectedToIme ?? false; + } + + /// Opens the software keyboard. + /// + /// The [viewId] is required do determine the view that the text input belongs to. You can call + /// `View.of(context).viewId` to get the current view's ID. + void open({ + required int viewId, + }) { + assert(hasDelegate); + _delegate?.open(viewId: viewId); + } + + void hide() { + assert(hasDelegate); + _delegate?.hide(); + } + + /// Closes the software keyboard. + void close() { + assert(hasDelegate); + _delegate?.close(); + } +} + +/// Delegate that's attached to a [SoftwareKeyboardController], which implements +/// the opening and closing of the software keyboard. +abstract class SoftwareKeyboardControllerDelegate { + /// Whether this delegate is currently connected to the platform IME. + bool get isConnectedToIme; + + /// Opens the software keyboard. + /// + /// The [viewId] is required do determine the view that the text input belongs to. You can call + /// `View.of(context).viewId` to get the current view's ID. + void open({ + required int viewId, + }); + + /// Hides the software keyboard without closing the IME connection. + void hide(); + + /// Closes the software keyboard, and the IME connection. + void close(); +} diff --git a/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart b/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart new file mode 100644 index 0000000000..4c0576ca84 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart @@ -0,0 +1,501 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/common_editor_operations.dart'; +import 'package:super_editor/src/default_editor/list_items.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/overlay_with_groups.dart'; + +import '../attributions.dart'; + +/// A mobile document editing toolbar, which is displayed in the application +/// [Overlay], and is mounted just above the software keyboard. +/// +/// Despite displaying the toolbar in the application [Overlay], [KeyboardEditingToolbar] +/// also (optionally) inserts some blank space into the current subtree, which takes up +/// the same amount of height as the toolbar that appears in the [Overlay]. +/// +/// Provides document editing capabilities, like converting paragraphs to blockquotes +/// and list items, and inserting horizontal rules. +class KeyboardEditingToolbar extends StatefulWidget { + const KeyboardEditingToolbar({ + Key? key, + required this.editor, + required this.document, + required this.composer, + required this.commonOps, + this.brightness, + this.takeUpSameSpaceAsToolbar = false, + }) : super(key: key); + + final Editor editor; + final Document document; + final DocumentComposer composer; + final CommonEditorOperations commonOps; + + @Deprecated("To change the brightness, wrap KeyboardEditingToolbar with a Theme, instead") + final Brightness? brightness; + + /// Whether this widget should take up empty space in the current subtree that + /// matches the space taken up by the toolbar in the application [Overlay]. + /// + /// If `true`, space is taken up that's equivalent to the toolbar height. If + /// `false`, no space is taken up by this widget at all. + /// + /// Taking up empty space is useful when this widget is positioned at the same + /// location on the screen as the toolbar that's in the overlay. By adding extra + /// space, other content in this subtree won't flow behind the toolbar in the + /// [Overlay]. + final bool takeUpSameSpaceAsToolbar; + + @override + State createState() => _KeyboardEditingToolbarState(); +} + +class _KeyboardEditingToolbarState extends State with WidgetsBindingObserver { + late KeyboardEditingToolbarOperations _toolbarOps; + + final _portalController = GroupedOverlayPortalController(displayPriority: OverlayGroupPriority.windowChrome); + + double _toolbarHeight = 0; + + @override + void initState() { + super.initState(); + + _toolbarOps = KeyboardEditingToolbarOperations( + editor: widget.editor, + document: widget.document, + composer: widget.composer, + commonOps: widget.commonOps, + ); + + WidgetsBinding.instance.runAsSoonAsPossible(() { + _portalController.show(); + }); + } + + @override + void didUpdateWidget(KeyboardEditingToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + + _toolbarOps = KeyboardEditingToolbarOperations( + editor: widget.editor, + document: widget.document, + composer: widget.composer, + commonOps: widget.commonOps, + ); + } + + @override + void dispose() { + if (_portalController.isShowing) { + _portalController.hide(); + } + super.dispose(); + } + + void _onToolbarLayout(double toolbarHeight) { + if (toolbarHeight == _toolbarHeight) { + return; + } + + // The toolbar in the overlay changed its height. Our child needs to take up the + // same amount of height so that content doesn't go behind our toolbar. Rebuild + // with the latest toolbar height and take up an equal amount of height. + setStateAsSoonAsPossible(() { + _toolbarHeight = toolbarHeight; + }); + } + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _portalController, + overlayChildBuilder: _buildToolbarOverlay, + // Take up empty space that's as tall as the toolbar so that other content + // doesn't layout behind it. + child: SizedBox(height: widget.takeUpSameSpaceAsToolbar ? _toolbarHeight : 0), + ); + } + + Widget _buildToolbarOverlay(BuildContext context) { + final selection = widget.composer.selection; + if (selection == null) { + return const SizedBox(); + } + + return KeyboardHeightBuilder(builder: (context, keyboardHeight) { + return Padding( + // Add padding that takes up the height of the software keyboard so + // that the toolbar sits just above the keyboard. + padding: EdgeInsets.only(bottom: keyboardHeight), + child: Align( + alignment: Alignment.bottomLeft, + child: _buildTheming( + child: Builder( + // Add a Builder so that _buildToolbar() uses theming from _buildTheming(). + builder: (themedContext) { + return _buildToolbar(themedContext); + }, + ), + ), + ), + ); + }); + } + + Widget _buildTheming({ + required Widget child, + }) { + final brightness = widget.brightness ?? MediaQuery.of(context).platformBrightness; + + return Theme( + data: Theme.of(context).copyWith( + brightness: brightness, + disabledColor: + brightness == Brightness.light ? Colors.black.withValues(alpha: 0.5) : Colors.white.withValues(alpha: 0.5), + ), + child: IconTheme( + data: IconThemeData( + color: brightness == Brightness.light ? Colors.black : Colors.white, + ), + child: child, + ), + ); + } + + Widget _buildToolbar(BuildContext context) { + final selection = widget.composer.selection!; + + return Material( + child: Container( + width: double.infinity, + height: 48, + color: Theme.of(context).brightness == Brightness.light ? const Color(0xFFDDDDDD) : const Color(0xFF222222), + child: LayoutBuilder(builder: (context, constraints) { + _onToolbarLayout(constraints.maxHeight); + + return Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ListenableBuilder( + listenable: widget.composer, + builder: (context, _) { + final selectedNode = widget.document.getNodeById(selection.extent.nodeId); + final isSingleNodeSelected = selection.extent.nodeId == selection.base.nodeId; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: selectedNode is TextNode ? _toolbarOps.toggleBold : null, + icon: const Icon(Icons.format_bold), + color: _toolbarOps.isBoldActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: selectedNode is TextNode ? _toolbarOps.toggleItalics : null, + icon: const Icon(Icons.format_italic), + color: _toolbarOps.isItalicsActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: selectedNode is TextNode ? _toolbarOps.toggleUnderline : null, + icon: const Icon(Icons.format_underline), + color: _toolbarOps.isUnderlineActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: selectedNode is TextNode ? _toolbarOps.toggleStrikethrough : null, + icon: const Icon(Icons.strikethrough_s), + color: _toolbarOps.isStrikethroughActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && + selectedNode.getMetadataValue('blockType') != header1Attribution) + ? _toolbarOps.convertToHeader1 + : null, + icon: const Icon(Icons.title), + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && + selectedNode.getMetadataValue('blockType') != header2Attribution) + ? _toolbarOps.convertToHeader2 + : null, + icon: const Icon(Icons.title), + iconSize: 18, + ), + IconButton( + onPressed: isSingleNodeSelected && + ((selectedNode is ParagraphNode && selectedNode.hasMetadataValue('blockType')) || + (selectedNode is TextNode && selectedNode is! ParagraphNode)) + ? _toolbarOps.convertToParagraph + : null, + icon: const Icon(Icons.wrap_text), + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && selectedNode is! ListItemNode || + (selectedNode is ListItemNode && selectedNode.type != ListItemType.ordered)) + ? _toolbarOps.convertToOrderedListItem + : null, + icon: const Icon(Icons.looks_one_rounded), + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && selectedNode is! ListItemNode || + (selectedNode is ListItemNode && selectedNode.type != ListItemType.unordered)) + ? _toolbarOps.convertToUnorderedListItem + : null, + icon: const Icon(Icons.list), + ), + IconButton( + onPressed: isSingleNodeSelected && + selectedNode is TextNode && + (selectedNode is! ParagraphNode || + selectedNode.getMetadataValue('blockType') != blockquoteAttribution) + ? _toolbarOps.convertToBlockquote + : null, + icon: const Icon(Icons.format_quote), + ), + IconButton( + onPressed: + isSingleNodeSelected && selectedNode is ParagraphNode && selectedNode.text.isEmpty + ? _toolbarOps.convertToHr + : null, + icon: const Icon(Icons.horizontal_rule), + ), + ], + ); + }), + ), + ), + Container( + width: 1, + height: 32, + color: const Color(0xFFCCCCCC), + ), + IconButton( + onPressed: _toolbarOps.closeKeyboard, + icon: const Icon(Icons.keyboard_hide), + ), + ], + ); + }), + ), + ); + } +} + +/// Builds (and rebuilds) a [builder] with the current height of the software keyboard. +/// +/// There's no explicit property for the software keyboard height. This builder uses +/// `EdgeInsets.fromViewPadding(View.of(context).viewInsets, View.of(context).devicePixelRatio).bottom` +/// as a proxy for the height of the software keyboard. +class KeyboardHeightBuilder extends StatefulWidget { + const KeyboardHeightBuilder({ + super.key, + required this.builder, + }); + + final Widget Function(BuildContext, double keyboardHeight) builder; + + @override + State createState() => _KeyboardHeightBuilderState(); +} + +class _KeyboardHeightBuilderState extends State with WidgetsBindingObserver { + double _keyboardHeight = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeMetrics() { + final keyboardHeight = + EdgeInsets.fromViewPadding(View.of(context).viewInsets, View.of(context).devicePixelRatio).bottom; + if (keyboardHeight == _keyboardHeight) { + return; + } + + setState(() { + _keyboardHeight = keyboardHeight; + }); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, _keyboardHeight); + } +} + +@visibleForTesting +class KeyboardEditingToolbarOperations { + KeyboardEditingToolbarOperations({ + required this.editor, + required this.document, + required this.composer, + required this.commonOps, + this.brightness, + }); + + final Editor editor; + final Document document; + final DocumentComposer composer; + final CommonEditorOperations commonOps; + final Brightness? brightness; + + bool get isBoldActive => _doesSelectionHaveAttributions({boldAttribution}); + void toggleBold() => _toggleAttributions({boldAttribution}); + + bool get isItalicsActive => _doesSelectionHaveAttributions({italicsAttribution}); + void toggleItalics() => _toggleAttributions({italicsAttribution}); + + bool get isUnderlineActive => _doesSelectionHaveAttributions({underlineAttribution}); + void toggleUnderline() => _toggleAttributions({underlineAttribution}); + + bool get isStrikethroughActive => _doesSelectionHaveAttributions({strikethroughAttribution}); + void toggleStrikethrough() => _toggleAttributions({strikethroughAttribution}); + + bool _doesSelectionHaveAttributions(Set attributions) { + final selection = composer.selection; + if (selection == null) { + return false; + } + + if (selection.isCollapsed) { + return composer.preferences.currentAttributions.containsAll(attributions); + } + + return document.doesSelectedTextContainAttributions(selection, attributions); + } + + void _toggleAttributions(Set attributions) { + final selection = composer.selection; + if (selection == null) { + return; + } + + selection.isCollapsed + ? commonOps.toggleComposerAttributions(attributions) + : commonOps.toggleAttributionsOnSelection(attributions); + } + + void convertToHeader1() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId); + if (selectedNode is! TextNode) { + return; + } + + if (selectedNode is ListItemNode) { + commonOps.convertToParagraph( + newMetadata: { + 'blockType': header1Attribution, + }, + ); + } else { + editor.execute([ + ChangeParagraphBlockTypeRequest( + nodeId: selectedNode.id, + blockType: header1Attribution, + ), + ]); + } + } + + void convertToHeader2() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId); + if (selectedNode is! TextNode) { + return; + } + + if (selectedNode is ListItemNode) { + commonOps.convertToParagraph( + newMetadata: { + 'blockType': header2Attribution, + }, + ); + } else { + editor.execute([ + ChangeParagraphBlockTypeRequest( + nodeId: selectedNode.id, + blockType: header2Attribution, + ), + ]); + } + } + + void convertToParagraph() { + commonOps.convertToParagraph(); + } + + void convertToOrderedListItem() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; + + commonOps.convertToListItem(ListItemType.ordered, selectedNode.text); + } + + void convertToUnorderedListItem() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; + + commonOps.convertToListItem(ListItemType.unordered, selectedNode.text); + } + + void convertToBlockquote() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; + + commonOps.convertToBlockquote(selectedNode.text); + } + + void convertToHr() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; + + editor.execute([ + ReplaceNodeRequest( + existingNodeId: selectedNode.id, + newNode: ParagraphNode( + id: selectedNode.id, + text: AttributedText('---'), + ), + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: selectedNode.id, + nodePosition: const TextNodePosition(offset: 3), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + InsertCharacterAtCaretRequest(character: " "), + ]); + } + + void closeKeyboard() { + editor.execute([ + const ChangeSelectionRequest( + null, + SelectionChangeType.clearSelection, + SelectionReason.userInteraction, + ), + ]); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/shared_ime.dart b/super_editor/lib/src/default_editor/document_ime/shared_ime.dart new file mode 100644 index 0000000000..32358d23b2 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/shared_ime.dart @@ -0,0 +1,354 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// A globally shared holder of an IME connection, so that the IME connection +/// can be seamlessly transferred between the same `SuperEditor` or `SuperTextField` +/// when their tree is rebuilt. +class SuperIme with ChangeNotifier { + static SuperIme? _instance; + static SuperIme get instance { + _instance ??= SuperIme._(); + return _instance!; + } + + SuperIme._(); + + /// Sets the [SuperImeLog], which is notified of important events that take + /// place within this [SuperIme], e.g., taking ownership, releasing ownership, + /// opening a connection, closing a connection. + set log(SuperImeLog? log) => _log = log; + SuperImeLog? _log = SuperImeLog(); + + /// The current owner of the IME, or `null` if there is no owner. + SuperImeInputId? get owner => _owner; + SuperImeInputId? _owner; + + /// The open OS IME connection, which is owned by [owner], but *may* have been + /// opened by a previous owner. + TextInputConnection? _imeConnection; + + /// The [SuperImeInputId] that was the owner when the [_imeConnection] was opened, + /// which may not be the same as [owner], if a different owner took ownership and + /// kept the connection open. + // TODO: Find out which scenarios would ever want to take new ownership, but leave the + // existing connection open. If we find them, document them. If we can't find them, + // then change `releaseOwnership()` to automatically close the open connection. + SuperImeInputId? _connectionOwner; + + /// The [TextInputClient] that was passed to Flutter when opening the current + /// [_imeConnection]. + /// + /// We track this so that we know when the client changes, which requires us to + /// close the current connection and open a new connection. This is because Flutter + /// registers the IME client when the connection is opened, and it cannot be change + /// or replaced after that. + TextInputClient? _attachedClient; + + /// Returns `true` if [SuperIme] currently holds a Flutter [TextInputConnection] + /// in [imeConnection]. + /// + /// The existence of an [imeConnection] doesn't mean that connection is attached to + /// the operating system. To check that status, use [isAttachedToOS]. + bool get hasConnection => _imeConnection != null; + + /// Returns `true` if [SuperIme] currently holds a Flutter [TextInputConnection] + /// AND that connection is attached to the operating system. + /// + /// When this is `true`, the operating system software keyboard, or other IME + /// interface, is currently interacting with the app (e.g., inputting text). + bool get isAttachedToOS => _imeConnection?.attached ?? false; + + /// Returns `true` if the given [input] is the current owner of the shared IME, + /// and the shared IME is currently attached to the OS. + bool isInputAttachedToOS(SuperImeInputId input) => _owner == input && isAttachedToOS; + + /// Returns the [TextInputClient] that is currently connected to the open IME + /// connection. + /// + /// This client is made available for instance verification. It's not expected that + /// apps call anything on this client. Doing so could corrupt the accounting between + /// the client and the OS IME state. + TextInputClient? get attachedClient => _attachedClient; + + /// If [owner] is the current IME owner, returns the shared [TextInputConnection], or `null` if + /// no such connection currently exists, or if the [owner] isn't actually the owner. + TextInputConnection? getImeConnectionForOwner(SuperImeInputId owner) { + if (owner != _owner) { + return null; + } + + return _imeConnection; + } + + /// If the given [ownerInputId] is the current owner, opens a new [TextInputConnection], and + /// optionally shows the software keyboard. + /// + /// The opened IME connection is available via [getImeConnectionForOwner]. + void openConnection( + SuperImeInputId ownerInputId, + TextInputClient client, + TextInputConfiguration configuration, { + bool showKeyboard = false, + }) { + if (!isOwner(ownerInputId)) { + return; + } + + if (false == _imeConnection?.attached) { + // We have a connection, but its been detached, and we can't re-attach + // without creating a new connection. Throw it away. + // + // While SuperIme might be a global, shared IME, we don't actually have + // global control of the IME. Only Flutter does. We need to be resilient to + // any other Flutter input messing with the IME. + _imeConnection = null; + } + + if (_imeConnection == null || client != _attachedClient) { + // Log the specific action we're taking here, because its nuanced and we will + // want to know which one we did, if a bug shows up. + if (_imeConnection == null) { + // We're opening a new connection. There was no previous connection. + _log?.onNewImeConnectionOpened(ownerInputId); + } else if (_owner?.role != ownerInputId.role) { + // The owner changed from one role to another, which means one editor to a + // completely different editor. + _log?.onImeConnectionSwitchedBetweenRoles(previousOwner: _connectionOwner!, newOwner: ownerInputId); + } else { + // The owner didn't change role, but did change instance. This means the owner + // is playing the same role (same editor), but is a different instance (different + // `State` object). + _log?.onImeConnectionSwitchedBetweenInstances(previousOwner: _connectionOwner!, newOwner: ownerInputId); + } + + _imeConnection = TextInput.attach(client, configuration); + } + _attachedClient = client; + _connectionOwner = ownerInputId; + + if (showKeyboard) { + _imeConnection!.show(); + } + + notifyListeners(); + } + + /// If the given [ownerInputId] is the current owner, then the current input connection + /// is closed, and the connection null'ed out. + void clearConnection(SuperImeInputId ownerInputId) { + if (!isOwner(ownerInputId)) { + return; + } + + _log?.onImeConnectionClosed(ownerInputId); + _imeConnection?.close(); + _imeConnection = null; + _connectionOwner = null; + _attachedClient = null; + + notifyListeners(); + } + + /// Returns `true` if a [SuperImeInputId] has claimed ownership of the shared IME. + /// + /// The existence of an owner doesn't imply the existence of an [imeConnection]. It's the + /// owner's job to open and close [imeConnection]s, as needed. + bool get isOwned => _owner != null; + + /// Returns true if the given [inputId] is the current owner of the shared IME. + bool isOwner(SuperImeInputId? inputId) => _owner == inputId; + + /// Takes ownership of the shared IME. + /// + /// Ownership might be taken from another owner, or might be taken at a moment where no + /// other owner exists. Taking ownership doesn't open or close an existing IME connection, + /// it only changes the actor that's allowed to open and access the IME connection. + /// + /// One owner cannot prevent another owner from taking ownership. This mechanism is not + /// a security feature, it's a convenience feature for different areas of code to work + /// together around the fact that only a single IME connection exists per app. + void takeOwnership(SuperImeInputId newOwnerInputId) { + if (_owner == newOwnerInputId) { + return; + } + + _log?.onOwnershipClaimed(newOwner: newOwnerInputId, previousOwner: _owner); + _owner = newOwnerInputId; + + notifyListeners(); + } + + /// Releases ownership of the IME, if [ownerInputId] is the current owner. + /// + /// We take an [ownerInputId] to reduce the possibility that one IME input accidentally + /// releases ownership when they're not the owner. + /// + /// For convenience, this method closes the open connection upon release, and then + /// throws away the connection, forcing the next owner to create a new connection, + /// and then open it. To prevent this, pass `false` for [clearConnectionOnRelease]. + void releaseOwnership( + SuperImeInputId ownerInputId, { + bool clearConnectionOnRelease = true, + }) { + if (_owner != ownerInputId) { + return; + } + + _log?.onOwnershipReleased(ownerInputId, willCloseConnection: clearConnectionOnRelease); + if (clearConnectionOnRelease) { + clearConnection(ownerInputId); + } + _owner = null; + + notifyListeners(); + } +} + +/// A specific IME input that might want to own the [SuperIme] shared IME. +/// +/// This class is just a composite ID, which is registered with [SuperIme] to +/// claim ownership over the IME. See [role] and [instance] for their individual +/// meaning. +class SuperImeInputId { + SuperImeInputId({ + required this.role, + required this.instance, + }); + + /// The role this owner is playing in the UI, or `null` if there's only a single + /// input widget in the whole widget tree. + /// + /// It's fine to provide a [role] even if there's only one input in the widget tree. + /// + /// Examples of possible [role] values include things like "chat", "document", "journal", or + /// any other type of content that an input might exist to compose. This choice is up + /// to the developer and the only thing that matters is uniqueness, e.g., "chat" is different + /// from "journal". + /// + /// ### How `role` works + /// The [role] is critical for dealing with `State` disposal and recreation when a + /// widget tree changes an ancestor, and therefore recreates the entire subtree. + /// + /// For example, imagine a widget tree like this: + /// + /// ```dart + /// SuperEditor( + /// //... + /// ) + /// ``` + /// + /// Then, something causes the widget tree to add a `SizedBox` above the `SuperEditor`: + /// + /// ```dart + /// SizedBox( + /// child: SuperEditor( + /// //... + /// ), + /// ); + /// ``` + /// + /// This change causes the `SuperEditor` and all of its internal widgets to be disposed + /// and recreated. More specifically, for each widget in the subtree, a new widget is + /// initialized, and the previous widget is then disposed. + /// + /// But these widgets don't have any idea that they're being replaced - as far as they know + /// they're being permanently destroyed. So should `SuperEditor`'s IME connection be closed + /// or not? + /// + /// This [role] is an ID that binds together the previous `SuperEditor` that's disposed + /// with the new `SuperEditor` that's being created. It tells the disposing `SuperEditor` + /// NOT to close its IME connection, so that the new `SuperEditor` can continue to use it. + /// This sharing prevents unexpected raising of the software keyboard across subtree + /// recreations. + final String? role; + + /// The specific owner of the IME, even within the same [role]. + /// + /// The purpose of [instance] is to differentiate between initializing widgets and + /// disposing widgets for the same input. See [role] for more info. + /// + /// A typical choice to provide as the [instance] is the [State] object that + /// owns a given IME connection. This is a naturally effective choice because the + /// concept of the [instance] is typically used to differentiate between initializing + /// and disposing [State] objects for the same widget. + final Object instance; + + @override + String toString() => "${role ?? 'Global editor'} ($instance)"; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperImeInputId && runtimeType == other.runtimeType && role == other.role && instance == other.instance; + + @override + int get hashCode => role.hashCode ^ instance.hashCode; +} + +/// Logger for [SuperIme] events, which can be used to print events to console, or +/// to forward those events to logging backend systems. +class SuperImeLog { + /// The [newOwner] explicitly requested ownership, taking it from [previousOwner]. + void onOwnershipClaimed({ + required SuperImeInputId newOwner, + required SuperImeInputId? previousOwner, + }) { + superImeLog.info("Giving IME ownership to new owner: '$newOwner', from previous owner: $previousOwner"); + } + + /// The [previousOwner] explicitly requested to give up ownership. + void onOwnershipReleased( + SuperImeInputId previousOwner, { + required bool willCloseConnection, + }) { + superImeLog.info("Releasing IME ownership from: '$previousOwner'"); + superImeLog.info(" - SuperIme will close the connection after releasing ownership"); + } + + /// A new IME connection was opened with the OS, via `TextInput.attach()`, and + /// this happened either without any previous connection existing, or this happened + /// after another connection was explicitly closed. + /// + /// This event is distinct from [onImeConnectionSwitchedBetweenInstances], even though + /// both of these events involve a connection being opened. + void onNewImeConnectionOpened(SuperImeInputId owner) { + superImeLog.info("Opening a new IME connection from a closed connection. Owner: $owner"); + } + + /// The IME was owned and open, then a new owner from a completely different editor + /// took control, and replaced the previous connection with its own, new connection. + void onImeConnectionSwitchedBetweenRoles({ + required SuperImeInputId previousOwner, + required SuperImeInputId newOwner, + }) { + superImeLog.info( + "Replacing IME connection, because owner changed roles.\n - Previous: $previousOwner\n - New: $newOwner", + ); + } + + /// The IME was owned and open, then a new owner took over with the same role, but + /// different instance, so the connection was closed for the first instance, and + /// immediately re-opened for the second instance. + /// + /// This happens when one input widget (like `SuperEditor`) has its widget tree + /// re-created, which throws out the previous `State` and creates a new `State`. + /// When this happens, the user expects the connection and keyboard to remain + /// exactly as-is, but internally, because the `State` object was replaced, there's + /// a new IME client instance. The only way to switch out the IME client is to + /// close the current connection, and then open a new one, with the new client. + /// + /// This event is emitted when this close/open series happens. + void onImeConnectionSwitchedBetweenInstances({ + required SuperImeInputId previousOwner, + required SuperImeInputId newOwner, + }) { + superImeLog.info( + "Replacing IME connection, because owner changed instances.\n - Previous: $previousOwner\n - New: $newOwner", + ); + } + + void onImeConnectionClosed(SuperImeInputId ownerBeforeClose) { + superImeLog.info("Closing IME connection (owner: '$ownerBeforeClose')"); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart b/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart new file mode 100644 index 0000000000..6bec8efa92 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart @@ -0,0 +1,1191 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/default_editor/debug_visualization.dart'; +import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; +import 'package:super_editor/src/default_editor/document_ime/shared_ime.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/actions.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; + +import '../document_hardware_keyboard/document_input_keyboard.dart'; +import 'document_delta_editing.dart'; +import 'document_ime_communication.dart'; +import 'document_ime_interaction_policies.dart'; +import 'document_serialization.dart'; +import 'ime_decoration.dart'; +import 'ime_keyboard_control.dart'; + +/// [SuperEditor] interactor that edits a document based on IME input +/// from the operating system. +// TODO: instead of an IME interactor, try defining more granular interactors, e.g., +// TextDeltaInteractor, FloatingCursorInteractor, ScribbleInteractor. +// The concept of the IME is so broad in functionality that if we mimic that +// concept, we're going to get stuck piling unrelated behaviors into one place. +// To make this division of responsibility possible, each of those interactors +// could receive a proxy TextInputClient, which allows each interactor to say +// proxyInputClient.addClient(myFocusedClient). +class SuperEditorImeInteractor extends StatefulWidget { + const SuperEditorImeInteractor({ + Key? key, + this.focusNode, + this.autofocus = false, + required this.editContext, + this.clearSelectionWhenEditorLosesFocus = true, + this.clearSelectionWhenImeConnectionCloses = true, + this.softwareKeyboardController, + this.inputRole, + this.imePolicies = const SuperEditorImePolicies(), + this.imeConfiguration = const SuperEditorImeConfiguration(), + this.imeOverrides, + this.isImeConnected, + this.isScribbleInProgress, + this.hardwareKeyboardActions = const [], + required this.selectorHandlers, + this.floatingCursorController, + this.log, + required this.child, + }) : super(key: key); + + final FocusNode? focusNode; + + final bool autofocus; + + /// All resources that are needed to edit a document. + final SuperEditorContext editContext; + + /// Whether the document's selection should be removed when the editor loses + /// all focus (not just primary focus). + /// + /// If `true`, when focus moves to a different subtree, such as a popup text + /// field, or a button somewhere else on the screen, the editor will remove + /// its selection. When focus returns to the editor, the previous selection can + /// be restored, but that's controlled by other policies. + /// + /// If `false`, the editor will retain its selection, including a visual caret + /// and selected content, even when the editor doesn't have any focus, and can't + /// process any input. + final bool clearSelectionWhenEditorLosesFocus; + + /// Whether the editor's selection should be removed when the editor closes or loses + /// its IME connection. + /// + /// Defaults to `true`. + /// + /// Apps that include a custom input mode, such as an editing panel that sometimes + /// replaces the software keyboard, should set this to `false` and instead control the + /// IME connection manually. + final bool clearSelectionWhenImeConnectionCloses; + + /// Controller that opens and closes the software keyboard. + /// + /// When [SuperEditorImePolicies.openKeyboardOnSelectionChange] and + /// [SuperEditorImePolicies.clearSelectionWhenEditorLosesFocus] are `false`, + /// an app can use this controller to manually open and close the software + /// keyboard, as needed. + /// + /// When [SuperEditorImePolicies.openKeyboardOnSelectionChange] and + /// [clearSelectionWhenImeDisconnects] are `true`, this controller probably + /// shouldn't be used, because the commands to open and close the keyboard + /// might conflict with teh automated behavior. + final SoftwareKeyboardController? softwareKeyboardController; + + final String? inputRole; + + /// Policies that dictate when and how `SuperEditor` should interact with the + /// platform IME. + final SuperEditorImePolicies imePolicies; + + /// Preferences for how the platform IME should look and behave during editing. + final SuperEditorImeConfiguration imeConfiguration; + + /// Overrides for IME actions. + /// + /// When the user edits document content in IME mode, those edits and actions + /// are reported to a [DeltaTextInputClient], which is then responsible for + /// applying those changes to a document. + /// + /// Provide a [DeltaTextInputClientDecorator], to override the default behaviors + /// for various IME messages. + final DeltaTextInputClientDecorator? imeOverrides; + + /// A (optional) notifier that's notified when the IME connection opens or closes. + /// + /// A `true` value means this interactor is connected to the platform's IME, a `false` + /// value means this interactor isn't connected to the platforms IME. + final ValueNotifier? isImeConnected; + + /// An (optional) notifier that reports whether a Scribble (Apple Pencil handwriting) + /// or stylus writing interaction is currently in progress. + /// + /// This can be used by gesture interactors and scroll controllers to avoid + /// interfering with scribble input. + final ValueNotifier? isScribbleInProgress; + + /// All the actions that the user can execute with physical hardware + /// keyboard keys. + /// + /// [keyboardActions] operates as a Chain of Responsibility. Starting + /// from the beginning of the list, a [SuperEditorKeyboardAction] is + /// given the opportunity to handle the currently pressed keys. If that + /// [SuperEditorKeyboardAction] reports the keys as handled, then execution + /// stops. Otherwise, execution continues to the next [SuperEditorKeyboardAction]. + final List hardwareKeyboardActions; + + /// Controls "floating cursor" behavior for iOS devices. + /// + /// The floating cursor is an iOS-only feature. Flutter reports floating cursor + /// messages through the IME API, which is why this controller is offered as + /// a property on this IME interactor. + /// + /// If no [floatingCursorController] is provided, this widget attempts to obtain + /// one from an ancestor [SuperEditorIosControlsScope] + final FloatingCursorController? floatingCursorController; + + /// Handlers for all Mac OS "selectors" reported by the IME. + /// + /// The IME reports selectors as unique `String`s, therefore selector handlers are + /// defined as a mapping from selector names to handler functions. + final Map selectorHandlers; + + /// A logger that is notified of events specifically to [TextDeltasDocumentEditor], + /// which lets apps report those specific events to their own issue tracker. + final TextDeltasDocumentEditorLog? log; + + final Widget child; + + @override + State createState() => SuperEditorImeInteractorState(); +} + +@visibleForTesting +class SuperEditorImeInteractorState extends State implements ImeInputOwner { + static bool _willCheckUniqueInputsNextFrame = false; + + static final _registeredInputsThisFrame = <(SuperImeInputId inputId, StackTrace stacktrace)>[]; + + /// Ensures that there are no other inputs with the same [inputId] at the end of the current + /// Flutter frame. + /// + /// At the end of the frame, if 2+ inputs registered with the same [SuperImeInputId.role], + /// an exception is thrown, which includes the stack traces for each of those registrations, + /// so that developers can debug why it happened. + static _registerInput(SuperImeInputId inputId) { + if (!kDebugMode) { + return; + } + + _registeredInputsThisFrame.add((inputId, StackTrace.current)); + + if (!_willCheckUniqueInputsNextFrame) { + _willCheckUniqueInputsNextFrame = true; + WidgetsBinding.instance.addPostFrameCallback(_verifyUniqueInputs); + } + } + + static void _unregisterInput(SuperImeInputId inputId) { + final startLength = _registeredInputsThisFrame.length; + _registeredInputsThisFrame.removeWhere((entry) => entry.$1.instance == inputId.instance); + + assert( + startLength != _registeredInputsThisFrame.length, + "An IME interactor tried to unregister itself with the global tracker, but we " + "didn't find it in the global tracker. Either it was never added, or it was " + "already removed. Either way, this is a programming mistake that needs to be " + "corrected by the caller.", + ); + } + + static void _verifyUniqueInputs(Duration _) { + // Clear flag so the next time the "ensure" method is called, we'' + // register another post frame callback. + _willCheckUniqueInputsNextFrame = false; + + final inputsByRole = >{}; + + for (final input in _registeredInputsThisFrame) { + inputsByRole[input.$1.role] ??= []; + inputsByRole[input.$1.role]!.add(input); + } + + final duplicates = <(SuperImeInputId, StackTrace)>[]; + for (final entry in inputsByRole.entries) { + if (entry.value.length < 2) { + // No duplicate inputs here. + continue; + } + + duplicates.addAll(entry.value); + } + + if (duplicates.isEmpty) { + // No duplicate inputs anywhere this frame. All good. We're done. + return; + } + + // We found some number of duplicates. Write them all out to an exception message. + throw Exception([ + "Found ${duplicates.length} duplicate input IDs this frame:", + for (final input in duplicates) ...[ + "Input: ${input.$1.role} (${input.$1.instance})", + "This duplicate input was built at this moment:", + input.$2, + "------ END OF ${input.$1.role} (${input.$1.instance}) ----", + ], + ].join("\n")); + } + + late FocusNode _focusNode; + + SuperEditorIosControlsController? _controlsController; + + late SuperImeInputId _myImeId; + final _ownedImeConnection = ValueNotifier(null); + late TextInputConfiguration _textInputConfiguration; + late DocumentImeInputClient _documentImeClient; + // The _imeClient is setup in one of two ways at any given time: + // _imeClient -> _documentImeClient, or + // _imeClient -> widget.imeOverrides -> _documentImeClient + // See widget.imeOverrides for more info. + late DeltaTextInputClientDecorator _imeClient; + // _documentImeConnection functions as both a TextInputConnection and a + // DeltaTextInputClient. This is required for a very specific reason that + // occurs in specific situations. To understand why we need it, check the + // implementation of DocumentImeInputClient. If we find a less confusing + // way to handle that scenario, then get rid of this property. + final _documentImeConnection = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _focusNode = (widget.focusNode ?? FocusNode()); + + _myImeId = SuperImeInputId(role: widget.inputRole, instance: this); + _registerInput(_myImeId); + SuperIme.instance.addListener(_onSharedImeChange); + _setupDocumentImeInputClient(); + _documentImeClient.isScribbleInProgress.addListener(_onScribbleStateChange); + + _imeClient = DeltaTextInputClientDecorator(); + _configureImeClientDecorators(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + // Synchronize the IME connection notifier with our IME connection state. We run + // this in a post-frame callback because the very first pump of the Super Editor + // widget tree won't have Super Editor connected as an IME delegate, yet. + if (widget.softwareKeyboardController != null) { + widget.isImeConnected?.value = widget.softwareKeyboardController!.isConnectedToIme; + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _controlsController = SuperEditorIosControlsScope.maybeRootOf(context); + _documentImeClient.floatingCursorController = + widget.floatingCursorController ?? _controlsController?.floatingCursorController; + _textInputConfiguration = widget.imeConfiguration // + .toTextInputConfiguration(viewId: View.of(context).viewId); + } + + @override + void didUpdateWidget(SuperEditorImeInteractor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.focusNode != oldWidget.focusNode) { + if (oldWidget.focusNode == null) { + _focusNode.dispose(); + } + } + + if (widget.inputRole != oldWidget.inputRole) { + final didOwnIme = SuperIme.instance.isOwner(_myImeId); + + // We changed roles. Release our previous claim to the shared IME. + SuperIme.instance.releaseOwnership(_myImeId); + + // Create a new IME input ID with the new role. + _unregisterInput(_myImeId); + _myImeId = SuperImeInputId(role: widget.inputRole, instance: this); + _registerInput(_myImeId); + + if (didOwnIme) { + // Re-take IME ownership. + // + // Note: In general when taking ownership, we need to be mindful of an IME + // connection that might already be open, and would therefore be tied to an + // existing IME client. In this case, because we were the previous owner, and + // the new owner, if an IME connection is open, it should be our IME client + // that's bound to it. So we don't need to open a new IME connection just + // because we took ownership. + SuperIme.instance.takeOwnership(_myImeId); + } + } + + if (widget.editContext != oldWidget.editContext) { + _setupDocumentImeInputClient(); + _onSharedImeChange(); + _documentImeClient.floatingCursorController = + widget.floatingCursorController ?? _controlsController?.floatingCursorController; + } + + if (widget.imeConfiguration != oldWidget.imeConfiguration) { + _textInputConfiguration = widget.imeConfiguration.toTextInputConfiguration(viewId: View.of(context).viewId); + if (isAttachedToIme) { + SuperIme.instance.getImeConnectionForOwner(_myImeId)!.updateConfig(_textInputConfiguration); + } + } + + if (widget.imeOverrides != oldWidget.imeOverrides) { + if (true == oldWidget.imeOverrides?.isCurrentClient(_documentImeClient)) { + // Remove ourselves as the IME client from the old IME decorator, but ONLY + // if we're still registered as the client (some other client may have + // installed itself, which means it's none of our business). + oldWidget.imeOverrides?.client = null; + } + + _configureImeClientDecorators(); + } + } + + @override + void dispose() { + SuperIme.instance.removeListener(_onSharedImeChange); + if (SuperIme.instance.isOwner(_myImeId)) { + // We are the current owner of the IME. Close the IME as we dispose ourselves. + // Note: If this disposal were part of a subtree move somewhere else, the other + // version of this widget would have already registered itself as the IME owner + // in its `initState()` method. + SuperIme.instance.releaseOwnership(_myImeId); + } + _unregisterInput(_myImeId); + + if (widget.focusNode == null) { + _focusNode.dispose(); + } + + if (true == widget.imeOverrides?.isCurrentClient(_documentImeClient)) { + // We're still the owner/client of the IME overrides. Null it out to + // remove the reference to us. + widget.imeOverrides?.client = null; + } + _imeClient.client = null; + _documentImeClient.isScribbleInProgress.removeListener(_onScribbleStateChange); + _documentImeClient.dispose(); + + super.dispose(); + } + + void _onScribbleStateChange() { + widget.isScribbleInProgress?.value = _documentImeClient.isScribbleInProgress.value; + } + + @visibleForTesting + @override + DeltaTextInputClient get imeClient => _imeClient; + + @visibleForTesting + bool get isAttachedToIme => SuperIme.instance.isInputAttachedToOS(_myImeId); + + TextInputConnection get imeConnection { + assert(SuperIme.instance.isOwner(_myImeId), + "The imeConnection getter should only be called when you know you're the owner. You're not."); + assert(SuperIme.instance.getImeConnectionForOwner(_myImeId) != null, + "The imeConnection getter should only be called when you know there's an IME connection. There isn't."); + + return SuperIme.instance.getImeConnectionForOwner(_myImeId)!; + } + + void _setupDocumentImeInputClient() { + _documentImeClient = DocumentImeInputClient( + selection: widget.editContext.composer.selectionNotifier, + composingRegion: widget.editContext.composer.composingRegion, + textDeltasDocumentEditor: TextDeltasDocumentEditor( + editor: widget.editContext.editor, + document: widget.editContext.document, + documentLayoutResolver: () => widget.editContext.documentLayout, + selection: widget.editContext.composer.selectionNotifier, + composerPreferences: widget.editContext.composer.preferences, + composingRegion: widget.editContext.composer.composingRegion, + commonOps: widget.editContext.commonOps, + log: widget.log, + onPerformAction: (action) => _imeClient.performAction(action), + ), + imeConnection: _ownedImeConnection, + onPerformSelector: _onPerformSelector, + ); + } + + void _onSharedImeChange() { + if (!SuperIme.instance.isOwner(_myImeId)) { + // We don't own the IME. Update our accounting. + _ownedImeConnection.value = null; + + _documentImeConnection.value = null; + if (true == widget.imeOverrides?.isCurrentClient(_documentImeClient)) { + // We're still the IME overrides client. Remove ourselves because we no + // longer own the IME. + widget.imeOverrides?.client = null; + } + widget.isImeConnected?.value = false; + return; + } + + if (!SuperIme.instance.isInputAttachedToOS(_myImeId)) { + // We own the IME, but our connection to the OS was closed. + _documentImeConnection.value = null; + widget.imeOverrides?.client = null; + widget.isImeConnected?.value = false; + return; + } + + // We own the IME, and a connection is open. Update our connection accounting, + // and make sure the open connection is configured for us, and not some previous + // client. + _ownedImeConnection.value = SuperIme.instance.getImeConnectionForOwner(_myImeId); + _configureImeClientDecorators(); + _documentImeConnection.value = _documentImeClient; + + if (SuperIme.instance.isInputAttachedToOS(_myImeId) && SuperIme.instance.attachedClient != _imeClient) { + // The IME is attached to the OS, but it's not using our client. This is probably because + // the IME was owned by a different client that had an open connection. Close that connection + // and open our own. If we don't do this, the IME connection will continue talking to + // the previous editor's client. + SuperIme.instance.openConnection( + _myImeId, + _imeClient, + widget.imeConfiguration.toTextInputConfiguration(viewId: View.of(context).viewId), + // To keep the keyboard up when transitioning from an old SuperEditor instance + // to a new SuperEditor instance, we must explicitly tell it to `show()` after + // opening the new connection. + // + // I'm not entirely sure that we always want this to be `true`, but at the time of + // writing this, I don't know of a situation where we wouldn't. If we discover one, + // re-evaluate this. + showKeyboard: true, + ); + } + + _reportVisualInformationToIme(); + + widget.isImeConnected?.value = true; + } + + void _configureImeClientDecorators() { + // If we were given IME overrides, use those overrides to decorate our _documentImeClient. + widget.imeOverrides?.client = _documentImeClient; + + // If we were given IME overrides, point our primary IME client to that client. Otherwise, + // point our primary IME client directly towards the _documentImeClient. + _imeClient.client = widget.imeOverrides ?? _documentImeClient; + } + + /// Report the global size and transform of the editor and the caret rect to the IME. + /// + /// This is needed to display the OS emoji & symbols panel at the editor selected position. + /// + /// This methods is re-scheduled to run at the end of every frame while we are attached to the IME. + void _reportVisualInformationToIme() { + if (!isAttachedToIme) { + return; + } + + final myRenderSliver = context.findRenderObject() as RenderSliver?; + if (myRenderSliver != null && myRenderSliver.hasSize) { + _reportSizeAndTransformToIme(); + _reportCaretRectToIme(); + _reportTextStyleToIme(); + _reportSelectionRectsToIme(); + } + + // There are some operations that might affect our transform, size and the caret rect, + // but we can't react to them. + // For example, the editor might be resized or moved around the screen. + // Because of this, we update our size, transform and caret rect at every frame. + // FIXME: This call seems to be scheduling frames. When the caret is in Timer mode, we see this method running continuously even though the only change should be the caret blinking every half a second + onNextFrame((_) => _reportVisualInformationToIme()); + } + + /// Report the global size and transform of the editor to the IME. + /// + /// This is needed to display the OS emoji & symbols panel at the editor selected position. + void _reportSizeAndTransformToIme() { + late Size size; + late Matrix4 transform; + + if (CurrentPlatform.isWeb) { + // On web, we can't set the caret rect. + // To display the IME panels at the correct position, + // instead of reporting the whole editor size and transform, + // we report only the information about the selected node. + final sizeAndTransform = _computeSizeAndTransformOfSelectNode(); + if (sizeAndTransform == null) { + return; + } + + (size, transform) = sizeAndTransform; + } else { + final renderSliver = context.findRenderObject() as RenderSliver; + + size = renderSliver.size; + transform = renderSliver.getTransformTo(null); + } + + SuperIme.instance.getImeConnectionForOwner(_myImeId)!.setEditableSizeAndTransform(size, transform); + } + + void _reportCaretRectToIme() { + if (CurrentPlatform.isWeb) { + // On web, setting the caret rect isn't supported. + // To position the IME popovers, we report the size, transform and style + // of the selected component and let the browser position the popovers. + return; + } + + final caretRect = _computeCaretRectInViewportSpace(); + if (caretRect != null) { + SuperIme.instance.getImeConnectionForOwner(_myImeId)!.setCaretRect(caretRect); + } + } + + /// Report our text style to the IME. + /// + /// This is used on web to set the text style of the hidden native input, + /// to try to match the text size on the browser with our text size. + /// + /// As our content can have multiple styles, the sizes won't be 100% in sync. + /// + /// TODO: update this after https://github.com/flutter/flutter/issues/134265 is resolved. + void _reportTextStyleToIme() { + if (!CurrentPlatform.isWeb) { + // If we are not on the web, we can position the caret rect without the need + // to send the text styles to the IME. + return; + } + + final selection = widget.editContext.composer.selection; + if (selection == null) { + return; + } + + final nodePosition = selection.extent.nodePosition; + if (nodePosition is! TextNodePosition) { + // The selected component doesn't contain text. + return; + } + + final docLayout = widget.editContext.documentLayout; + + DocumentComponent? selectedComponent = docLayout.getComponentByNodeId(selection.extent.nodeId); + if (selectedComponent is ProxyDocumentComponent) { + // The selected component is a proxy. + // If this component displays text, the text component is bounded to childDocumentComponentKey. + selectedComponent = selectedComponent.childDocumentComponentKey.currentState as DocumentComponent?; + } + + if (selectedComponent == null) { + editorImeLog.warning('A selection exists but no component for node ${selection.extent.nodeId} was found'); + return; + } + + if (selectedComponent is! TextComponentState) { + // The selected component isn't a text component. We can't query its style. + return; + } + + final style = selectedComponent.getTextStyleAt(nodePosition.offset); + SuperIme.instance.getImeConnectionForOwner(_myImeId)!.setStyle( + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + textDirection: selectedComponent.textDirection ?? TextDirection.ltr, + textAlign: selectedComponent.textAlign ?? TextAlign.left, + ); + } + + /// Report character bounding rects to the IME so that Scribble (Apple Pencil + /// handwriting) and similar stylus features can target the correct text positions. + void _reportSelectionRectsToIme() { + if (defaultTargetPlatform != TargetPlatform.iOS && defaultTargetPlatform != TargetPlatform.android) { + // Selection rects are only needed on mobile platforms for Scribble/stylus support. + return; + } + + // Pushing fresh setSelectionRects on every frame while the iOS floating + // cursor is active (user holding the spacebar to drag the caret) makes + // iOS drop the floating cursor visual. The caret still lands at the + // correct final position, but the user sees nothing move during the drag. + final floatingCursorController = + widget.floatingCursorController ?? _controlsController?.floatingCursorController; + if (floatingCursorController?.isActive.value == true) { + return; + } + + final selection = widget.editContext.composer.selection; + if (selection == null) { + return; + } + + final docLayout = widget.editContext.documentLayout; + final renderSliver = context.findRenderObject() as RenderSliver; + + // Build the same serialization that is sent to the IME so we can + // map document positions to IME text offsets. + final imeSerialization = DocumentImeSerializer( + widget.editContext.document, + selection, + widget.editContext.composer.composingRegion.value, + ); + + final selectionRects = []; + + for (final entry in imeSerialization.docTextNodesToImeRanges.entries) { + final nodeId = entry.key; + final imeRange = entry.value; + + final component = docLayout.getComponentByNodeId(nodeId); + if (component == null) { + continue; + } + + final node = widget.editContext.document.getNodeById(nodeId); + if (node is! TextNode) { + // Non-text nodes are serialized as single characters. Report a single + // rect for them. + final componentBox = component.context.findRenderObject() as RenderBox; + final globalOffset = componentBox.localToGlobal(Offset.zero); + final localOffset = renderSliver.globalToLocal(globalOffset); + selectionRects.add(SelectionRect( + position: imeRange.start, + bounds: localOffset & componentBox.size, + )); + continue; + } + + // For text nodes, report a rect for each character. + final textLength = node.text.length; + for (int charIndex = 0; charIndex < textLength; charIndex++) { + final charRect = component.getRectForPosition(TextNodePosition(offset: charIndex)); + final globalOffset = docLayout.getGlobalOffsetFromDocumentOffset(charRect.topLeft); + final localOffset = renderSliver.globalToLocal(globalOffset); + + selectionRects.add(SelectionRect( + position: imeRange.start + charIndex, + bounds: localOffset & charRect.size, + )); + } + } + + if (selectionRects.isNotEmpty) { + SuperIme.instance.getImeConnectionForOwner(_myImeId)!.setSelectionRects(selectionRects); + } + } + + /// Compute the caret rect in the editor's content space. + /// + /// Returns `null` if we don't have a selection or if we can't get the caret rect + /// from the document layout. + Rect? _computeCaretRectInViewportSpace() { + final selection = widget.editContext.composer.selection; + if (selection == null) { + return null; + } + + final docLayout = widget.editContext.documentLayout; + final rectInDocLayoutSpace = docLayout.getRectForPosition(selection.extent); + + if (rectInDocLayoutSpace == null) { + return null; + } + + final renderSliver = context.findRenderObject() as RenderSliver; + + // The value returned from getRectForPosition is in the document's layout coordinates. + // As the document layout is scrollable, this rect might be outside of the viewport height. + // Map the offset to the editor's viewport coordinates. + final caretOffset = renderSliver.globalToLocal( + docLayout.getGlobalOffsetFromDocumentOffset(rectInDocLayoutSpace.topLeft), + ); + + return caretOffset & rectInDocLayoutSpace.size; + } + + /// Compute the size and transform of the selected node's visual component + /// to the global coordinates. + /// + /// Returns `null` if the we don't have a selection, or if we can't find + /// a component for the selected node. + (Size size, Matrix4 transform)? _computeSizeAndTransformOfSelectNode() { + final selection = widget.editContext.composer.selection; + if (selection == null) { + return null; + } + + final documentLayout = widget.editContext.documentLayout; + + DocumentComponent? selectedComponent = documentLayout.getComponentByNodeId(selection.extent.nodeId); + if (selectedComponent is ProxyDocumentComponent) { + // The selected componente is a proxy. + // If this component displays text, the text component is bounded to childDocumentComponentKey. + selectedComponent = selectedComponent.childDocumentComponentKey.currentState as DocumentComponent; + } + + if (selectedComponent == null) { + editorImeLog.warning('A selection exists but no component for node ${selection.extent.nodeId} was found'); + return null; + } + + final renderBox = selectedComponent.context.findRenderObject() as RenderBox; + return (renderBox.size, renderBox.getTransformTo(null)); + } + + void _onPerformSelector(String selectorName) { + final handler = widget.selectorHandlers[selectorName]; + if (handler == null) { + editorImeLog.warning("No handler found for $selectorName"); + return; + } + + handler(widget.editContext); + } + + @override + Widget build(BuildContext context) { + return SuperEditorImeDebugVisuals( + imeConnection: _ownedImeConnection, + child: IntentBlocker( + intents: CurrentPlatform.isApple ? appleBlockedIntents : nonAppleBlockedIntents, + child: SuperEditorHardwareKeyHandler( + focusNode: _focusNode, + editContext: widget.editContext, + keyboardActions: widget.hardwareKeyboardActions, + autofocus: widget.autofocus, + child: DocumentSelectionOpenAndCloseImePolicy( + focusNode: _focusNode, + editor: widget.editContext.editor, + selection: widget.editContext.composer.selectionNotifier, + inputId: _myImeId, + imeClientFactory: () => _imeClient, + imeConfiguration: _textInputConfiguration, + openKeyboardOnSelectionChange: widget.imePolicies.openKeyboardOnSelectionChange, + closeKeyboardOnSelectionLost: widget.imePolicies.closeKeyboardOnSelectionLost, + clearSelectionWhenEditorLosesFocus: widget.clearSelectionWhenEditorLosesFocus, + clearSelectionWhenImeConnectionCloses: widget.clearSelectionWhenImeConnectionCloses, + child: ImeFocusPolicy( + focusNode: _focusNode, + inputId: _myImeId, + imeClientFactory: () => _imeClient, + imeConfiguration: _textInputConfiguration, + openImeOnPrimaryFocusGain: widget.imePolicies.openKeyboardOnGainPrimaryFocus, + closeImeOnPrimaryFocusLost: widget.imePolicies.closeKeyboardOnLosePrimaryFocus, + openImeOnNonPrimaryFocusGain: widget.imePolicies.openImeOnNonPrimaryFocusGain, + closeImeOnNonPrimaryFocusLost: widget.imePolicies.closeImeOnNonPrimaryFocusLost, + child: SoftwareKeyboardOpener( + controller: widget.softwareKeyboardController, + inputId: _myImeId, + createImeClient: () => _imeClient, + createImeConfiguration: () => _textInputConfiguration, + child: widget.child, + ), + ), + ), + ), + ), + ); + } +} + +/// A callback to handle a `performSelector` call. +typedef SuperEditorSelectorHandler = void Function(SuperEditorContext context); + +void moveLeft(SuperEditorContext context) { + context.commonOps.moveCaretUpstream(); +} + +void moveRight(SuperEditorContext context) { + context.commonOps.moveCaretDownstream(); +} + +void moveUp(SuperEditorContext context) { + context.commonOps.moveCaretUp(); +} + +void moveDown(SuperEditorContext context) { + context.commonOps.moveCaretDown(); +} + +void moveWordLeft(SuperEditorContext context) { + context.commonOps.moveCaretUpstream(movementModifier: MovementModifier.word); +} + +void moveWordRight(SuperEditorContext context) { + context.commonOps.moveCaretDownstream(movementModifier: MovementModifier.word); +} + +void moveToLeftEndOfLine(SuperEditorContext context) { + context.commonOps.moveCaretUpstream(movementModifier: MovementModifier.line); +} + +void moveToRightEndOfLine(SuperEditorContext context) { + context.commonOps.moveCaretDownstream(movementModifier: MovementModifier.line); +} + +void moveToBeginningOfParagraph(SuperEditorContext context) { + context.commonOps.moveCaretUpstream(movementModifier: MovementModifier.paragraph); +} + +void moveToEndOfParagraph(SuperEditorContext context) { + context.commonOps.moveCaretDownstream(movementModifier: MovementModifier.paragraph); +} + +void moveToBeginningOfDocument(SuperEditorContext context) { + context.commonOps.moveSelectionToBeginningOfDocument(expand: false); +} + +void moveToEndOfDocument(SuperEditorContext context) { + context.commonOps.moveSelectionToEndOfDocument(expand: false); +} + +void moveLeftAndModifySelection(SuperEditorContext context) { + context.commonOps.moveCaretUpstream(expand: true); +} + +void moveRightAndModifySelection(SuperEditorContext context) { + context.commonOps.moveCaretDownstream(expand: true); +} + +void moveUpAndModifySelection(SuperEditorContext context) { + context.commonOps.moveCaretUp(expand: true); +} + +void moveDownAndModifySelection(SuperEditorContext context) { + context.commonOps.moveCaretDown(expand: true); +} + +void moveWordLeftAndModifySelection(SuperEditorContext context) { + context.commonOps.moveCaretUpstream( + expand: true, + movementModifier: MovementModifier.word, + ); +} + +void moveWordRightAndModifySelection(SuperEditorContext context) { + context.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.word, + ); +} + +void moveToLeftEndOfLineAndModifySelection(SuperEditorContext context) { + context.commonOps.moveCaretUpstream( + expand: true, + movementModifier: MovementModifier.line, + ); +} + +void moveParagraphBackwardAndModifySelection(SuperEditorContext context) { + context.commonOps.moveCaretUpstream( + expand: true, + movementModifier: MovementModifier.paragraph, + ); +} + +void moveParagraphForwardAndModifySelection(SuperEditorContext context) { + context.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.paragraph, + ); +} + +void moveToBeginningOfDocumentAndModifySelection(SuperEditorContext context) { + context.commonOps.moveSelectionToBeginningOfDocument(expand: true); +} + +void moveToEndOfDocumentAndModifySelection(SuperEditorContext context) { + context.commonOps.moveSelectionToEndOfDocument(expand: true); +} + +void moveToRightEndOfLineAndModifySelection(SuperEditorContext context) { + context.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.line, + ); +} + +void indentListItem(SuperEditorContext context) { + context.commonOps.indentListItem(); +} + +void unIndentListItem(SuperEditorContext context) { + context.commonOps.unindentListItem(); +} + +void insertNewLine(SuperEditorContext context) { + if (CurrentPlatform.isWeb) { + return; + } + context.editor.execute([ + InsertNewlineAtCaretRequest(), + ]); +} + +void deleteWordBackward(SuperEditorContext context) { + bool didMove = false; + + didMove = context.commonOps.moveCaretUpstream( + expand: true, + movementModifier: MovementModifier.word, + ); + + if (didMove) { + context.commonOps.deleteSelection(TextAffinity.upstream); + } +} + +void deleteWordForward(SuperEditorContext context) { + bool didMove = false; + + didMove = context.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.word, + ); + + if (didMove) { + context.commonOps.deleteSelection(TextAffinity.downstream); + } +} + +void deleteToBeginningOfLine(SuperEditorContext context) { + bool didMove = false; + + didMove = context.commonOps.moveCaretUpstream( + expand: true, + movementModifier: MovementModifier.line, + ); + + if (didMove) { + context.commonOps.deleteSelection(TextAffinity.upstream); + } +} + +void deleteToEndOfLine(SuperEditorContext context) { + bool didMove = false; + + didMove = context.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.line, + ); + + if (didMove) { + context.commonOps.deleteSelection(TextAffinity.downstream); + } +} + +void deleteBackward(SuperEditorContext context) { + if (CurrentPlatform.isWeb) { + return; + } + context.commonOps.deleteUpstream(); +} + +void deleteForward(SuperEditorContext context) { + if (CurrentPlatform.isWeb) { + return; + } + context.commonOps.deleteDownstream(); +} + +void scrollToBeginningOfDocument(SuperEditorContext context) { + context.scroller.animateTo( + context.scroller.minScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); +} + +void scrollToEndOfDocument(SuperEditorContext context) { + context.scroller.animateTo( + context.scroller.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); +} + +void scrollPageUp(SuperEditorContext context) { + context.scroller.animateTo( + max(context.scroller.scrollOffset - context.scroller.viewportDimension, context.scroller.minScrollExtent), + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); +} + +void scrollPageDown(SuperEditorContext context) { + context.scroller.animateTo( + min(context.scroller.scrollOffset + context.scroller.viewportDimension, context.scroller.maxScrollExtent), + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); +} + +/// A collection of policies that dictate how a [SuperEditor]'s focus, selection, and +/// IME should interact, such as opening the software keyboard whenever [SuperEditor]'s +/// selection changes ([openKeyboardOnSelectionChange]). +class SuperEditorImePolicies { + const SuperEditorImePolicies({ + this.openKeyboardOnGainPrimaryFocus = true, + this.closeKeyboardOnLosePrimaryFocus = false, + this.openImeOnNonPrimaryFocusGain = true, + this.closeImeOnNonPrimaryFocusLost = true, + this.openKeyboardOnSelectionChange = true, + this.closeKeyboardOnSelectionLost = true, + }); + + /// Whether to automatically raise the software keyboard when [SuperEditor] + /// gains primary focus (not just regular focus). + /// + /// Defaults to `true`. + final bool openKeyboardOnGainPrimaryFocus; + + /// Whether to automatically close the software keyboard when [SuperEditor] + /// loses primary focus (even if it retains regular focus). + /// + /// Defaults to `false`, so that affordances, like a popover, can take primary + /// focus, while still sending IME content input to `SuperEditor` at the same + /// time. + final bool closeKeyboardOnLosePrimaryFocus; + + /// Whether to open an IME connection when `SuperEditor` gains NON-primary focus. + /// + /// Defaults to `true`. + final bool openImeOnNonPrimaryFocusGain; + + /// Whether to close the IME connection when `SuperEditor` loses NON-primary focus. + /// + /// Defaults to `true`. + final bool closeImeOnNonPrimaryFocusLost; + + /// {@template openKeyboardOnSelectionChange} + /// Whether the software keyboard should be raised whenever the editor's selection + /// changes, such as when a user taps to place the caret. + /// + /// In a typical app, this property should be `true`. In some apps, the keyboard + /// needs to be closed and opened to reveal special editing controls. In those cases + /// this property should probably be `false`, and the app should take responsibility + /// for opening and closing the keyboard. + /// {@endtemplate} + final bool openKeyboardOnSelectionChange; + + /// Whether the software keyboard should be closed whenever the editor goes from + /// having a selection to not having a selection. + /// + /// In a typical app, this property should be `true`, because there's no place to + /// apply IME input when there's no editor selection. + final bool closeKeyboardOnSelectionLost; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorImePolicies && + runtimeType == other.runtimeType && + openKeyboardOnGainPrimaryFocus == other.openKeyboardOnGainPrimaryFocus && + closeKeyboardOnLosePrimaryFocus == other.closeKeyboardOnLosePrimaryFocus && + openImeOnNonPrimaryFocusGain == other.openImeOnNonPrimaryFocusGain && + closeImeOnNonPrimaryFocusLost == other.closeImeOnNonPrimaryFocusLost && + openKeyboardOnSelectionChange == other.openKeyboardOnSelectionChange && + closeKeyboardOnSelectionLost == other.closeKeyboardOnSelectionLost; + + @override + int get hashCode => + openKeyboardOnGainPrimaryFocus.hashCode ^ + closeKeyboardOnLosePrimaryFocus.hashCode ^ + openImeOnNonPrimaryFocusGain.hashCode ^ + closeImeOnNonPrimaryFocusLost.hashCode ^ + openKeyboardOnSelectionChange.hashCode ^ + closeKeyboardOnSelectionLost.hashCode; +} + +/// Input Method Engine (IME) configuration for document text input. +class SuperEditorImeConfiguration { + const SuperEditorImeConfiguration({ + this.enableAutocorrect = true, + this.enableSuggestions = true, + this.keyboardBrightness = Brightness.light, + this.keyboardActionButton = TextInputAction.newline, + }); + + /// Whether the OS should offer auto-correction options to the user. + final bool enableAutocorrect; + + /// Whether the OS should offer text completion suggestions to the user. + final bool enableSuggestions; + + /// The brightness of the software keyboard (only applies to platforms + /// with a software keyboard). + final Brightness keyboardBrightness; + + /// The action button that's displayed on a software keyboard, e.g., + /// new-line, done, go, etc. + final TextInputAction keyboardActionButton; + + /// Converts this configuration to a [TextInputConfiguration] that can be used to attach to the IME. + /// + /// The [viewId] is required do determine the view that the text input belongs to. You can call + /// `View.of(context).viewId` to get the current view's ID. + TextInputConfiguration toTextInputConfiguration({ + required int viewId, + }) { + return TextInputConfiguration( + viewId: viewId, + enableDeltaModel: true, + inputType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + autocorrect: enableAutocorrect, + enableSuggestions: enableSuggestions, + inputAction: keyboardActionButton, + keyboardAppearance: keyboardBrightness, + ); + } + + SuperEditorImeConfiguration copyWith({ + bool? enableAutocorrect, + bool? enableSuggestions, + Brightness? keyboardBrightness, + TextInputAction? keyboardActionButton, + bool? clearSelectionWhenImeDisconnects, + }) { + return SuperEditorImeConfiguration( + enableAutocorrect: enableAutocorrect ?? this.enableAutocorrect, + enableSuggestions: enableSuggestions ?? this.enableSuggestions, + keyboardBrightness: keyboardBrightness ?? this.keyboardBrightness, + keyboardActionButton: keyboardActionButton ?? this.keyboardActionButton, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorImeConfiguration && + runtimeType == other.runtimeType && + enableAutocorrect == other.enableAutocorrect && + enableSuggestions == other.enableSuggestions && + keyboardBrightness == other.keyboardBrightness && + keyboardActionButton == other.keyboardActionButton; + + @override + int get hashCode => + enableAutocorrect.hashCode ^ + enableSuggestions.hashCode ^ + keyboardBrightness.hashCode ^ + keyboardActionButton.hashCode; +} diff --git a/super_editor/lib/src/default_editor/document_input_ime.dart b/super_editor/lib/src/default_editor/document_input_ime.dart deleted file mode 100644 index 27dbc753b2..0000000000 --- a/super_editor/lib/src/default_editor/document_input_ime.dart +++ /dev/null @@ -1,1334 +0,0 @@ -import 'dart:math'; - -import 'package:attributed_text/attributed_text.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:super_editor/src/core/document.dart'; -import 'package:super_editor/src/core/document_composer.dart'; -import 'package:super_editor/src/core/document_editor.dart'; -import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/core/edit_context.dart'; -import 'package:super_editor/src/default_editor/common_editor_operations.dart'; -import 'package:super_editor/src/default_editor/paragraph.dart'; -import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; -import 'package:super_editor/src/default_editor/text.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; -import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; -import 'package:super_editor/src/infrastructure/keyboard.dart'; -import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; - -import 'attributions.dart'; -import 'document_input_keyboard.dart'; -import 'list_items.dart'; - -/// Governs document input that comes from the operating system's -/// Input Method Engine (IME). -/// -/// IME input is the only form of input that can come from a mobile -/// device's software keyboard. In a desktop environment with a -/// physical keyboard, developers can choose to respond to IME input -/// or individual key presses on the keyboard. For key press input, -/// see super_editor's keyboard input support. - -/// Document interactor that changes a document based on IME input -/// from the operating system. -class DocumentImeInteractor extends StatefulWidget { - const DocumentImeInteractor({ - Key? key, - this.focusNode, - this.autofocus = false, - required this.editContext, - required this.softwareKeyboardHandler, - this.hardwareKeyboardActions = const [], - this.floatingCursorController, - required this.child, - }) : super(key: key); - - final FocusNode? focusNode; - - final bool autofocus; - - final EditContext editContext; - - final SoftwareKeyboardHandler softwareKeyboardHandler; - - /// All the actions that the user can execute with physical hardware - /// keyboard keys. - /// - /// [keyboardActions] operates as a Chain of Responsibility. Starting - /// from the beginning of the list, a [DocumentKeyboardAction] is - /// given the opportunity to handle the currently pressed keys. If that - /// [DocumentKeyboardAction] reports the keys as handled, then execution - /// stops. Otherwise, execution continues to the next [DocumentKeyboardAction]. - final List hardwareKeyboardActions; - - final FloatingCursorController? floatingCursorController; - - final Widget child; - - @override - State createState() => _DocumentImeInteractorState(); -} - -class _DocumentImeInteractorState extends State - with TextInputClient, DeltaTextInputClient - implements ImeInputOwner { - late FocusNode _focusNode; - - TextInputConnection? _inputConnection; - - @override - void initState() { - super.initState(); - - _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); - - widget.editContext.composer.selectionNotifier.addListener(_onComposerChange); - widget.editContext.composer.imeConfiguration.addListener(_onClientWantsDifferentImeConfiguration); - } - - @override - void didUpdateWidget(DocumentImeInteractor oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.focusNode != oldWidget.focusNode) { - _focusNode.removeListener(_onFocusChange); - _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); - } - - if (widget.editContext.composer.selectionNotifier != oldWidget.editContext.composer.selectionNotifier) { - oldWidget.editContext.composer.selectionNotifier.removeListener(_onComposerChange); - widget.editContext.composer.selectionNotifier.addListener(_onComposerChange); - } - if (widget.editContext.composer.imeConfiguration != oldWidget.editContext.composer.imeConfiguration) { - oldWidget.editContext.composer.imeConfiguration.removeListener(_onClientWantsDifferentImeConfiguration); - oldWidget.editContext.composer.imeConfiguration.addListener(_onClientWantsDifferentImeConfiguration); - } - } - - @override - void dispose() { - _detachFromIme(); - - widget.editContext.composer.imeConfiguration.removeListener(_onClientWantsDifferentImeConfiguration); - widget.editContext.composer.selectionNotifier.removeListener(_onComposerChange); - - if (widget.focusNode == null) { - _focusNode.dispose(); - } - - super.dispose(); - } - - @override - DeltaTextInputClient get imeClient => this; - - void _onFocusChange() { - if (_focusNode.hasFocus) { - editorImeLog.info('Gained focus'); - _attachToIme(); - } else { - editorImeLog.info('Lost focus'); - _detachFromIme(); - } - } - - void _onComposerChange() { - final selection = widget.editContext.composer.selection; - editorImeLog.info("Document composer (${widget.editContext.composer.hashCode}) changed. New selection: $selection"); - - if (selection == null) { - _detachFromIme(); - } else { - if (isAttachedToIme && !_isApplyingDeltas) { - // Note: ^ We don't re-serialize and send to IME while we're in the middle - // of applying deltas because we might be in an inconsistent state. A sync - // will be done when all the deltas have been applied. - _inputConnection!.show(); - editorImeLog.fine( - "Document composer changed while attached to IME. Re-serializing the document and sending to the IME."); - _syncImeWithDocumentAndComposer(); - } else if (!isAttachedToIme) { - _attachToIme(); - } - } - } - - void _onClientWantsDifferentImeConfiguration() { - if (!isAttachedToIme) { - return; - } - - editorImeLog.fine( - "Updating IME to use new config with action button: ${widget.editContext.composer.imeConfiguration.value.keyboardActionButton}"); - _inputConnection!.updateConfig(_createInputConfiguration()); - } - - bool get isAttachedToIme => _inputConnection?.attached == true; - - void _attachToIme() { - if (isAttachedToIme) { - // We're already connected to the IME. - return; - } - - editorImeLog.info('Attaching TextInputClient to TextInput'); - - _inputConnection = TextInput.attach( - this, - _createInputConfiguration(), - ); - - _syncImeWithDocumentAndComposer(); - - _inputConnection! - ..show() - ..setEditingState(currentTextEditingValue); - - editorImeLog.fine('Is attached to input client? ${_inputConnection!.attached}'); - } - - TextInputConfiguration _createInputConfiguration() { - final imeConfig = widget.editContext.composer.imeConfiguration.value; - - return TextInputConfiguration( - enableDeltaModel: true, - inputType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - autocorrect: imeConfig.enableAutocorrect, - enableSuggestions: imeConfig.enableSuggestions, - inputAction: imeConfig.keyboardActionButton, - keyboardAppearance: imeConfig.keyboardBrightness ?? MediaQuery.of(context).platformBrightness, - ); - } - - void _detachFromIme() { - if (!isAttachedToIme) { - return; - } - - editorImeLog.info('Detaching TextInputClient from TextInput.'); - - widget.editContext.composer.selection = null; - - _inputConnection!.close(); - } - - @override - // TODO: implement currentAutofillScope - AutofillScope? get currentAutofillScope => throw UnimplementedError(); - - @override - TextEditingValue get currentTextEditingValue => _currentTextEditingValue; - TextEditingValue _currentTextEditingValue = const TextEditingValue(); - DocumentImeSerializer? _currentImeSerialization; - TextEditingValue? _lastTextEditingValueSentToOs; - set currentTextEditingValue(TextEditingValue newValue) { - _currentTextEditingValue = newValue; - if (newValue != _lastTextEditingValueSentToOs && !_isApplyingDeltas) { - editorImeLog.info("Sending new text editing value to OS: $_currentTextEditingValue"); - _inputConnection?.setEditingState(_currentTextEditingValue); - _lastTextEditingValueSentToOs = _currentTextEditingValue; - } else if (_isApplyingDeltas) { - editorImeLog.fine("Ignoring new TextEditingValue because we're applying deltas"); - } else { - editorImeLog.fine("Ignoring new TextEditingValue because it's the same as the existing one: $newValue"); - } - } - - bool _isApplyingDeltas = false; - - void _syncImeWithDocumentAndComposer([TextRange? newComposingRegion]) { - final selection = widget.editContext.composer.selection; - if (selection != null) { - editorImeLog.fine("Syncing IME with Doc and Composer, given composing region: $newComposingRegion"); - - final newDocSerialization = DocumentImeSerializer( - widget.editContext.editor.document, - selection, - ); - - editorImeLog.fine("Previous doc serialization did prepend? ${_currentImeSerialization?.didPrependPlaceholder}"); - editorImeLog.fine("Desired composing region: $newComposingRegion"); - editorImeLog.fine("Did new doc prepend placeholder? ${newDocSerialization.didPrependPlaceholder}"); - TextRange composingRegion = newComposingRegion ?? currentTextEditingValue.composing; - if (_currentImeSerialization != null && - _currentImeSerialization!.didPrependPlaceholder && - composingRegion.isValid && - !newDocSerialization.didPrependPlaceholder) { - // The IME's desired composing region includes the prepended placeholder. - // The updated IME value doesn't have a prepended placeholder, adjust - // the composing region bounds. - composingRegion = TextRange( - start: composingRegion.start - 2, - end: composingRegion.end - 2, - ); - } - - _currentImeSerialization = newDocSerialization; - currentTextEditingValue = newDocSerialization.toTextEditingValue().copyWith(composing: composingRegion); - } - } - - @override - void updateEditingValue(TextEditingValue value) { - editorImeLog.info("Received new TextEditingValue from OS: $value"); - setState(() { - _currentTextEditingValue = value; - }); - } - - @override - void updateEditingValueWithDeltas(List textEditingDeltas) { - editorImeLog.info("Received edit deltas from platform: ${textEditingDeltas.length} deltas"); - for (final delta in textEditingDeltas) { - editorImeLog.info("$delta"); - } - - final imeValueBeforeChange = currentTextEditingValue; - editorImeLog.fine("IME value before applying deltas: $imeValueBeforeChange"); - - _isApplyingDeltas = true; - widget.softwareKeyboardHandler.applyDeltas(textEditingDeltas); - _isApplyingDeltas = false; - - editorImeLog.fine("Done applying deltas. Serializing the document and sending to IME."); - _syncImeWithDocumentAndComposer(textEditingDeltas.last.composing); - - editorImeLog.fine("IME value after applying deltas: $currentTextEditingValue"); - - final hasDestructiveUpdate = - textEditingDeltas.where((element) => element is! TextEditingDeltaNonTextUpdate).toList().isNotEmpty; - if (hasDestructiveUpdate && imeValueBeforeChange == currentTextEditingValue) { - // Sometimes the IME reports changes to us, but our document doesn't change - // in ways that's reflected in the IME. In this case, we need to "reset" - // the IME value to what it was before the deltas. - // - // Example: The user has a caret in an empty paragraph. That empty paragraph - // includes a couple hidden characters, so the IME value might look like: - // - // ". |" - // - // The ". " substring is invisible to the user and the "|" represents the caret at - // the beginning of the empty paragraph. - // - // Then the user inserts a newline "\n". This causes Super Editor to insert a new, - // empty paragraph node, and place the caret in the new, empty paragraph. At this - // point, we have an issue: - // - // This class still sees the TextEditingValue as: ". |" - // - // However, the OS IME thinks the TextEditingValue is: ". |\n" - // - // In this situation, even though our TextEditingValue looks identical to what it - // was before, we need to send our TextEditingValue to the OS so that the OS doesn't - // think there's a "\n" sitting in the edit region. - editorImeLog.fine( - "Sending forceful update to IME because our local TextEditingValue didn't change, but the IME may have"); - _inputConnection!.setEditingState(currentTextEditingValue); - } - } - - @override - void performAction(TextInputAction action) { - editorImeLog.fine("IME says to perform action: $action"); - widget.softwareKeyboardHandler.performAction(action); - } - - @override - void performSelector(String selectorName) { - // TODO: implement this method starting with Flutter 3.3.4 - } - - @override - void performPrivateCommand(String action, Map data) { - // TODO: implement performPrivateCommand - } - - @override - void showAutocorrectionPromptRect(int start, int end) { - // TODO: implement showAutocorrectionPromptRect - } - - @override - void updateFloatingCursor(RawFloatingCursorPoint point) { - switch (point.state) { - case FloatingCursorDragState.Start: - case FloatingCursorDragState.Update: - widget.floatingCursorController?.offset = point.offset; - break; - case FloatingCursorDragState.End: - widget.floatingCursorController?.offset = null; - break; - } - } - - @override - void connectionClosed() { - editorImeLog.info("IME connection closed"); - _inputConnection = null; - } - - KeyEventResult _onKeyPressed(FocusNode node, RawKeyEvent keyEvent) { - if (keyEvent is! RawKeyDownEvent) { - editorKeyLog.finer("Received key event, but ignoring because it's not a down event: $keyEvent"); - return KeyEventResult.handled; - } - - editorKeyLog.info("Handling key press: $keyEvent"); - ExecutionInstruction instruction = ExecutionInstruction.continueExecution; - int index = 0; - while (instruction == ExecutionInstruction.continueExecution && index < widget.hardwareKeyboardActions.length) { - instruction = widget.hardwareKeyboardActions[index]( - editContext: widget.editContext, - keyEvent: keyEvent, - ); - index += 1; - } - - switch (instruction) { - case ExecutionInstruction.haltExecution: - return KeyEventResult.handled; - case ExecutionInstruction.continueExecution: - case ExecutionInstruction.blocked: - return KeyEventResult.ignored; - } - } - - @override - Widget build(BuildContext context) { - return Focus( - focusNode: _focusNode, - autofocus: widget.autofocus, - onKey: widget.hardwareKeyboardActions.isEmpty ? null : _onKeyPressed, - child: widget.child, - ); - } -} - -class DocumentImeSerializer { - static const _leadingCharacter = '. '; - - DocumentImeSerializer(this._doc, this._selection) { - _serialize(); - } - - final Document _doc; - final DocumentSelection _selection; - final _imeRangesToDocTextNodes = {}; - final _docTextNodesToImeRanges = {}; - final _selectedNodes = []; - late String _imeText; - String _prependedPlaceholder = ''; - - void _serialize() { - editorImeLog.fine("Creating an IME model from document and selection"); - final buffer = StringBuffer(); - int characterCount = 0; - - if (_shouldPrependPlaceholder()) { - // Put an arbitrary character at the front of the text so that - // the IME will report backspace buttons when the caret sits at - // the beginning of the node. For example, the caret is at the - // beginning of some text and we want to combine this text with - // the text above it when the user presses backspace. - // - // Text above... - // |The selected text node. - _prependedPlaceholder = _leadingCharacter; - buffer.write(_prependedPlaceholder); - characterCount = _prependedPlaceholder.length; - } else { - _prependedPlaceholder = ''; - } - - _selectedNodes.clear(); - _selectedNodes.addAll(_doc.getNodesInContentOrder(_selection)); - for (int i = 0; i < _selectedNodes.length; i += 1) { - // Append a newline character before appending another node's text. - // - // The choice to separate each node with a newline was a judgement call. - // There is no OS-level expectation for how structured content should - // collapse down to IME content. - if (i != 0) { - buffer.write('\n'); - characterCount += 1; - } - - final node = _selectedNodes[i]; - if (node is! TextNode) { - buffer.write('~'); - characterCount += 1; - - final imeRange = TextRange(start: characterCount - 1, end: characterCount); - _imeRangesToDocTextNodes[imeRange] = node.id; - _docTextNodesToImeRanges[node.id] = imeRange; - - continue; - } - - // Cache mappings between the IME text range and the document position - // so that we can easily convert between the two, when requested. - final imeRange = TextRange(start: characterCount, end: characterCount + node.text.text.length); - _imeRangesToDocTextNodes[imeRange] = node.id; - _docTextNodesToImeRanges[node.id] = imeRange; - - // Concatenate this node's text with the previous nodes. - buffer.write(node.text.text); - characterCount += node.text.text.length; - } - - _imeText = buffer.toString(); - editorImeLog.fine("IME serialization:\n'$_imeText'"); - } - - bool _shouldPrependPlaceholder() { - // We want to prepend an arbitrary placeholder character whenever the - // user's selection is collapsed at the beginning of a node, and there's - // another node above the selected node. Without the arbitrary character, - // the IME would assume that there's no content before the current node and - // therefore it wouldn't report the backspace button. - final selectedNode = _doc.getNode(_selection.extent)!; - final selectedNodeIndex = _doc.getNodeIndexById(selectedNode.id); - return selectedNodeIndex > 0 && - _selection.isCollapsed && - _selection.extent.nodePosition == selectedNode.beginningPosition; - } - - bool get didPrependPlaceholder => _prependedPlaceholder.isNotEmpty; - - DocumentSelection? imeToDocumentSelection(TextSelection imeSelection) { - editorImeLog.fine("Creating doc selection from IME selection: $imeSelection"); - if (didPrependPlaceholder && - ((!imeSelection.isCollapsed && imeSelection.start < _prependedPlaceholder.length) || - (imeSelection.isCollapsed && imeSelection.extentOffset <= _prependedPlaceholder.length))) { - // The IME is trying to select our artificial prepended character. - // If that's the only character that the IME is trying to select, then - // return a null selection to indicate that there's nothing to select. - // If the selection is expanded, then remove the arbitrary character from - // the selection. - if ((imeSelection.isCollapsed && imeSelection.extentOffset < _prependedPlaceholder.length) || - (imeSelection.start < _prependedPlaceholder.length && imeSelection.end == _prependedPlaceholder.length)) { - editorImeLog.fine("Returning null doc selection"); - return null; - } else { - editorImeLog.fine("Removing arbitrary character from IME selection"); - imeSelection = imeSelection.copyWith( - baseOffset: min(imeSelection.baseOffset, _prependedPlaceholder.length), - extentOffset: min(imeSelection.extentOffset, _prependedPlaceholder.length), - ); - editorImeLog.fine("Adjusted IME selection is: $imeSelection"); - } - } else { - editorImeLog.fine("Mapping the IME base/extent to their corresponding doc positions without modification."); - } - - return DocumentSelection( - base: _imeToDocumentPosition( - imeSelection.base, - isUpstream: imeSelection.base.affinity == TextAffinity.upstream, - ), - extent: _imeToDocumentPosition( - imeSelection.extent, - isUpstream: imeSelection.extent.affinity == TextAffinity.upstream, - ), - ); - } - - DocumentPosition _imeToDocumentPosition(TextPosition imePosition, {required bool isUpstream}) { - for (final range in _imeRangesToDocTextNodes.keys) { - if (imePosition.offset >= range.start && imePosition.offset <= range.end) { - final node = _doc.getNodeById(_imeRangesToDocTextNodes[range]!)!; - - if (node is TextNode) { - return DocumentPosition( - nodeId: _imeRangesToDocTextNodes[range]!, - nodePosition: TextNodePosition(offset: imePosition.offset - range.start), - ); - } else { - if (imePosition.offset <= range.start) { - // Return a position at the start of the node. - return DocumentPosition( - nodeId: node.id, - nodePosition: node.beginningPosition, - ); - } else { - // Return a position at the end of the node. - return DocumentPosition( - nodeId: node.id, - nodePosition: node.endPosition, - ); - } - } - } - } - - editorImeLog.shout( - "Couldn't map an IME position to a document position. IME position: $imePosition. The selected offset range is: ${_imeRangesToDocTextNodes.keys.last.start} -> ${_imeRangesToDocTextNodes.keys.last.end}"); - throw Exception("Couldn't map an IME position to a document position. IME position: $imePosition"); - } - - TextSelection documentToImeSelection(DocumentSelection docSelection) { - editorImeLog.fine("Converting doc selection to ime selection: $docSelection"); - final selectionAffinity = _doc.getAffinityForSelection(docSelection); - - final startDocPosition = selectionAffinity == TextAffinity.downstream ? docSelection.base : docSelection.extent; - final startImePosition = _documentToImePosition(startDocPosition); - - final endDocPosition = selectionAffinity == TextAffinity.downstream ? docSelection.extent : docSelection.base; - final endImePosition = _documentToImePosition(endDocPosition); - - editorImeLog.fine("Start IME position: $startImePosition"); - editorImeLog.fine("End IME position: $endImePosition"); - return TextSelection( - baseOffset: startImePosition.offset, - extentOffset: endImePosition.offset, - affinity: startImePosition == endImePosition ? endImePosition.affinity : TextAffinity.downstream, - ); - } - - TextPosition _documentToImePosition(DocumentPosition docPosition) { - editorImeLog.fine("Converting DocumentPosition to IME TextPosition: $docPosition"); - final imeRange = _docTextNodesToImeRanges[docPosition.nodeId]; - if (imeRange == null) { - throw Exception("No such document position in the IME content: $docPosition"); - } - - final nodePosition = docPosition.nodePosition; - - if (nodePosition is UpstreamDownstreamNodePosition) { - if (nodePosition.affinity == TextAffinity.upstream) { - editorImeLog.fine("The doc position is an upstream position on a block."); - // Return the text position before the special character, - // e.g., "|~". - return TextPosition(offset: imeRange.start); - } else { - editorImeLog.fine("The doc position is a downstream position on a block."); - // Return the text position after the special character, - // e.g., "~|". - return TextPosition(offset: imeRange.start + 1); - } - } - - if (nodePosition is TextNodePosition) { - return TextPosition(offset: imeRange.start + (docPosition.nodePosition as TextNodePosition).offset); - } - - throw Exception("Super Editor doesn't know how to convert a $nodePosition into an IME-compatible selection"); - } - - TextEditingValue toTextEditingValue() { - editorImeLog.fine("Creating TextEditingValue from document. Selection: $_selection"); - editorImeLog.fine("Text:\n'$_imeText'"); - final imeSelection = documentToImeSelection(_selection); - editorImeLog.fine("Selection: $imeSelection"); - - return TextEditingValue( - text: _imeText, - selection: imeSelection, - ); - } - - /// Narrows the given [selection] until the base and extent both point to - /// `TextNode`s. - /// - /// If the given [selection] base and/or extent already point to a `TextNode` - /// then those same end-caps are retained in the returned `DocumentSelection`. - /// - /// If there is no text content within the [selection], `null` is returned. - DocumentSelection? _constrictToTextSelectionEndCaps(DocumentSelection selection) { - final baseNode = _doc.getNodeById(selection.base.nodeId)!; - final baseNodeIndex = _doc.getNodeIndexById(baseNode.id); - final extentNode = _doc.getNodeById(selection.extent.nodeId)!; - final extentNodeIndex = _doc.getNodeIndexById(extentNode.id); - - final startNode = baseNodeIndex <= extentNodeIndex ? baseNode : extentNode; - final startNodeIndex = _doc.getNodeIndexById(startNode.id); - final startPosition = - baseNodeIndex <= extentNodeIndex ? selection.base.nodePosition : selection.extent.nodePosition; - final endNode = baseNodeIndex <= extentNodeIndex ? extentNode : baseNode; - final endNodeIndex = _doc.getNodeIndexById(endNode.id); - final endPosition = baseNodeIndex <= extentNodeIndex ? selection.extent.nodePosition : selection.base.nodePosition; - - if (startNodeIndex == endNodeIndex) { - // The document selection is all in one node. - if (startNode is! TextNode) { - // The only content selected is non-text. Return null. - return null; - } - - // Part of a single TextNode is selected, therefore, the given selection - // is already restricted to text end caps. - return selection; - } - - DocumentNode? restrictedStartNode; - TextNodePosition? restrictedStartPosition; - if (startNode is TextNode) { - restrictedStartNode = startNode; - restrictedStartPosition = startPosition as TextNodePosition; - } else { - int restrictedStartNodeIndex = startNodeIndex + 1; - while (_doc.getNodeAt(restrictedStartNodeIndex) is! TextNode && restrictedStartNodeIndex <= endNodeIndex) { - restrictedStartNodeIndex += 1; - } - - if (_doc.getNodeAt(restrictedStartNodeIndex) is TextNode) { - restrictedStartNode = _doc.getNodeAt(restrictedStartNodeIndex); - restrictedStartPosition = const TextNodePosition(offset: 0); - } - } - - DocumentNode? restrictedEndNode; - TextNodePosition? restrictedEndPosition; - if (endNode is TextNode) { - restrictedEndNode = endNode; - restrictedEndPosition = endPosition as TextNodePosition; - } else { - int restrictedEndNodeIndex = endNodeIndex - 1; - while (_doc.getNodeAt(restrictedEndNodeIndex) is! TextNode && restrictedEndNodeIndex >= startNodeIndex) { - restrictedEndNodeIndex -= 1; - } - - if (_doc.getNodeAt(restrictedEndNodeIndex) is TextNode) { - restrictedEndNode = _doc.getNodeAt(restrictedEndNodeIndex); - restrictedEndPosition = TextNodePosition(offset: (restrictedEndNode as TextNode).text.text.length); - } - } - - // If there was no text between the selection end-caps, return null. - if (restrictedStartPosition == null || restrictedEndPosition == null) { - return null; - } - - return DocumentSelection( - base: DocumentPosition( - nodeId: restrictedStartNode!.id, - nodePosition: restrictedStartPosition, - ), - extent: DocumentPosition( - nodeId: restrictedEndNode!.id, - nodePosition: restrictedEndPosition, - ), - ); - } - - /// Serializes just enough document text to serve the needs of the IME. - /// - /// The serialized text includes all the content of all partially selected - /// nodes, plus one node on either side to allow for upstream and downstream - /// deletions. For example, the user press backspace at the beginning of a - /// paragraph. We need to tell the IME that there's content before the paragraph - /// so that the IME sends us the delete delta. - String _getMinimumTextForIME(DocumentSelection selection) { - final baseNode = _doc.getNodeById(selection.base.nodeId)!; - final baseNodeIndex = _doc.getNodeIndexById(baseNode.id); - final extentNode = _doc.getNodeById(selection.extent.nodeId)!; - final extentNodeIndex = _doc.getNodeIndexById(extentNode.id); - - final selectionStartNode = baseNodeIndex <= extentNodeIndex ? baseNode : extentNode; - final selectionStartNodeIndex = _doc.getNodeIndexById(selectionStartNode.id); - final startNodeIndex = max(selectionStartNodeIndex - 1, 0); - - final selectionEndNode = baseNodeIndex <= extentNodeIndex ? extentNode : baseNode; - final selectionEndNodeIndex = _doc.getNodeIndexById(selectionEndNode.id); - final endNodeIndex = min(selectionEndNodeIndex + 1, _doc.nodes.length - 1); - - final buffer = StringBuffer(); - for (int i = startNodeIndex; i <= endNodeIndex; i += 1) { - final node = _doc.getNodeAt(i); - if (node is! TextNode) { - continue; - } - - if (buffer.length > 0) { - buffer.write('\n'); - } - - buffer.write(node.text.text); - } - - return buffer.toString(); - } -} - -/// Input Method Engine (IME) configuration for document text input. -/// -/// The IME is an operating system component that observes text that's -/// being edited, and intercepts keyboard input to apply transforms to -/// the user's input. The alternative to IME input is for an app to -/// listen and respond to each individual keyboard key. On mobile, IME -/// input is the only available input system because there is no physical -/// keyboard. -class ImeConfiguration { - const ImeConfiguration({ - this.enableAutocorrect = true, - this.enableSuggestions = true, - this.keyboardBrightness, - this.keyboardActionButton = TextInputAction.newline, - }); - - /// Whether the OS should offer auto-correction options to the user. - final bool enableAutocorrect; - - /// Whether the OS should offer text completion suggestions to the user. - final bool enableSuggestions; - - /// The brightness of the software keyboard (only applies to platforms - /// with a software keyboard). - final Brightness? keyboardBrightness; - - /// The action button that's displayed on a software keyboard, e.g., - /// new-line, done, go, etc. - final TextInputAction keyboardActionButton; - - ImeConfiguration copyWith({ - bool? enableAutocorrect, - bool? enableSuggestions, - Brightness? keyboardBrightness, - TextInputAction? keyboardActionButton, - }) { - return ImeConfiguration( - enableAutocorrect: enableAutocorrect ?? this.enableAutocorrect, - enableSuggestions: enableSuggestions ?? this.enableSuggestions, - keyboardBrightness: keyboardBrightness ?? this.keyboardBrightness, - keyboardActionButton: keyboardActionButton ?? this.keyboardActionButton, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ImeConfiguration && - runtimeType == other.runtimeType && - enableAutocorrect == other.enableAutocorrect && - enableSuggestions == other.enableSuggestions && - keyboardBrightness == other.keyboardBrightness && - keyboardActionButton == other.keyboardActionButton; - - @override - int get hashCode => - enableAutocorrect.hashCode ^ - enableSuggestions.hashCode ^ - keyboardBrightness.hashCode ^ - keyboardActionButton.hashCode; -} - -/// Applies software keyboard edits to a document. -class SoftwareKeyboardHandler { - const SoftwareKeyboardHandler({ - required this.editor, - required this.composer, - required this.commonOps, - }); - - final DocumentEditor editor; - final DocumentComposer composer; - final CommonEditorOperations commonOps; - - /// Applies the given [textEditingDeltas] to the [Document]. - void applyDeltas(List textEditingDeltas) { - editorImeLog.info("Applying ${textEditingDeltas.length} IME deltas to document"); - - for (final delta in textEditingDeltas) { - editorImeLog.info("Applying delta: $delta"); - if (delta is TextEditingDeltaInsertion) { - _applyInsertion(delta); - } else if (delta is TextEditingDeltaReplacement) { - _applyReplacement(delta); - } else if (delta is TextEditingDeltaDeletion) { - _applyDeletion(delta); - } else if (delta is TextEditingDeltaNonTextUpdate) { - _applyNonTextChange(delta); - } else { - editorImeLog.shout("Unknown IME delta type: ${delta.runtimeType}"); - } - } - } - - void _applyInsertion(TextEditingDeltaInsertion delta) { - editorImeLog.fine('Inserted text: "${delta.textInserted}"'); - editorImeLog.fine("Insertion offset: ${delta.insertionOffset}"); - editorImeLog.fine("Selection: ${delta.selection}"); - editorImeLog.fine("Composing: ${delta.composing}"); - editorImeLog.fine('Old text: "${delta.oldText}"'); - - if (delta.textInserted == "\n") { - // On iOS, newlines are reported here and also to performAction(). - // On Android and web, newlines are only reported here. So, on Android and web, - // we forward the newline action to performAction. - if (defaultTargetPlatform == TargetPlatform.android || kIsWeb) { - editorImeLog.fine("Received a newline insertion on Android. Forwarding to newline input action."); - performAction(TextInputAction.newline); - } else { - editorImeLog.fine("Skipping insertion delta because its a newline"); - } - return; - } - - if (delta.textInserted == "\t" && (defaultTargetPlatform == TargetPlatform.iOS)) { - // On iOS, tabs pressed at the the software keyboard are reported here. - commonOps.indentListItem(); - return; - } - - editorImeLog.fine( - "Inserting text: ${delta.textInserted}, insertion offset: ${delta.insertionOffset}, ime selection: ${delta.selection}"); - - insert( - TextPosition(offset: delta.insertionOffset, affinity: delta.selection.affinity), - delta.textInserted, - ); - } - - void _applyReplacement(TextEditingDeltaReplacement delta) { - editorImeLog.fine("Text replaced: '${delta.textReplaced}'"); - editorImeLog.fine("Replacement text: '${delta.replacementText}'"); - editorImeLog.fine("Replaced range: ${delta.replacedRange}"); - editorImeLog.fine("Selection: ${delta.selection}"); - editorImeLog.fine("Composing: ${delta.composing}"); - editorImeLog.fine('Old text: "${delta.oldText}"'); - - if (delta.replacementText == "\n") { - // On iOS, newlines are reported here and also to performAction(). - // On Android and web, newlines are only reported here. So, on Android and web, - // we forward the newline action to performAction. - if (defaultTargetPlatform == TargetPlatform.android || kIsWeb) { - editorImeLog.fine("Received a newline replacement on Android. Forwarding to newline input action."); - performAction(TextInputAction.newline); - } else { - editorImeLog.fine("Skipping replacement delta because its a newline"); - } - return; - } - - if (delta.replacementText == "\t" && (defaultTargetPlatform == TargetPlatform.iOS)) { - // On iOS, tabs pressed at the the software keyboard are reported here. - commonOps.indentListItem(); - return; - } - - replace(delta.replacedRange, delta.replacementText); - } - - void _applyDeletion(TextEditingDeltaDeletion delta) { - editorImeLog.fine("Delete delta:\n" - "Text deleted: '${delta.textDeleted}'\n" - "Deleted Range: ${delta.deletedRange}\n" - "Selection: ${delta.selection}\n" - "Composing: ${delta.composing}\n" - "Old text: '${delta.oldText}'"); - - delete(delta.deletedRange); - - editorImeLog.fine("Deletion operation complete"); - } - - void _applyNonTextChange(TextEditingDeltaNonTextUpdate delta) { - editorImeLog.fine("Non-text change:"); - // editorImeLog.fine("App-side selection - ${currentTextEditingValue.selection}"); - // editorImeLog.fine("App-side composing - ${currentTextEditingValue.composing}"); - editorImeLog.fine("OS-side selection - ${delta.selection}"); - editorImeLog.fine("OS-side composing - ${delta.composing}"); - // currentTextEditingValue = _currentTextEditingValue.copyWith(composing: delta.composing); - } - - void insert(TextPosition insertionPosition, String textInserted) { - if (textInserted == "\n") { - // Newlines are handled in performAction() - return; - } - - editorImeLog.fine('Inserting "$textInserted" at position "$insertionPosition"'); - editorImeLog.fine("Serializing document to perform IME operation"); - final docSerializer = DocumentImeSerializer( - editor.document, - composer.selection!, - ); - editorImeLog.fine("Converting IME insertion offset into a DocumentSelection"); - final insertionSelection = docSerializer.imeToDocumentSelection( - TextSelection.fromPosition(insertionPosition), - ); - editorImeLog - .fine("Updating the Document Composer's selection to place caret at insertion offset:\n$insertionSelection"); - final selectionBeforeInsertion = composer.selection; - composer.selection = insertionSelection; - - editorImeLog.fine("Inserting the text at the Document Composer's selection"); - final didInsert = commonOps.insertPlainText(textInserted); - editorImeLog.fine("Insertion successful? $didInsert"); - - if (!didInsert) { - editorImeLog.fine("Failed to insert characters. Restoring previous selection."); - composer.selection = selectionBeforeInsertion; - } - - commonOps.convertParagraphByPatternMatching( - composer.selection!.extent.nodeId, - ); - } - - void replace(TextRange replacedRange, String replacementText) { - final docSerializer = DocumentImeSerializer( - editor.document, - composer.selection!, - ); - - final replacementSelection = docSerializer.imeToDocumentSelection(TextSelection( - baseOffset: replacedRange.start, - // TODO: the delta API is wrong for TextRange.end, it should be exclusive, - // but it's implemented as inclusive. Change this code when Flutter - // fixes the problem. - extentOffset: replacedRange.end, - )); - - if (replacementSelection != null) { - composer.selection = replacementSelection; - } - editorImeLog.fine("Replacing selection: $replacementSelection"); - editorImeLog.fine('With text: "$replacementText"'); - - if (replacementText == "\n") { - performAction(TextInputAction.newline); - return; - } - - commonOps.insertPlainText(replacementText); - - commonOps.convertParagraphByPatternMatching( - composer.selection!.extent.nodeId, - ); - } - - void delete(TextRange deletedRange) { - final rangeToDelete = deletedRange; - final docSerializer = DocumentImeSerializer( - editor.document, - composer.selection!, - ); - final docSelectionToDelete = docSerializer.imeToDocumentSelection(TextSelection( - baseOffset: rangeToDelete.start, - extentOffset: rangeToDelete.end, - )); - editorImeLog.fine("Doc selection to delete: $docSelectionToDelete"); - - if (docSelectionToDelete == null) { - final selectedNodeIndex = editor.document.getNodeIndexById( - composer.selection!.extent.nodeId, - ); - if (selectedNodeIndex > 0) { - // The user is trying to delete upstream at the start of a node. - // This action requires intervention because the IME doesn't know - // that there's more content before this node. Instruct the editor - // to run a delete action upstream, which will take the desired - // "backspace" behavior at the start of this node. - commonOps.deleteUpstream(); - editorImeLog.fine("Deleted upstream. New selection: ${composer.selection}"); - return; - } - } - - editorImeLog.fine("Running selection deletion operation"); - composer.selection = docSelectionToDelete; - commonOps.deleteSelection(); - } - - void performAction(TextInputAction action) { - switch (action) { - case TextInputAction.newline: - if (!composer.selection!.isCollapsed) { - commonOps.deleteSelection(); - } - commonOps.insertBlockLevelNewline(); - break; - case TextInputAction.none: - // no-op - break; - case TextInputAction.done: - case TextInputAction.go: - case TextInputAction.search: - case TextInputAction.send: - case TextInputAction.next: - case TextInputAction.previous: - case TextInputAction.continueAction: - case TextInputAction.join: - case TextInputAction.route: - case TextInputAction.emergencyCall: - case TextInputAction.unspecified: - editorImeLog.warning("User pressed unhandled action button: $action"); - break; - } - } -} - -/// Toolbar that provides document editing capabilities, like converting -/// paragraphs to blockquotes and list items, and inserting horizontal -/// rules. -/// -/// This toolbar is intended to be placed just above the keyboard on a -/// mobile device. -class KeyboardEditingToolbar extends StatelessWidget { - const KeyboardEditingToolbar({ - Key? key, - required this.document, - required this.composer, - required this.commonOps, - this.brightness, - }) : super(key: key); - - final Document document; - final DocumentComposer composer; - final CommonEditorOperations commonOps; - final Brightness? brightness; - - bool get _isBoldActive => _doesSelectionHaveAttributions({boldAttribution}); - void _toggleBold() => _toggleAttributions({boldAttribution}); - - bool get _isItalicsActive => _doesSelectionHaveAttributions({italicsAttribution}); - void _toggleItalics() => _toggleAttributions({italicsAttribution}); - - bool get _isUnderlineActive => _doesSelectionHaveAttributions({underlineAttribution}); - void _toggleUnderline() => _toggleAttributions({underlineAttribution}); - - bool get _isStrikethroughActive => _doesSelectionHaveAttributions({strikethroughAttribution}); - void _toggleStrikethrough() => _toggleAttributions({strikethroughAttribution}); - - bool _doesSelectionHaveAttributions(Set attributions) { - final selection = composer.selection; - if (selection == null) { - return false; - } - - if (selection.isCollapsed) { - return composer.preferences.currentAttributions.containsAll(attributions); - } - - return document.doesSelectedTextContainAttributions(selection, attributions); - } - - void _toggleAttributions(Set attributions) { - final selection = composer.selection; - if (selection == null) { - return; - } - - selection.isCollapsed - ? commonOps.toggleComposerAttributions(attributions) - : commonOps.toggleAttributionsOnSelection(attributions); - } - - void _convertToHeader1() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId); - if (selectedNode is! TextNode) { - return; - } - - if (selectedNode is ListItemNode) { - commonOps.convertToParagraph( - newMetadata: { - 'blockType': header1Attribution, - }, - ); - } else { - selectedNode.putMetadataValue('blockType', header1Attribution); - } - } - - void _convertToHeader2() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId); - if (selectedNode is! TextNode) { - return; - } - - if (selectedNode is ListItemNode) { - commonOps.convertToParagraph( - newMetadata: { - 'blockType': header2Attribution, - }, - ); - } else { - selectedNode.putMetadataValue('blockType', header2Attribution); - } - } - - void _convertToParagraph() { - commonOps.convertToParagraph(); - } - - void _convertToOrderedListItem() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; - - commonOps.convertToListItem(ListItemType.ordered, selectedNode.text); - } - - void _convertToUnorderedListItem() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; - - commonOps.convertToListItem(ListItemType.unordered, selectedNode.text); - } - - void _convertToBlockquote() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; - - commonOps.convertToBlockquote(selectedNode.text); - } - - void _convertToHr() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; - - selectedNode.text = AttributedText(text: '--- '); - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: selectedNode.id, - nodePosition: const TextNodePosition(offset: 4), - ), - ); - commonOps.convertParagraphByPatternMatching(selectedNode.id); - } - - void _closeKeyboard() { - composer.selection = null; - } - - @override - Widget build(BuildContext context) { - final selection = composer.selection; - - if (selection == null) { - return const SizedBox(); - } - - final brightness = this.brightness ?? MediaQuery.of(context).platformBrightness; - - return Theme( - data: Theme.of(context).copyWith( - brightness: brightness, - disabledColor: brightness == Brightness.light ? Colors.black.withOpacity(0.5) : Colors.white.withOpacity(0.5), - ), - child: IconTheme( - data: IconThemeData( - color: brightness == Brightness.light ? Colors.black : Colors.white, - ), - child: Material( - child: Container( - width: double.infinity, - height: 48, - color: brightness == Brightness.light ? const Color(0xFFDDDDDD) : const Color(0xFF222222), - child: Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ListenableBuilder( - listenable: composer, - builder: (context) { - final selectedNode = document.getNodeById(selection.extent.nodeId); - final isSingleNodeSelected = selection.extent.nodeId == selection.base.nodeId; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: selectedNode is TextNode ? _toggleBold : null, - icon: const Icon(Icons.format_bold), - color: _isBoldActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: selectedNode is TextNode ? _toggleItalics : null, - icon: const Icon(Icons.format_italic), - color: _isItalicsActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: selectedNode is TextNode ? _toggleUnderline : null, - icon: const Icon(Icons.format_underline), - color: _isUnderlineActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: selectedNode is TextNode ? _toggleStrikethrough : null, - icon: const Icon(Icons.strikethrough_s), - color: _isStrikethroughActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && - selectedNode.getMetadataValue('blockType') != header1Attribution) - ? _convertToHeader1 - : null, - icon: const Icon(Icons.title), - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && - selectedNode.getMetadataValue('blockType') != header2Attribution) - ? _convertToHeader2 - : null, - icon: const Icon(Icons.title), - iconSize: 18, - ), - IconButton( - onPressed: isSingleNodeSelected && - ((selectedNode is ParagraphNode && - selectedNode.hasMetadataValue('blockType')) || - (selectedNode is TextNode && selectedNode is! ParagraphNode)) - ? _convertToParagraph - : null, - icon: const Icon(Icons.wrap_text), - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && selectedNode is! ListItemNode || - (selectedNode is ListItemNode && selectedNode.type != ListItemType.ordered)) - ? _convertToOrderedListItem - : null, - icon: const Icon(Icons.looks_one_rounded), - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && selectedNode is! ListItemNode || - (selectedNode is ListItemNode && - selectedNode.type != ListItemType.unordered)) - ? _convertToUnorderedListItem - : null, - icon: const Icon(Icons.list), - ), - IconButton( - onPressed: isSingleNodeSelected && - selectedNode is TextNode && - (selectedNode is! ParagraphNode || - selectedNode.getMetadataValue('blockType') != blockquoteAttribution) - ? _convertToBlockquote - : null, - icon: const Icon(Icons.format_quote), - ), - IconButton( - onPressed: isSingleNodeSelected && - selectedNode is ParagraphNode && - selectedNode.text.text.isEmpty - ? _convertToHr - : null, - icon: const Icon(Icons.horizontal_rule), - ), - ], - ); - }), - ), - ), - Container( - width: 1, - height: 32, - color: const Color(0xFFCCCCCC), - ), - IconButton( - onPressed: _closeKeyboard, - icon: const Icon(Icons.keyboard_hide), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/super_editor/lib/src/default_editor/document_input_keyboard.dart b/super_editor/lib/src/default_editor/document_input_keyboard.dart deleted file mode 100644 index aa345b6484..0000000000 --- a/super_editor/lib/src/default_editor/document_input_keyboard.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:super_editor/src/core/edit_context.dart'; -import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/keyboard.dart'; - -/// Governs document input that comes from a physical keyboard. -/// -/// Keyboard input won't work on a mobile device with a software -/// keyboard because the software keyboard sends input through -/// the operating system's Input Method Engine. For mobile use-cases, -/// see super_editor's IME input support. - -/// Receives all keyboard input, when focused, and invokes relevant document -/// editing actions on the given [editContext.editor]. -/// -/// [keyboardActions] determines the mapping from keyboard key presses -/// to document editing behaviors. [keyboardActions] operates as a -/// Chain of Responsibility. -class DocumentKeyboardInteractor extends StatelessWidget { - const DocumentKeyboardInteractor({ - Key? key, - required this.focusNode, - required this.editContext, - required this.keyboardActions, - required this.child, - this.autofocus = false, - }) : super(key: key); - - /// The source of all key events. - final FocusNode focusNode; - - /// Whether or not the [DocumentKeyboardInteractor] should autofocus - final bool autofocus; - - /// Service locator for document editing dependencies. - final EditContext editContext; - - /// All the actions that the user can execute with keyboard keys. - /// - /// [keyboardActions] operates as a Chain of Responsibility. Starting - /// from the beginning of the list, a [DocumentKeyboardAction] is - /// given the opportunity to handle the currently pressed keys. If that - /// [DocumentKeyboardAction] reports the keys as handled, then execution - /// stops. Otherwise, execution continues to the next [DocumentKeyboardAction]. - final List keyboardActions; - - /// The [child] widget, which is expected to include the document UI - /// somewhere in the sub-tree. - final Widget child; - - KeyEventResult _onKeyPressed(FocusNode node, RawKeyEvent keyEvent) { - if (keyEvent is! RawKeyDownEvent) { - editorKeyLog.finer("Received key event, but ignoring because it's not a down event: $keyEvent"); - return KeyEventResult.handled; - } - - editorKeyLog.info("Handling key press: $keyEvent"); - ExecutionInstruction instruction = ExecutionInstruction.continueExecution; - int index = 0; - while (instruction == ExecutionInstruction.continueExecution && index < keyboardActions.length) { - instruction = keyboardActions[index]( - editContext: editContext, - keyEvent: keyEvent, - ); - index += 1; - } - - switch (instruction) { - case ExecutionInstruction.haltExecution: - return KeyEventResult.handled; - case ExecutionInstruction.continueExecution: - case ExecutionInstruction.blocked: - return KeyEventResult.ignored; - } - } - - @override - Widget build(BuildContext context) { - return Focus( - focusNode: focusNode, - onKey: _onKeyPressed, - autofocus: autofocus, - child: child, - ); - } -} - -/// Executes this action, if the action wants to run, and returns -/// a desired `ExecutionInstruction` to either continue or halt -/// execution of actions. -/// -/// It is possible that an action makes changes and then returns -/// `ExecutionInstruction.continueExecution` to continue execution. -/// -/// It is possible that an action does nothing and then returns -/// `ExecutionInstruction.haltExecution` to prevent further execution. -typedef DocumentKeyboardAction = ExecutionInstruction Function({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}); - -/// A [DocumentKeyboardAction] that reports [ExecutionInstruction.blocked] -/// for any key combination that matches one of the given [keys]. -DocumentKeyboardAction ignoreKeyCombos(List keys) { - return ({ - required EditContext editContext, - required RawKeyEvent keyEvent, - }) { - for (final key in keys) { - if (key.accepts(keyEvent, RawKeyboard.instance)) { - return ExecutionInstruction.blocked; - } - } - return ExecutionInstruction.continueExecution; - }; -} diff --git a/super_editor/lib/src/default_editor/document_keyboard_actions.dart b/super_editor/lib/src/default_editor/document_keyboard_actions.dart deleted file mode 100644 index 0e3ef730bf..0000000000 --- a/super_editor/lib/src/default_editor/document_keyboard_actions.dart +++ /dev/null @@ -1,436 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:super_editor/src/core/document.dart'; -import 'package:super_editor/src/core/document_layout.dart'; -import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/core/edit_context.dart'; -import 'package:super_editor/src/default_editor/attributions.dart'; -import 'package:super_editor/src/infrastructure/keyboard.dart'; - -import 'paragraph.dart'; -import 'text.dart'; - -ExecutionInstruction doNothingWhenThereIsNoSelection({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (editContext.composer.selection == null) { - return ExecutionInstruction.haltExecution; - } else { - return ExecutionInstruction.continueExecution; - } -} - -ExecutionInstruction pasteWhenCmdVIsPressed({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyV) { - return ExecutionInstruction.continueExecution; - } - if (editContext.composer.selection == null) { - return ExecutionInstruction.continueExecution; - } - - editContext.commonOps.paste(); - - return ExecutionInstruction.haltExecution; -} - -ExecutionInstruction selectAllWhenCmdAIsPressed({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyA) { - return ExecutionInstruction.continueExecution; - } - - final didSelectAll = editContext.commonOps.selectAll(); - return didSelectAll ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; -} - -ExecutionInstruction copyWhenCmdCIsPressed({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyC) { - return ExecutionInstruction.continueExecution; - } - if (editContext.composer.selection == null) { - return ExecutionInstruction.continueExecution; - } - if (editContext.composer.selection!.isCollapsed) { - // Nothing to copy, but we technically handled the task. - return ExecutionInstruction.haltExecution; - } - - editContext.commonOps.copy(); - - return ExecutionInstruction.haltExecution; -} - -ExecutionInstruction cutWhenCmdXIsPressed({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyX) { - return ExecutionInstruction.continueExecution; - } - if (editContext.composer.selection == null) { - return ExecutionInstruction.continueExecution; - } - if (editContext.composer.selection!.isCollapsed) { - // Nothing to cut, but we technically handled the task. - return ExecutionInstruction.haltExecution; - } - - editContext.commonOps.cut(); - - return ExecutionInstruction.haltExecution; -} - -ExecutionInstruction cmdBToToggleBold({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyB) { - return ExecutionInstruction.continueExecution; - } - - if (editContext.composer.selection!.isCollapsed) { - editContext.commonOps.toggleComposerAttributions({boldAttribution}); - return ExecutionInstruction.haltExecution; - } else { - editContext.commonOps.toggleAttributionsOnSelection({boldAttribution}); - return ExecutionInstruction.haltExecution; - } -} - -ExecutionInstruction cmdIToToggleItalics({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyI) { - return ExecutionInstruction.continueExecution; - } - - if (editContext.composer.selection!.isCollapsed) { - editContext.commonOps.toggleComposerAttributions({italicsAttribution}); - return ExecutionInstruction.haltExecution; - } else { - editContext.commonOps.toggleAttributionsOnSelection({italicsAttribution}); - return ExecutionInstruction.haltExecution; - } -} - -ExecutionInstruction anyCharacterOrDestructiveKeyToDeleteSelection({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (editContext.composer.selection == null || editContext.composer.selection!.isCollapsed) { - return ExecutionInstruction.continueExecution; - } - - // Do nothing if CMD or CTRL are pressed because this signifies an attempted - // shortcut. - if (keyEvent.isControlPressed || keyEvent.isMetaPressed) { - return ExecutionInstruction.continueExecution; - } - - // Flutter reports a character for ESC, but we don't want to add a character - // for ESC. Ignore this key press - if (keyEvent.logicalKey == LogicalKeyboardKey.escape) { - return ExecutionInstruction.continueExecution; - } - - // Specifically exclude situations where shift is pressed because shift - // needs to alter the selection, not delete content. We have to explicitly - // look for this because when shift is pressed along with an arrow key, - // Flutter reports a non-null character. - if (keyEvent.isShiftPressed) { - return ExecutionInstruction.continueExecution; - } - - final isDestructiveKey = - keyEvent.logicalKey == LogicalKeyboardKey.backspace || keyEvent.logicalKey == LogicalKeyboardKey.delete; - final isCharacterKey = - keyEvent.character != null && keyEvent.character != '' && !isKeyEventCharacterBlacklisted(keyEvent.character); - - final shouldDeleteSelection = isDestructiveKey || isCharacterKey; - if (!shouldDeleteSelection) { - return ExecutionInstruction.continueExecution; - } - - editContext.commonOps.deleteSelection(); - - if (isCharacterKey) { - // We continue handler execution even though we deleted the selection. - // If the user pressed a character key, we want to let the character entry - // behavior run. - return ExecutionInstruction.continueExecution; - } - - // We deleted a selection in response to an explicit deletion key, e.g., - // BACKSPACE or DELETE. We don't want any other handlers to respond to - // this key. - return ExecutionInstruction.haltExecution; -} - -ExecutionInstruction backspaceToRemoveUpstreamContent({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { - return ExecutionInstruction.continueExecution; - } - - if (keyEvent.isMetaPressed || keyEvent.isAltPressed) { - return ExecutionInstruction.continueExecution; - } - - final didDelete = editContext.commonOps.deleteUpstream(); - - return didDelete ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; -} - -ExecutionInstruction mergeNodeWithNextWhenDeleteIsPressed({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (keyEvent.logicalKey != LogicalKeyboardKey.delete) { - return ExecutionInstruction.continueExecution; - } - - if (editContext.composer.selection == null) { - return ExecutionInstruction.continueExecution; - } - - final node = editContext.editor.document.getNodeById(editContext.composer.selection!.extent.nodeId); - if (node is! TextNode) { - return ExecutionInstruction.continueExecution; - } - - final nextNode = editContext.editor.document.getNodeAfter(node); - if (nextNode == null) { - return ExecutionInstruction.continueExecution; - } - if (nextNode is! TextNode) { - return ExecutionInstruction.continueExecution; - } - - final currentParagraphLength = node.text.text.length; - - // Send edit command. - editContext.editor.executeCommand( - CombineParagraphsCommand( - firstNodeId: node.id, - secondNodeId: nextNode.id, - ), - ); - - // Place the cursor at the point where the text came together. - editContext.composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: node.id, - nodePosition: TextNodePosition(offset: currentParagraphLength), - ), - ); - - return ExecutionInstruction.haltExecution; -} - -ExecutionInstruction moveUpDownLeftAndRightWithArrowKeys({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - const arrowKeys = [ - LogicalKeyboardKey.arrowLeft, - LogicalKeyboardKey.arrowRight, - LogicalKeyboardKey.arrowUp, - LogicalKeyboardKey.arrowDown, - ]; - if (!arrowKeys.contains(keyEvent.logicalKey)) { - return ExecutionInstruction.continueExecution; - } - - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { - return ExecutionInstruction.continueExecution; - } - - if (defaultTargetPlatform == TargetPlatform.linux && - keyEvent.isAltPressed && - (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp || keyEvent.logicalKey == LogicalKeyboardKey.arrowDown)) { - return ExecutionInstruction.continueExecution; - } - - bool didMove = false; - if (keyEvent.logicalKey == LogicalKeyboardKey.arrowLeft || keyEvent.logicalKey == LogicalKeyboardKey.arrowRight) { - MovementModifier? movementModifier; - if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && - keyEvent.isControlPressed) { - movementModifier = MovementModifier.word; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isMetaPressed) { - movementModifier = MovementModifier.line; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isAltPressed) { - movementModifier = MovementModifier.word; - } - - if (keyEvent.logicalKey == LogicalKeyboardKey.arrowLeft) { - // Move the caret left/upstream. - didMove = editContext.commonOps.moveCaretUpstream( - expand: keyEvent.isShiftPressed, - movementModifier: movementModifier, - ); - } else { - // Move the caret right/downstream. - didMove = editContext.commonOps.moveCaretDownstream( - expand: keyEvent.isShiftPressed, - movementModifier: movementModifier, - ); - } - } else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp) { - didMove = editContext.commonOps.moveCaretUp(expand: keyEvent.isShiftPressed); - } else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowDown) { - didMove = editContext.commonOps.moveCaretDown(expand: keyEvent.isShiftPressed); - } - - return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; -} - -ExecutionInstruction moveToLineStartOrEndWithCtrlAOrE({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (defaultTargetPlatform == TargetPlatform.macOS) { - return ExecutionInstruction.continueExecution; - } - - if (!keyEvent.isControlPressed) { - return ExecutionInstruction.continueExecution; - } - bool didMove = false; - - if (keyEvent.logicalKey == LogicalKeyboardKey.keyA) { - didMove = editContext.commonOps.moveCaretUpstream( - expand: keyEvent.isShiftPressed, - movementModifier: MovementModifier.line, - ); - } - - if (keyEvent.logicalKey == LogicalKeyboardKey.keyE) { - didMove = editContext.commonOps.moveCaretDownstream( - expand: keyEvent.isShiftPressed, - movementModifier: MovementModifier.line, - ); - } - - return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; -} - -ExecutionInstruction moveToLineStartWithHome({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { - return ExecutionInstruction.continueExecution; - } - - bool didMove = false; - if (keyEvent.logicalKey == LogicalKeyboardKey.home) { - didMove = editContext.commonOps.moveCaretUpstream( - expand: keyEvent.isShiftPressed, - movementModifier: MovementModifier.line, - ); - } - - return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; -} - -ExecutionInstruction moveToLineEndWithEnd({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { - return ExecutionInstruction.continueExecution; - } - - bool didMove = false; - if (keyEvent.logicalKey == LogicalKeyboardKey.end) { - didMove = editContext.commonOps.moveCaretDownstream( - expand: keyEvent.isShiftPressed, - movementModifier: MovementModifier.line, - ); - } - - return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; -} - -ExecutionInstruction deleteLineWithCmdBksp({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { - return ExecutionInstruction.continueExecution; - } - if (editContext.composer.selection == null) { - return ExecutionInstruction.continueExecution; - } - - bool didMove = false; - - didMove = editContext.commonOps.moveCaretUpstream( - expand: true, - movementModifier: MovementModifier.line, - ); - - if (didMove) { - return editContext.commonOps.deleteSelection() - ? ExecutionInstruction.haltExecution - : ExecutionInstruction.continueExecution; - } - return ExecutionInstruction.continueExecution; -} - -ExecutionInstruction deleteWordWithAltBksp({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (!keyEvent.isAltPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { - return ExecutionInstruction.continueExecution; - } - if (editContext.composer.selection == null) { - return ExecutionInstruction.continueExecution; - } - - bool didMove = false; - - didMove = editContext.commonOps.moveCaretUpstream( - expand: true, - movementModifier: MovementModifier.word, - ); - - if (didMove) { - return editContext.commonOps.deleteSelection() - ? ExecutionInstruction.haltExecution - : ExecutionInstruction.continueExecution; - } - return ExecutionInstruction.continueExecution; -} - -/// When the ESC key is pressed, the editor should collapse the expanded selection. -/// -/// Do nothing if selection is already collapsed. -ExecutionInstruction collapseSelectionWhenEscIsPressed({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (keyEvent.logicalKey != LogicalKeyboardKey.escape) { - return ExecutionInstruction.continueExecution; - } - if (editContext.composer.selection == null || editContext.composer.selection!.isCollapsed) { - return ExecutionInstruction.continueExecution; - } - - editContext.commonOps.collapseSelection(); - return ExecutionInstruction.haltExecution; -} diff --git a/super_editor/lib/src/default_editor/document_layers/attributed_text_bounds_overlay.dart b/super_editor/lib/src/default_editor/document_layers/attributed_text_bounds_overlay.dart new file mode 100644 index 0000000000..e86928f8d4 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_layers/attributed_text_bounds_overlay.dart @@ -0,0 +1,28 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/infrastructure/attribution_layout_bounds.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; + +/// A [SuperEditorLayerBuilder] that makes [AttributionBounds] usable by a `SuperEditor`. +/// +/// See [AttributionBounds] for the real implementation. +class AttributedTextBoundsOverlay implements SuperEditorLayerBuilder { + const AttributedTextBoundsOverlay({ + required this.selector, + required this.builder, + }); + + final AttributionBoundsSelector selector; + final AttributionBoundsBuilder builder; + + @override + ContentLayerStatefulWidget build(BuildContext context, SuperEditorContext editContext) { + return AttributionBounds( + document: editContext.document, + layout: editContext.documentLayout, + selector: selector, + builder: builder, + ); + } +} diff --git a/super_editor/lib/src/default_editor/document_scrollable.dart b/super_editor/lib/src/default_editor/document_scrollable.dart index a0ee4ef261..98dc5a6277 100644 --- a/super_editor/lib/src/default_editor/document_scrollable.dart +++ b/super_editor/lib/src/default_editor/document_scrollable.dart @@ -1,13 +1,17 @@ import 'dart:math'; import 'dart:ui'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/document_gestures.dart'; +import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/material_scrollbar.dart'; import 'package:super_editor/src/infrastructure/scrolling_diagnostics/_scrolling_minimap.dart'; -import '../infrastructure/document_gestures.dart'; - /// Scroller for a document. /// /// If there's an ancestor [Scrollable] in the widget tree, then this @@ -25,15 +29,37 @@ class DocumentScrollable extends StatefulWidget { const DocumentScrollable({ Key? key, required this.autoScroller, + this.scrollController, + this.scroller, + this.isScribbleInProgress, this.scrollingMinimapId, this.showDebugPaint = false, - this.scrollController, + required this.shrinkWrap, required this.child, }) : super(key: key); /// Controller that adjusts the scroll offset of this [DocumentScrollable]. final AutoScrollController autoScroller; + /// The [ScrollController] that governs this [DocumentScrollable]'s scroll + /// offset. + /// + /// `scrollController` is not used if this `SuperEditor` has an ancestor + /// `Scrollable`. + final ScrollController? scrollController; + + /// A [DocumentScroller], to which this scrollable attaches itself, so + /// that external actors, such as keyboard handlers, can query and change + /// the scroll offset. + final DocumentScroller? scroller; + + /// A listenable that reports whether a Scribble (Apple Pencil) or stylus + /// writing interaction is currently in progress. + /// + /// When scribble is active, touch/stylus scrolling is disabled to prevent + /// the scroll view from stealing the scribble gesture. + final ValueListenable? isScribbleInProgress; + /// ID that this widget's scrolling system registers with an ancestor /// [ScrollingMinimaps] to report scrolling diagnostics for debugging. final String? scrollingMinimapId; @@ -42,21 +68,19 @@ class DocumentScrollable extends StatefulWidget { /// debugging, when `true`. final bool showDebugPaint; - /// The [ScrollController] that governs this [DocumentScrollable]'s scroll - /// offset. - /// - /// `scrollController` is not used if this `SuperEditor` has an ancestor - /// `Scrollable`. - final ScrollController? scrollController; - /// This widget's child, which should include a document. final Widget child; + /// Whether to shrink wrap the [CustomScrollView] that's used to host + /// the editor content. Only used when there's no ancestor [Scrollable]. + final bool shrinkWrap; + @override State createState() => _DocumentScrollableState(); } -class _DocumentScrollableState extends State with SingleTickerProviderStateMixin { +class _DocumentScrollableState extends State + with SingleTickerProviderStateMixin { // The ScrollController that's used when we install our own Scrollable. late ScrollController _scrollController; // The ScrollPosition used when there's an ancestor Scrollable. @@ -69,7 +93,7 @@ class _DocumentScrollableState extends State with SingleTick super.initState(); _scrollController = widget.scrollController ?? ScrollController(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + onNextFrame((_) { // Wait until the next frame to attach to auto-scroller because // our ScrollController isn't attached to the Scrollable, yet. widget.autoScroller.attachScrollable( @@ -77,6 +101,8 @@ class _DocumentScrollableState extends State with SingleTick () => _viewport, () => _scrollPosition, ); + + widget.scroller?.attach(_scrollPosition); }); } @@ -89,9 +115,10 @@ class _DocumentScrollableState extends State with SingleTick // ancestor ScrollingMinimaps. if (widget.scrollingMinimapId != null) { _debugInstrumentation = ScrollableInstrumentation() - ..viewport.value = Scrollable.of(context)!.context - ..scrollPosition.value = Scrollable.of(context)!.position; - ScrollingMinimaps.of(context)?.put(widget.scrollingMinimapId!, _debugInstrumentation); + ..viewport.value = Scrollable.of(context).context + ..scrollPosition.value = Scrollable.of(context).position; + ScrollingMinimaps.of(context) + ?.put(widget.scrollingMinimapId!, _debugInstrumentation); } } @@ -106,13 +133,18 @@ class _DocumentScrollableState extends State with SingleTick } if (widget.autoScroller != oldWidget.autoScroller) { - widget.autoScroller.detachScrollable(); + oldWidget.autoScroller.detachScrollable(); widget.autoScroller.attachScrollable( this, () => _viewport, () => _scrollPosition, ); } + + if (widget.scroller != oldWidget.scroller) { + oldWidget.scroller?.detach(); + widget.scroller?.attach(_scrollPosition); + } } @override @@ -129,6 +161,9 @@ class _DocumentScrollableState extends State with SingleTick } widget.autoScroller.detachScrollable(); + + widget.scroller?.detach(); + super.dispose(); } @@ -141,7 +176,9 @@ class _DocumentScrollableState extends State with SingleTick /// widget includes a `ScrollView` and this `State`'s render object /// is the viewport `RenderBox`. RenderBox get _viewport => - (_findAncestorScrollable(context)?.context.findRenderObject() ?? context.findRenderObject()) as RenderBox; + (context.findAncestorScrollableWithVerticalScroll?.context + .findRenderObject() ?? + context.findRenderObject()) as RenderBox; /// Returns the `ScrollPosition` that controls the scroll offset of /// this widget. @@ -153,34 +190,21 @@ class _DocumentScrollableState extends State with SingleTick /// If this widget doesn't have an ancestor `Scrollable`, then this /// widget includes a `ScrollView` and the `ScrollView`'s position /// is returned. - ScrollPosition get _scrollPosition => _ancestorScrollPosition ?? _scrollController.position; - - ScrollableState? _findAncestorScrollable(BuildContext context) { - final ancestorScrollable = Scrollable.of(context); - if (ancestorScrollable == null) { - return null; - } - - final direction = ancestorScrollable.axisDirection; - // If the direction is horizontal, then we are inside a widget like a TabBar - // or a horizontal ListView, so we can't use the ancestor scrollable - if (direction == AxisDirection.left || direction == AxisDirection.right) { - return null; - } - - return ancestorScrollable; - } + ScrollPosition get _scrollPosition => + _ancestorScrollPosition ?? _scrollController.position; @override Widget build(BuildContext context) { - final ancestorScrollable = _findAncestorScrollable(context); + final ancestorScrollable = context.findAncestorScrollableWithVerticalScroll; _ancestorScrollPosition = ancestorScrollable?.position; - + if (ancestorScrollable != null) { + return widget.child; + } return Stack( children: [ - _ancestorScrollPosition == null // - ? _buildScroller(child: widget.child) // - : widget.child, + _buildScroller( + child: widget.child, + ), if (widget.showDebugPaint) ..._buildScrollingDebugPaint( includesScrollView: ancestorScrollable == null, @@ -192,9 +216,83 @@ class _DocumentScrollableState extends State with SingleTick Widget _buildScroller({ required Widget child, }) { - return SingleChildScrollView( + final scrollBehavior = ScrollConfiguration.of(context); + + Widget scrollView = CustomScrollView( controller: _scrollController, - physics: const NeverScrollableScrollPhysics(), + shrinkWrap: widget.shrinkWrap, + slivers: [child], + ); + + // When a Scribble/stylus writing interaction is in progress, disable + // touch scrolling so the scroll view doesn't steal the gesture from + // the IME's scribble handler. + if (widget.isScribbleInProgress != null) { + scrollView = ValueListenableBuilder( + valueListenable: widget.isScribbleInProgress!, + child: scrollView, + builder: (context, isScribbling, scrollViewChild) { + if (isScribbling) { + return ScrollConfiguration( + behavior: scrollBehavior.copyWith( + scrollbars: false, + physics: const NeverScrollableScrollPhysics(), + ), + child: scrollViewChild!, + ); + } + return ScrollConfiguration( + behavior: scrollBehavior.copyWith(scrollbars: false), + child: scrollViewChild!, + ); + }, + ); + } else { + scrollView = ScrollConfiguration( + behavior: scrollBehavior.copyWith(scrollbars: false), + child: scrollView, + ); + } + + return _maybeBuildScrollbar( + behavior: scrollBehavior, + child: scrollView, + ); + } + + Widget _maybeBuildScrollbar({ + required ScrollBehavior behavior, + required Widget child, + }) { + // We allow apps to prevent the custom scrollbar from being added by + // wrapping the editor with a `ScrollConfiguration` configured to not + // display scrollbars. However, at this moment we can't query this + // information from the BuildContext. As a workaround, we check whether + // or not the buildScrollbar method returns a ScrollBar. If it doesn't, + // this means the app doesn't want us to add our own ScrollBar. + // + // Change this after https://github.com/flutter/flutter/issues/141508 is solved. + final maybeScrollBar = behavior.buildScrollbar( + context, + child, + ScrollableDetails.vertical(controller: _scrollController), + ); + if (maybeScrollBar == child) { + // The scroll behavior is configured to NOT show scrollbars. + return child; + } + + // As we handle the scrolling gestures ourselves, + // we use NeverScrollableScrollPhysics to prevent SingleChildScrollView + // from scrolling. This also prevents the user from interacting + // with the scrollbar. + // We use a modified version of Flutter's Scrollbar that allows + // configuring it with a different scroll physics. + // + // See https://github.com/superlistapp/super_editor/issues/1628 for more details. + return ScrollbarWithCustomPhysics( + controller: _scrollController, + physics: behavior.getScrollPhysics(context), child: child, ); } @@ -314,8 +412,8 @@ class AutoScrollController with ChangeNotifier { /// /// A [vsync] is needed to create a [Ticker], which is used to animate /// auto-scrolling. - void attachScrollable( - TickerProvider vsync, ViewportResolver viewportResolver, ScrollPositionResolver scrollPositionResolver) { + void attachScrollable(TickerProvider vsync, ViewportResolver viewportResolver, + ScrollPositionResolver scrollPositionResolver) { detachScrollable(); _ticker = vsync.createTicker(_onTick); _getViewport = viewportResolver; @@ -329,7 +427,14 @@ class AutoScrollController with ChangeNotifier { // The scroll position changed. Probably because the position scrolled // up or down. Notify our listeners so that they can adjust the document // selection bounds or other related properties. - notifyListeners(); + // + // The scroll position may trigger layout changes, notify the listeners + // after the layout settles. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (hasScrollable) { + notifyListeners(); + } + }); } /// Stops controlling a [Scrollable] that was attached with [attachScrollable]. @@ -355,8 +460,14 @@ class AutoScrollController with ChangeNotifier { } final scrollPosition = _getScrollPosition!(); + + if (scrollPosition.maxScrollExtent == 0) { + return; + } + scrollPosition.jumpTo( - (scrollPosition.pixels + delta).clamp(0.0, scrollPosition.maxScrollExtent), + (scrollPosition.pixels + delta) + .clamp(0.0, scrollPosition.maxScrollExtent), ); } @@ -370,7 +481,9 @@ class AutoScrollController with ChangeNotifier { } if (pos is ScrollPositionWithSingleContext) { - pos.goBallistic(pixelsPerSecond); + if (pos.maxScrollExtent > 0) { + pos.goBallistic(pixelsPerSecond); + } pos.context.setIgnorePointer(false); } } @@ -384,7 +497,8 @@ class AutoScrollController with ChangeNotifier { } if (pos is ScrollPositionWithSingleContext) { - if (pos.pixels > pos.minScrollExtent && pos.pixels < pos.maxScrollExtent) { + if (pos.pixels > pos.minScrollExtent && + pos.pixels < pos.maxScrollExtent) { pos.goIdle(); } } @@ -412,20 +526,25 @@ class AutoScrollController with ChangeNotifier { final beyondTopExtent = min(selectionExtentRectInViewport.top, 0).abs(); - final beyondBottomExtent = max(selectionExtentRectInViewport.bottom - viewportBox.size.height, 0); + final beyondBottomExtent = + max(selectionExtentRectInViewport.bottom - viewportBox.size.height, 0); editorScrollingLog.finest('Ensuring extent is visible.'); editorScrollingLog.finest(' - viewport size: ${viewportBox.size}'); - editorScrollingLog.finest(' - scroll controller offset: ${scrollPosition.pixels}'); - editorScrollingLog.finest(' - selection extent rect in viewport: $selectionExtentRectInViewport'); + editorScrollingLog + .finest(' - scroll controller offset: ${scrollPosition.pixels}'); + editorScrollingLog.finest( + ' - selection extent rect in viewport: $selectionExtentRectInViewport'); editorScrollingLog.finest(' - beyond top: $beyondTopExtent'); editorScrollingLog.finest(' - beyond bottom: $beyondBottomExtent'); late double newScrollPosition; if (beyondTopExtent > 0) { - newScrollPosition = (scrollPosition.pixels - beyondTopExtent).clamp(0.0, scrollPosition.maxScrollExtent); + newScrollPosition = (scrollPosition.pixels - beyondTopExtent) + .clamp(0.0, scrollPosition.maxScrollExtent); } else if (beyondBottomExtent > 0) { - newScrollPosition = (beyondBottomExtent + scrollPosition.pixels).clamp(0.0, scrollPosition.maxScrollExtent); + newScrollPosition = (beyondBottomExtent + scrollPosition.pixels) + .clamp(0.0, scrollPosition.maxScrollExtent); } else { return; } @@ -519,19 +638,22 @@ class AutoScrollController with ChangeNotifier { if (_autoScrollGlobalRegion!.top < globalAutoScrollRect.top) { _scrollUp(globalAutoScrollRect.top - _autoScrollGlobalRegion!.top); } else if (_autoScrollGlobalRegion!.bottom > globalAutoScrollRect.bottom) { - _scrollDown(_autoScrollGlobalRegion!.bottom - globalAutoScrollRect.bottom); + _scrollDown( + _autoScrollGlobalRegion!.bottom - globalAutoScrollRect.bottom); } // We have to re-calculate the drag end in the doc (instead of // caching the value during the pan update) because the position // in the document is impacted by auto-scrolling behavior. - _deltaWhileAutoScrolling = _autoScrollingStartOffset! - _getScrollPosition!().pixels; + _deltaWhileAutoScrolling = + _autoScrollingStartOffset! - _getScrollPosition!().pixels; } void _scrollUp(double distanceInGutter) { final scrollPosition = _getScrollPosition!(); if (scrollPosition.pixels <= 0) { - editorScrollingLog.finest("Tried to scroll up but the scroll position is already at the top"); + editorScrollingLog.finest( + "Tried to scroll up but the scroll position is already at the top"); return; } @@ -541,7 +663,8 @@ class AutoScrollController with ChangeNotifier { final scrollAmount = lerpDouble(0, _maxScrollSpeed, speedPercent)!; editorScrollingLog.finest("Speed percent: $speedPercent"); - editorScrollingLog.finest("Jumping from ${scrollPosition.pixels} to ${scrollPosition.pixels + scrollAmount}"); + editorScrollingLog.finest( + "Jumping from ${scrollPosition.pixels} to ${scrollPosition.pixels + scrollAmount}"); scrollPosition.jumpTo(scrollPosition.pixels - scrollAmount); } @@ -549,7 +672,8 @@ class AutoScrollController with ChangeNotifier { void _scrollDown(double distanceInGutter) { final scrollPosition = _getScrollPosition!(); if (scrollPosition.pixels >= scrollPosition.maxScrollExtent) { - editorScrollingLog.finest("Tried to scroll down but the scroll position is already beyond the max"); + editorScrollingLog.finest( + "Tried to scroll down but the scroll position is already beyond the max"); return; } @@ -559,7 +683,8 @@ class AutoScrollController with ChangeNotifier { final scrollAmount = lerpDouble(0, _maxScrollSpeed, speedPercent)!; editorScrollingLog.finest("Speed percent: $speedPercent"); - editorScrollingLog.finest("Jumping from ${scrollPosition.pixels} to ${scrollPosition.pixels + scrollAmount}"); + editorScrollingLog.finest( + "Jumping from ${scrollPosition.pixels} to ${scrollPosition.pixels + scrollAmount}"); scrollPosition.jumpTo(scrollPosition.pixels + scrollAmount); } diff --git a/super_editor/lib/src/default_editor/document_selection_on_focus_mixin.dart b/super_editor/lib/src/default_editor/document_selection_on_focus_mixin.dart deleted file mode 100644 index 7b1d0fcaf4..0000000000 --- a/super_editor/lib/src/default_editor/document_selection_on_focus_mixin.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:super_editor/super_editor.dart'; - -/// Synchronizes document focus with document selection. -/// -/// Whenever the editor receives focus, if it was previously selected, the previous selection is restored. -/// -/// If there is no previous selection, the caret is moved to the end of the document. -/// -/// Start watching and synchronizing focus with selection by calling `startSyncingSelectionWithFocus`. -/// -/// Stop watching and synchronizing focus with selection by calling `stopSyncingSelectionWithFocus`. -/// -/// When the document's [FocusNode] changes, provide the new [FocusNode] to [onFocusNodeReplaced]. -/// -/// When the document's [DocumentComposer] changes, provide the new [DocumentComposer] to [onDocumentSelectionNotifierReplaced]. -/// -/// When the document's [DocumentLayoutResolver] changes, provide the new [DocumentLayoutResolver] to [onDocumentLayoutResolverReplaced]. -mixin DocumentSelectionOnFocusMixin on State { - // Holds the last selection, so we can restore it when the editor is re-focused. - DocumentSelection? _previousSelection; - - FocusNode? _focusNode; - DocumentLayoutResolver? _getDocumentLayout; - ValueNotifier? _selection; - - /// Starts watching and synchronizing focus with selection. - /// - /// Watches the document selection, so it can be restored after - /// the editor receives focus. - /// - /// If the previous selection isn't avaible when the editor receives focus, - /// the caret is moved to the end of the document. - void startSyncingSelectionWithFocus({ - required FocusNode focusNode, - required DocumentLayoutResolver getDocumentLayout, - required ValueNotifier selection, - }) { - _focusNode = focusNode; - _focusNode!.addListener(_onFocusChange); - _getDocumentLayout = getDocumentLayout; - _selection = selection; - _selection!.addListener(_onSelectionChange); - - // If we already start focused we need to check if the selection update is needed. - // This is happening on desktop when the editor uses autofocus. - if (focusNode.hasFocus) { - _onFocusChange(); - } - } - - // Stops watching and synchronizing focus with selection. - void stopSyncingSelectionWithFocus() { - _focusNode?.removeListener(_onFocusChange); - _selection?.removeListener(_onSelectionChange); - } - - /// Should be called whenever the editor `focusNode` is replaced. - void onFocusNodeReplaced(FocusNode? focusNode) { - _focusNode?.removeListener(_onFocusChange); - _focusNode = focusNode; - _focusNode!.addListener(_onFocusChange); - } - - /// Should be called whenever the editor selection notifier is replaced. - void onDocumentSelectionNotifierReplaced(ValueNotifier? selection) { - _selection?.removeListener(_onSelectionChange); - _selection = selection; - _selection?.addListener(_onSelectionChange); - } - - /// Should be called whenever the [DocumentLayoutResolver] is replaced. - void onDocumentLayoutResolverReplaced(DocumentLayoutResolver? layoutResolver) { - _getDocumentLayout = layoutResolver; - } - - void _onFocusChange() { - if (!_focusNode!.hasFocus) { - _selection?.value = null; - return; - } - - // We move the selection in the next frame, so we don't try to access the - // DocumentLayout before it is available when the editor has autofocus - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // We only update the selection when it's null - // because, when the user taps at the document the selection is - // already set to the correct position, so we don't override it. - if (mounted && _focusNode!.hasFocus && _selection!.value == null) { - if (_previousSelection != null) { - _selection?.value = _previousSelection; - return; - } - - DocumentPosition? position = _getDocumentLayout?.call().findLastSelectablePosition(); - if (position != null) { - _selection?.value = DocumentSelection.collapsed( - position: position, - ); - } - } - }); - } - - void _onSelectionChange() { - // We store the last selection so the next time the editor is focused - // the selection is restored. - if (_selection?.value != null) { - _previousSelection = _selection?.value; - } - } -} diff --git a/super_editor/lib/src/default_editor/horizontal_rule.dart b/super_editor/lib/src/default_editor/horizontal_rule.dart index 1bb4d4302a..3d3aadff66 100644 --- a/super_editor/lib/src/default_editor/horizontal_rule.dart +++ b/super_editor/lib/src/default_editor/horizontal_rule.dart @@ -1,5 +1,6 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/material.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/selection_aware_viewmodel.dart'; import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; import '../core/document.dart'; @@ -8,11 +9,13 @@ import 'layout_single_column/layout_single_column.dart'; /// [DocumentNode] for a horizontal rule, which represents a full-width /// horizontal separation in a document. -class HorizontalRuleNode extends BlockNode with ChangeNotifier { +@immutable +class HorizontalRuleNode extends BlockNode { HorizontalRuleNode({ required this.id, + super.metadata, }) { - putMetadataValue("blockType", const NamedAttribution("horizontalRule")); + initAddToMetadata({"blockType": const NamedAttribution("horizontalRule")}); } @override @@ -32,6 +35,27 @@ class HorizontalRuleNode extends BlockNode with ChangeNotifier { return other is HorizontalRuleNode; } + @override + DocumentNode copyWithAddedMetadata(Map newProperties) { + return HorizontalRuleNode( + id: id, + metadata: { + ...metadata, + ...newProperties, + }, + ); + } + + @override + DocumentNode copyAndReplaceMetadata(Map newMetadata) { + return HorizontalRuleNode(id: id, metadata: newMetadata); + } + + @override + HorizontalRuleNode copy() { + return HorizontalRuleNode(id: id); + } + @override bool operator ==(Object other) => identical(this, other) || other is HorizontalRuleNode && runtimeType == other.runtimeType && id == other.id; @@ -51,6 +75,7 @@ class HorizontalRuleComponentBuilder implements ComponentBuilder { return HorizontalRuleComponentViewModel( nodeId: node.id, + createdAt: node.metadata[NodeMetadata.createdAt], selectionColor: const Color(0x00000000), caretColor: const Color(0x00000000), ); @@ -65,27 +90,31 @@ class HorizontalRuleComponentBuilder implements ComponentBuilder { return HorizontalRuleComponent( componentKey: componentContext.componentKey, - selection: componentViewModel.selection, + selection: componentViewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection?, selectionColor: componentViewModel.selectionColor, showCaret: componentViewModel.caret != null, caretColor: componentViewModel.caretColor, + opacity: componentViewModel.opacity, ); } } -class HorizontalRuleComponentViewModel extends SingleColumnLayoutComponentViewModel { +class HorizontalRuleComponentViewModel extends SingleColumnLayoutComponentViewModel with SelectionAwareViewModelMixin { HorizontalRuleComponentViewModel({ - required String nodeId, - double? maxWidth, - EdgeInsetsGeometry padding = EdgeInsets.zero, - this.selection, - required this.selectionColor, + required super.nodeId, + super.createdAt, + super.maxWidth, + super.padding = EdgeInsets.zero, + super.opacity = 1.0, + DocumentNodeSelection? selection, + Color selectionColor = Colors.transparent, this.caret, required this.caretColor, - }) : super(nodeId: nodeId, maxWidth: maxWidth, padding: padding); + }) { + super.selection = selection; + super.selectionColor = selectionColor; + } - UpstreamDownstreamNodeSelection? selection; - Color selectionColor; UpstreamDownstreamNodePosition? caret; Color caretColor; @@ -93,8 +122,10 @@ class HorizontalRuleComponentViewModel extends SingleColumnLayoutComponentViewMo HorizontalRuleComponentViewModel copy() { return HorizontalRuleComponentViewModel( nodeId: nodeId, + createdAt: createdAt, maxWidth: maxWidth, padding: padding, + opacity: opacity, selection: selection, selectionColor: selectionColor, caret: caret, @@ -109,6 +140,7 @@ class HorizontalRuleComponentViewModel extends SingleColumnLayoutComponentViewMo other is HorizontalRuleComponentViewModel && runtimeType == other.runtimeType && nodeId == other.nodeId && + createdAt == other.createdAt && selection == other.selection && selectionColor == other.selectionColor && caret == other.caret && @@ -118,6 +150,7 @@ class HorizontalRuleComponentViewModel extends SingleColumnLayoutComponentViewMo int get hashCode => super.hashCode ^ nodeId.hashCode ^ + createdAt.hashCode ^ selection.hashCode ^ selectionColor.hashCode ^ caret.hashCode ^ @@ -135,6 +168,7 @@ class HorizontalRuleComponent extends StatelessWidget { this.selection, required this.caretColor, this.showCaret = false, + this.opacity = 1.0, }) : super(key: key); final GlobalKey componentKey; @@ -145,16 +179,21 @@ class HorizontalRuleComponent extends StatelessWidget { final Color caretColor; final bool showCaret; + final double opacity; + @override Widget build(BuildContext context) { - return SelectableBox( - selection: selection, - selectionColor: selectionColor, - child: BoxComponent( - key: componentKey, - child: Divider( - color: color, - thickness: thickness, + return IgnorePointer( + child: SelectableBox( + selection: selection, + selectionColor: selectionColor, + child: BoxComponent( + key: componentKey, + opacity: opacity, + child: Divider( + color: color, + thickness: thickness, + ), ), ), ); diff --git a/super_editor/lib/src/default_editor/image.dart b/super_editor/lib/src/default_editor/image.dart index 202ca9b36f..244cf3089b 100644 --- a/super_editor/lib/src/default_editor/image.dart +++ b/super_editor/lib/src/default_editor/image.dart @@ -1,5 +1,8 @@ +import 'dart:typed_data' show Uint8List; + import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/material.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/selection_aware_viewmodel.dart'; import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; import '../core/document.dart'; @@ -7,39 +10,38 @@ import 'box_component.dart'; import 'layout_single_column/layout_single_column.dart'; /// [DocumentNode] that represents an image at a URL. -class ImageNode extends BlockNode with ChangeNotifier { +@immutable +class ImageNode extends BlockNode { ImageNode({ required this.id, - required String imageUrl, - String altText = '', - Map? metadata, - }) : _imageUrl = imageUrl, - _altText = altText { - this.metadata = metadata; - - putMetadataValue("blockType", const NamedAttribution("image")); + required this.imageUrl, + this.expectedBitmapSize, + this.altText = '', + super.metadata, + }) { + initAddToMetadata({NodeMetadata.blockType: const NamedAttribution("image")}); } @override final String id; - String _imageUrl; - String get imageUrl => _imageUrl; - set imageUrl(String newImageUrl) { - if (newImageUrl != _imageUrl) { - _imageUrl = newImageUrl; - notifyListeners(); - } - } + final String imageUrl; - String _altText; - String get altText => _altText; - set altText(String newAltText) { - if (newAltText != _altText) { - _altText = newAltText; - notifyListeners(); - } - } + /// The expected size of the image. + /// + /// Used to size the component while the image is still being loaded, + /// so the content don't shift after the image is loaded. + /// + /// It's technically permissible to provide only a single expected dimension, + /// however providing only a single dimension won't provide enough information + /// to size an image component before the image is loaded. Providing only a + /// width in a vertical layout won't have any visual effect. Providing only a height + /// in a vertical layout will likely take up more space or less space than the final + /// image because the final image will probably be scaled. Therefore, to take + /// advantage of [ExpectedSize], you should try to provide both dimensions. + final ExpectedSize? expectedBitmapSize; + + final String altText; @override String? copyContent(dynamic selection) { @@ -47,7 +49,7 @@ class ImageNode extends BlockNode with ChangeNotifier { throw Exception('ImageNode can only copy content from a UpstreamDownstreamNodeSelection.'); } - return !selection.isCollapsed ? _imageUrl : null; + return !selection.isCollapsed ? imageUrl : null; } @override @@ -55,17 +57,52 @@ class ImageNode extends BlockNode with ChangeNotifier { return other is ImageNode && imageUrl == other.imageUrl && altText == other.altText; } + @override + DocumentNode copyWithAddedMetadata(Map newProperties) { + return ImageNode( + id: id, + imageUrl: imageUrl, + expectedBitmapSize: expectedBitmapSize, + altText: altText, + metadata: { + ...metadata, + ...newProperties, + }, + ); + } + + @override + DocumentNode copyAndReplaceMetadata(Map newMetadata) { + return ImageNode( + id: id, + imageUrl: imageUrl, + expectedBitmapSize: expectedBitmapSize, + altText: altText, + metadata: newMetadata, + ); + } + + ImageNode copy() { + return ImageNode( + id: id, + imageUrl: imageUrl, + expectedBitmapSize: expectedBitmapSize, + altText: altText, + metadata: Map.from(metadata), + ); + } + @override bool operator ==(Object other) => identical(this, other) || other is ImageNode && runtimeType == other.runtimeType && id == other.id && - _imageUrl == other._imageUrl && - _altText == other._altText; + imageUrl == other.imageUrl && + altText == other.altText; @override - int get hashCode => id.hashCode ^ _imageUrl.hashCode ^ _altText.hashCode; + int get hashCode => id.hashCode ^ imageUrl.hashCode ^ altText.hashCode; } class ImageComponentBuilder implements ComponentBuilder { @@ -79,7 +116,9 @@ class ImageComponentBuilder implements ComponentBuilder { return ImageComponentViewModel( nodeId: node.id, + createdAt: node.metadata[NodeMetadata.createdAt], imageUrl: node.imageUrl, + expectedSize: node.expectedBitmapSize, selectionColor: const Color(0x00000000), ); } @@ -94,33 +133,43 @@ class ImageComponentBuilder implements ComponentBuilder { return ImageComponent( componentKey: componentContext.componentKey, imageUrl: componentViewModel.imageUrl, - selection: componentViewModel.selection, + expectedSize: componentViewModel.expectedSize, + selection: componentViewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection?, selectionColor: componentViewModel.selectionColor, + opacity: componentViewModel.opacity, ); } } -class ImageComponentViewModel extends SingleColumnLayoutComponentViewModel { +class ImageComponentViewModel extends SingleColumnLayoutComponentViewModel with SelectionAwareViewModelMixin { ImageComponentViewModel({ - required String nodeId, - double? maxWidth, - EdgeInsetsGeometry padding = EdgeInsets.zero, + required super.nodeId, + super.createdAt, + super.maxWidth, + super.padding = EdgeInsets.zero, + super.opacity = 1.0, required this.imageUrl, - this.selection, - required this.selectionColor, - }) : super(nodeId: nodeId, maxWidth: maxWidth, padding: padding); + this.expectedSize, + DocumentNodeSelection? selection, + Color selectionColor = Colors.transparent, + }) { + this.selection = selection; + this.selectionColor = selectionColor; + } String imageUrl; - UpstreamDownstreamNodeSelection? selection; - Color selectionColor; + ExpectedSize? expectedSize; @override ImageComponentViewModel copy() { return ImageComponentViewModel( nodeId: nodeId, + createdAt: createdAt, maxWidth: maxWidth, padding: padding, + opacity: opacity, imageUrl: imageUrl, + expectedSize: expectedSize, selection: selection, selectionColor: selectionColor, ); @@ -133,13 +182,19 @@ class ImageComponentViewModel extends SingleColumnLayoutComponentViewModel { other is ImageComponentViewModel && runtimeType == other.runtimeType && nodeId == other.nodeId && + createdAt == other.createdAt && imageUrl == other.imageUrl && selection == other.selection && selectionColor == other.selectionColor; @override int get hashCode => - super.hashCode ^ nodeId.hashCode ^ imageUrl.hashCode ^ selection.hashCode ^ selectionColor.hashCode; + super.hashCode ^ + nodeId.hashCode ^ + createdAt.hashCode ^ + imageUrl.hashCode ^ + selection.hashCode ^ + selectionColor.hashCode; } /// Displays an image in a document. @@ -148,16 +203,21 @@ class ImageComponent extends StatelessWidget { Key? key, required this.componentKey, required this.imageUrl, + this.expectedSize, this.selectionColor = Colors.blue, this.selection, + this.opacity = 1.0, this.imageBuilder, }) : super(key: key); final GlobalKey componentKey; final String imageUrl; + final ExpectedSize? expectedSize; final Color selectionColor; final UpstreamDownstreamNodeSelection? selection; + final double opacity; + /// Called to obtain the inner image for the given [imageUrl]. /// /// This builder is used in tests to 'mock' an [Image], avoiding accessing the network. @@ -177,11 +237,37 @@ class ImageComponent extends StatelessWidget { selectionColor: selectionColor, child: BoxComponent( key: componentKey, + opacity: opacity, child: imageBuilder != null ? imageBuilder!(context, imageUrl) : Image.network( imageUrl, fit: BoxFit.contain, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (frame != null) { + // The image is already loaded. Use the image as is. + return child; + } + + if (expectedSize != null && expectedSize!.width != null && expectedSize!.height != null) { + // Both width and height were provide. + // Preserve the aspect ratio of the original image. + return AspectRatio( + aspectRatio: expectedSize!.aspectRatio, + child: SizedBox( + width: expectedSize!.width!.toDouble(), + height: expectedSize!.height!.toDouble(), + ), + ); + } + + // The image is still loading and only one dimension was provided. + // Use the given dimension. + return SizedBox( + width: expectedSize?.width?.toDouble(), + height: expectedSize?.height?.toDouble(), + ); + }, ), ), ), @@ -190,3 +276,311 @@ class ImageComponent extends StatelessWidget { ); } } + +/// The expected size of a piece of content, such as an image that's loading. +class ExpectedSize { + const ExpectedSize(this.width, this.height); + + final int? width; + final int? height; + + double get aspectRatio => height != null // + ? (width ?? 0) / height! + : throw UnsupportedError("Can't compute the aspect ratio with a null height"); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ExpectedSize && // + runtimeType == other.runtimeType && + width == other.width && + height == other.height; + + @override + int get hashCode => width.hashCode ^ height.hashCode; +} + +/// A [ComponentBuilder] that builds [BitmapImageComponent]s based on [BitmapImageNode]s. +/// +/// These nodes and components work specifically with bitmap images, whose data is available in-memory +/// as a [Uint8List]. +class BitmapImageComponentBuilder implements ComponentBuilder { + const BitmapImageComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + if (node is! BitmapImageNode) { + return null; + } + + return BitmapImageComponentViewModel( + nodeId: node.id, + createdAt: node.metadata[NodeMetadata.createdAt], + imageData: node.imageData, + expectedSize: node.expectedBitmapSize, + selectionColor: const Color(0x00000000), + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + if (componentViewModel is! BitmapImageComponentViewModel) { + return null; + } + + return BitmapImageComponent( + componentKey: componentContext.componentKey, + imageData: componentViewModel.imageData, + expectedSize: componentViewModel.expectedSize, + selection: componentViewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection?, + selectionColor: componentViewModel.selectionColor, + opacity: componentViewModel.opacity, + ); + } +} + +class BitmapImageComponentViewModel extends SingleColumnLayoutComponentViewModel with SelectionAwareViewModelMixin { + BitmapImageComponentViewModel({ + required super.nodeId, + super.createdAt, + super.maxWidth, + super.padding = EdgeInsets.zero, + super.opacity = 1.0, + required this.imageData, + this.expectedSize, + DocumentNodeSelection? selection, + Color selectionColor = Colors.transparent, + }) { + this.selection = selection; + this.selectionColor = selectionColor; + } + + Uint8List imageData; + ExpectedSize? expectedSize; + + @override + BitmapImageComponentViewModel copy() { + return BitmapImageComponentViewModel( + nodeId: nodeId, + createdAt: createdAt, + maxWidth: maxWidth, + padding: padding, + opacity: opacity, + imageData: imageData, + expectedSize: expectedSize, + selection: selection, + selectionColor: selectionColor, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is BitmapImageComponentViewModel && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + createdAt == other.createdAt && + selection == other.selection && + selectionColor == other.selectionColor && + imageData.isSameAs(other.imageData); + + @override + int get hashCode => + super.hashCode ^ + nodeId.hashCode ^ + createdAt.hashCode ^ + imageData.hashCode ^ + selection.hashCode ^ + selectionColor.hashCode; +} + +/// Displays an image in a document. +class BitmapImageComponent extends StatelessWidget { + const BitmapImageComponent({ + super.key, + required this.componentKey, + required this.imageData, + this.expectedSize, + this.selectionColor = Colors.blue, + this.selection, + this.opacity = 1.0, + }); + + final GlobalKey componentKey; + final Uint8List imageData; + final ExpectedSize? expectedSize; + final Color selectionColor; + final UpstreamDownstreamNodeSelection? selection; + + final double opacity; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.basic, + hitTestBehavior: HitTestBehavior.translucent, + child: IgnorePointer( + child: Center( + child: SelectableBox( + selection: selection, + selectionColor: selectionColor, + child: BoxComponent( + key: componentKey, + opacity: opacity, + child: Image.memory( + imageData, + fit: BoxFit.contain, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (frame != null) { + // The image is already loaded. Use the image as is. + return child; + } + + if (expectedSize != null && expectedSize!.width != null && expectedSize!.height != null) { + // Both width and height were provide. + // Preserve the aspect ratio of the original image. + return AspectRatio( + aspectRatio: expectedSize!.aspectRatio, + child: SizedBox(width: expectedSize!.width!.toDouble(), height: expectedSize!.height!.toDouble()), + ); + } + + // The image is still loading and only one dimension was provided. + // Use the given dimension. + return SizedBox(width: expectedSize?.width?.toDouble(), height: expectedSize?.height?.toDouble()); + }, + ), + ), + ), + ), + ), + ); + } +} + +/// [DocumentNode] that represents an image at a URL. +@immutable +class BitmapImageNode extends BlockNode { + BitmapImageNode({ + required this.id, + required this.imageData, + this.expectedBitmapSize, + this.altText = '', + super.metadata, + }) { + initAddToMetadata({NodeMetadata.blockType: const NamedAttribution("image")}); + } + + @override + final String id; + + final Uint8List imageData; + + /// The expected size of the image. + /// + /// Used to size the component while the image is still being loaded, + /// so the content don't shift after the image is loaded. + /// + /// It's technically permissible to provide only a single expected dimension, + /// however providing only a single dimension won't provide enough information + /// to size an image component before the image is loaded. Providing only a + /// width in a vertical layout won't have any visual effect. Providing only a height + /// in a vertical layout will likely take up more space or less space than the final + /// image because the final image will probably be scaled. Therefore, to take + /// advantage of [ExpectedSize], you should try to provide both dimensions. + final ExpectedSize? expectedBitmapSize; + + final String altText; + + @override + String? copyContent(dynamic selection) { + // There's no obvious String serialization for a bitmap image. + return null; + } + + @override + bool hasEquivalentContent(DocumentNode other) { + return other is BitmapImageNode && altText == other.altText && imageData.isSameAs(other.imageData); + } + + @override + DocumentNode copyWithAddedMetadata(Map newProperties) { + return BitmapImageNode( + id: id, + imageData: imageData, + expectedBitmapSize: expectedBitmapSize, + altText: altText, + metadata: {...metadata, ...newProperties}, + ); + } + + @override + DocumentNode copyAndReplaceMetadata(Map newMetadata) { + return BitmapImageNode( + id: id, + imageData: imageData, + expectedBitmapSize: expectedBitmapSize, + altText: altText, + metadata: newMetadata, + ); + } + + BitmapImageNode copy() { + return BitmapImageNode( + id: id, + imageData: imageData, + expectedBitmapSize: expectedBitmapSize, + altText: altText, + metadata: Map.from(metadata), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BitmapImageNode && + runtimeType == other.runtimeType && + id == other.id && + altText == other.altText && + imageData.isSameAs(other.imageData); + + @override + int get hashCode => id.hashCode ^ imageData.hashCode ^ altText.hashCode; +} + +extension on Uint8List { + /// Returns `true` if this [Uint8List] is identical in data to [other]. + bool isSameAs(Uint8List other) { + if (identical(this, other)) { + return true; + } + + if (this.lengthInBytes != other.lengthInBytes) { + return false; + } + + // Treat the underlying buffer as a list of 64-bit integers + // to compare 8 bytes at a time. + final words1 = buffer.asUint64List(); + final words2 = other.buffer.asUint64List(); + + for (var i = 0; i < words1.length; i++) { + if (words1[i] != words2[i]) { + return false; + } + } + + // Compare any remaining bytes (if length wasn't a multiple of 8) + for (var i = words1.lengthInBytes; i < lengthInBytes; i++) { + if (this[i] != other[i]) { + return false; + } + } + + return true; + } +} diff --git a/super_editor/lib/src/default_editor/layout_single_column/_layout.dart b/super_editor/lib/src/default_editor/layout_single_column/_layout.dart index 1d048df53e..4d3196489a 100644 --- a/super_editor/lib/src/default_editor/layout_single_column/_layout.dart +++ b/super_editor/lib/src/default_editor/layout_single_column/_layout.dart @@ -29,6 +29,8 @@ class SingleColumnDocumentLayout extends StatefulWidget { Key? key, required this.presenter, required this.componentBuilders, + this.onBuildScheduled, + this.wrapWithSliverAdapter = true, this.showDebugPaint = false, }) : super(key: key); @@ -44,6 +46,20 @@ class SingleColumnDocumentLayout extends StatefulWidget { /// that piece of content. final List componentBuilders; + /// Callback that's invoked whenever this widget schedules a build with + /// `setState()`. + /// + /// This callback was added to facilitate the ContentLayers widget, because + /// Flutter makes it impossible to monitor the dirty state of a sub-tree. + /// + /// TODO: Get rid of this as soon as Flutter makes it possible to monitor + /// dirty subtrees. + final VoidCallback? onBuildScheduled; + + /// Whether to wrap this layout with a [SliverToBoxAdapter] so that this layout + /// can be displayed within a `Sliver`. + final bool wrapWithSliverAdapter; + /// Adds a debugging UI to the document layout, when true. final bool showDebugPaint; @@ -62,6 +78,10 @@ class _SingleColumnDocumentLayoutState extends State late SingleColumnLayoutPresenterChangeListener _presenterListener; + // The key for the renderBox that contains the actual document layout. + final GlobalKey _boxKey = GlobalKey(); + BuildContext get boxContext => _boxKey.currentContext!; + @override void initState() { super.initState(); @@ -84,6 +104,8 @@ class _SingleColumnDocumentLayoutState extends State if (widget.presenter != oldWidget.presenter) { oldWidget.presenter.removeChangeListener(_presenterListener); widget.presenter.addChangeListener(_presenterListener); + + widget.presenter.updateViewModel(); } } @@ -100,10 +122,11 @@ class _SingleColumnDocumentLayoutState extends State void _onViewModelChange({ required List addedComponents, + required List movedComponents, required List changedComponents, required List removedComponents, }) { - if (addedComponents.isNotEmpty || removedComponents.isNotEmpty) { + if (addedComponents.isNotEmpty || movedComponents.isNotEmpty || removedComponents.isNotEmpty) { setState(() { // Re-flow the whole layout. }); @@ -126,7 +149,7 @@ class _SingleColumnDocumentLayoutState extends State DocumentPosition? getDocumentPositionNearestToOffset(Offset rawDocumentOffset) { // Constrain the incoming offset to sit within the width // of this document layout. - final docBox = context.findRenderObject() as RenderBox; + final docBox = boxContext.findRenderObject() as RenderBox; final documentOffset = Offset( // Notice the +1/-1. Experimentally, I determined that if we confine // to the exact width, that x-value is considered outside the @@ -210,6 +233,22 @@ class _SingleColumnDocumentLayoutState extends State return offsetAtComponent.dy > componentBox.size.height; } + @override + Rect? getEdgeForPosition(DocumentPosition position) { + final component = getComponentByNodeId(position.nodeId); + if (component == null) { + editorLayoutLog.info('Could not find any component for node position: $position'); + return null; + } + + final componentEdge = component.getEdgeForPosition(position.nodePosition); + + final componentBox = component.context.findRenderObject() as RenderBox; + final docOffset = componentBox.localToGlobal(Offset.zero, ancestor: boxContext.findRenderObject()); + + return componentEdge.translate(docOffset.dx, docOffset.dy); + } + @override Rect? getRectForPosition(DocumentPosition position) { final component = getComponentByNodeId(position.nodeId); @@ -217,10 +256,11 @@ class _SingleColumnDocumentLayoutState extends State editorLayoutLog.info('Could not find any component for node position: $position'); return null; } + final componentRect = component.getRectForPosition(position.nodePosition); final componentBox = component.context.findRenderObject() as RenderBox; - final docOffset = componentBox.localToGlobal(Offset.zero, ancestor: context.findRenderObject()); + final docOffset = componentBox.localToGlobal(Offset.zero, ancestor: boxContext.findRenderObject()); return componentRect.translate(docOffset.dx, docOffset.dy); } @@ -239,40 +279,73 @@ class _SingleColumnDocumentLayoutState extends State final componentBoundingBoxes = []; // Collect bounding boxes for all selected components. + final documentLayoutBox = boxContext.findRenderObject() as RenderBox; if (base.nodeId == extent.nodeId) { // Selection within a single node. topComponent = extentComponent; - final componentBoundingBox = extentComponent.getRectForSelection(base.nodePosition, extent.nodePosition); + final componentOffsetInDocument = (topComponent.context.findRenderObject() as RenderBox) + .localToGlobal(Offset.zero, ancestor: documentLayoutBox); + + final componentBoundingBox = extentComponent + .getRectForSelection( + base.nodePosition, + extent.nodePosition, + ) + .translate( + componentOffsetInDocument.dx, + componentOffsetInDocument.dy, + ); componentBoundingBoxes.add(componentBoundingBox); } else { // Selection across nodes. final selectedNodes = _getNodeIdsBetween(base.nodeId, extent.nodeId); topComponent = getComponentByNodeId(selectedNodes.first)!; final startPosition = selectedNodes.first == base.nodeId ? base.nodePosition : extent.nodePosition; - final endPosition = selectedNodes.first == extent.nodeId ? extent.nodePosition : base.nodePosition; + final endPosition = selectedNodes.first == base.nodeId ? extent.nodePosition : base.nodePosition; for (int i = 0; i < selectedNodes.length; ++i) { final component = getComponentByNodeId(selectedNodes[i])!; + final componentOffsetInDocument = + (component.context.findRenderObject() as RenderBox).localToGlobal(Offset.zero, ancestor: documentLayoutBox); if (i == 0) { // This is the first node. The selection goes from // startPosition to the end of the node. final firstNodeEndPosition = component.getEndPosition(); - componentBoundingBoxes.add(component.getRectForSelection(startPosition, firstNodeEndPosition)); + final selectionRectInComponent = component.getRectForSelection( + startPosition, + firstNodeEndPosition, + ); + final componentRectInDocument = selectionRectInComponent.translate( + componentOffsetInDocument.dx, + componentOffsetInDocument.dy, + ); + componentBoundingBoxes.add(componentRectInDocument); } else if (i == selectedNodes.length - 1) { // This is the last node. The selection goes from // the beginning of the node to endPosition. final lastNodeStartPosition = component.getBeginningPosition(); - componentBoundingBoxes.add(component.getRectForSelection(lastNodeStartPosition, endPosition)); + final selectionRectInComponent = component.getRectForSelection( + lastNodeStartPosition, + endPosition, + ); + final componentRectInDocument = selectionRectInComponent.translate( + componentOffsetInDocument.dx, + componentOffsetInDocument.dy, + ); + componentBoundingBoxes.add(componentRectInDocument); } else { // This node sits between start and end. All content // is selected. - componentBoundingBoxes.add( - component.getRectForSelection( - component.getBeginningPosition(), - component.getEndPosition(), - ), + final selectionRectInComponent = component.getRectForSelection( + component.getBeginningPosition(), + component.getEndPosition(), + ); + final componentRectInDocument = selectionRectInComponent.translate( + componentOffsetInDocument.dx, + componentOffsetInDocument.dy, ); + componentBoundingBoxes.add(componentRectInDocument); } } } @@ -280,14 +353,9 @@ class _SingleColumnDocumentLayoutState extends State // Combine all component boxes into one big bounding box. Rect boundingBox = componentBoundingBoxes.first; for (int i = 1; i < componentBoundingBoxes.length; ++i) { - boundingBox.expandToInclude(componentBoundingBoxes[i]); + boundingBox = boundingBox.expandToInclude(componentBoundingBoxes[i]); } - // Translate the bounding box so that it's positioned in document coordinate space. - final topComponentBox = topComponent.context.findRenderObject() as RenderBox; - final docOffset = topComponentBox.localToGlobal(Offset.zero, ancestor: context.findRenderObject()); - boundingBox = boundingBox.translate(docOffset.dx, docOffset.dy); - return boundingBox; } @@ -427,8 +495,9 @@ class _SingleColumnDocumentLayoutState extends State /// Returns `null` if there is no overlap. Rect? _getLocalOverlapWithComponent(Rect region, DocumentComponent component) { final componentBox = component.context.findRenderObject() as RenderBox; - final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: context.findRenderObject()); + final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: boxContext.findRenderObject()); final componentBounds = contentOffset & componentBox.size; + editorLayoutLog.finest("Component bounds: $componentBounds, versus region of interest: $region"); if (region.overlaps(componentBounds)) { // Report the overlap in our local coordinate space. @@ -543,7 +612,7 @@ class _SingleColumnDocumentLayoutState extends State } bool _isOffsetInComponent(RenderBox componentBox, Offset documentOffset) { - final containerBox = context.findRenderObject() as RenderBox; + final containerBox = boxContext.findRenderObject() as RenderBox; final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: containerBox); final contentRect = contentOffset & componentBox.size; @@ -553,7 +622,7 @@ class _SingleColumnDocumentLayoutState extends State /// Returns the vertical distance between the given [documentOffset] and the /// bounds of the given [componentBox]. double _getDistanceToComponent(RenderBox componentBox, Offset documentOffset) { - final documentLayoutBox = context.findRenderObject() as RenderBox; + final documentLayoutBox = boxContext.findRenderObject() as RenderBox; final componentOffset = componentBox.localToGlobal(Offset.zero, ancestor: documentLayoutBox); final componentRect = componentOffset & componentBox.size; @@ -570,7 +639,7 @@ class _SingleColumnDocumentLayoutState extends State } Offset _componentOffset(RenderBox componentBox, Offset documentOffset) { - final containerBox = context.findRenderObject() as RenderBox; + final containerBox = boxContext.findRenderObject() as RenderBox; final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: containerBox); final contentRect = contentOffset & componentBox.size; @@ -598,17 +667,17 @@ class _SingleColumnDocumentLayoutState extends State @override Offset getDocumentOffsetFromAncestorOffset(Offset ancestorOffset, [RenderObject? ancestor]) { - return (context.findRenderObject() as RenderBox).globalToLocal(ancestorOffset, ancestor: ancestor); + return (boxContext.findRenderObject() as RenderBox).globalToLocal(ancestorOffset, ancestor: ancestor); } @override Offset getAncestorOffsetFromDocumentOffset(Offset documentOffset, [RenderObject? ancestor]) { - return (context.findRenderObject() as RenderBox).localToGlobal(documentOffset, ancestor: ancestor); + return (boxContext.findRenderObject() as RenderBox).localToGlobal(documentOffset, ancestor: ancestor); } @override Offset getGlobalOffsetFromDocumentOffset(Offset documentOffset) { - return (context.findRenderObject() as RenderBox).localToGlobal(documentOffset); + return (boxContext.findRenderObject() as RenderBox).localToGlobal(documentOffset); } @override @@ -637,10 +706,17 @@ class _SingleColumnDocumentLayoutState extends State ); } + @override + void setState(VoidCallback fn) { + super.setState(fn); + widget.onBuildScheduled?.call(); + } + @override Widget build(BuildContext context) { editorLayoutLog.fine("Building document layout"); - return Padding( + Widget result = Padding( + key: _boxKey, padding: widget.presenter.viewModel.padding, child: Column( mainAxisSize: MainAxisSize.min, @@ -648,6 +724,15 @@ class _SingleColumnDocumentLayoutState extends State children: _buildDocComponents(), ), ); + + if (widget.wrapWithSliverAdapter) { + result = SliverToBoxAdapter( + child: result, + ); + } + + editorLayoutLog.fine("Done building document"); + return result; } List _buildDocComponents() { @@ -780,7 +865,7 @@ class _SingleColumnDocumentLayoutState extends State final component = componentKey.currentState as DocumentComponent; final componentBox = component.context.findRenderObject() as RenderBox; - final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: context.findRenderObject()); + final contentOffset = componentBox.localToGlobal(Offset.zero, ancestor: boxContext.findRenderObject()); return contentOffset & componentBox.size; } } @@ -832,6 +917,7 @@ class _PresenterComponentBuilderState extends State<_PresenterComponentBuilder> void _onViewModelChange({ required List addedComponents, + required List movedComponents, required List changedComponents, required List removedComponents, }) { @@ -867,7 +953,10 @@ class _Component extends StatelessWidget { required this.componentBuilders, required this.componentViewModel, required this.componentKey, - // ignore: unused_element + // TODO(srawlins): `unused_element`, when reporting a parameter, is being + // renamed to `unused_element_parameter`. For now, ignore each; when the SDK + // constraint is >= 3.6.0, just ignore `unused_element_parameter`. + // ignore: unused_element, unused_element_parameter this.showDebugPaint = false, }) : super(key: key); @@ -897,6 +986,8 @@ class _Component extends StatelessWidget { for (final componentBuilder in componentBuilders) { var component = componentBuilder.createComponent(componentContext, componentViewModel); if (component != null) { + // TODO: we might need a SizeChangedNotifier here for the case where two components + // change size exactly inversely component = ConstrainedBox( constraints: BoxConstraints(maxWidth: componentViewModel.maxWidth ?? double.infinity), child: SizedBox( diff --git a/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart b/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart index 0ddc7bb0ce..86234d4c10 100644 --- a/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart +++ b/super_editor/lib/src/default_editor/layout_single_column/_presenter.dart @@ -1,4 +1,5 @@ import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:super_editor/src/core/document.dart'; @@ -85,7 +86,7 @@ class SingleColumnLayoutPresenter { _listeners.remove(listener); } - void _onDocumentChange() { + void _onDocumentChange(_) { editorLayoutLog.info("The document changed. Marking the presenter dirty."); final wasDirty = isDirty; @@ -164,17 +165,16 @@ class SingleColumnLayoutPresenter { // The document changed. All view models were invalidated. Create a // new base document view model. final viewModels = []; - for (int i = 0; i < _document.nodes.length; i += 1) { + for (final node in _document) { SingleColumnLayoutComponentViewModel? viewModel; for (final builder in _componentBuilders) { - viewModel = builder.createViewModel(_document, _document.nodes[i]); + viewModel = builder.createViewModel(_document, node); if (viewModel != null) { break; } } if (viewModel == null) { - throw Exception( - "Couldn't find styler to create component for document node: ${_document.nodes[i].runtimeType}"); + throw Exception("Couldn't find styler to create component for document node: ${node.runtimeType}"); } viewModels.add(viewModel); } @@ -207,42 +207,74 @@ class SingleColumnLayoutPresenter { required SingleColumnLayoutViewModel oldViewModel, required SingleColumnLayoutViewModel newViewModel, }) { + editorLayoutLog.finer("Computing layout view model changes to notify listeners of those changes."); + final addedComponents = []; + final movedComponents = []; final removedComponents = []; final changedComponents = []; final nodeIdToComponentMap = {}; + final nodeIdToPreviousOrderMap = {}; // Maps a component's node ID to a change code: // -1 - the component was removed // 0 - the component is unchanged // 1 - the component changed - // 2 - the component was added + // 2 - the component was moved + // 3 - the component was added final changeMap = {}; - for (final oldComponent in oldViewModel.componentViewModels) { + + // Catalog the components in the previous view model. + for (int i = 0; i < oldViewModel.componentViewModels.length; i += 1) { + final oldComponent = oldViewModel.componentViewModels[i]; final nodeId = oldComponent.nodeId; + nodeIdToComponentMap[nodeId] = oldComponent; + nodeIdToPreviousOrderMap[nodeId] = i; + changeMap[nodeId] = -1; } - for (final newComponent in newViewModel.componentViewModels) { + + // Accumulate all changes that we can find between the old view model and the new one. + for (int i = 0; i < newViewModel.componentViewModels.length; i += 1) { + final newComponent = newViewModel.componentViewModels[i]; final nodeId = newComponent.nodeId; - if (nodeIdToComponentMap.containsKey(nodeId)) { - if (nodeIdToComponentMap[nodeId] == newComponent) { - // The component hasn't changed. - changeMap[nodeId] = 0; - } else if (nodeIdToComponentMap[nodeId].runtimeType == newComponent.runtimeType) { - // The component still exists, but it changed. - changeMap[nodeId] = 1; - } else { - // The component has changed type, e.g., from an Image to a - // Paragraph. This can happen as a result of deletions. Treat - // this as a component removal. - changeMap[nodeId] = -1; - editorLayoutLog.fine("Component node changed type. Assuming this is a removal: $nodeId"); - } - } else { + + if (!nodeIdToComponentMap.containsKey(nodeId)) { // This component is new. + editorLayoutLog.fine("New component was added for node $nodeId"); + changeMap[nodeId] = 3; + continue; + } + + if (nodeIdToPreviousOrderMap[nodeId] != i) { + // This component moved somewhere else. Mark this view model as changed. + editorLayoutLog.fine( + "Component for node $nodeId was at index ${nodeIdToPreviousOrderMap[nodeId]} but now it's at $i, marking the view model as changed"); changeMap[nodeId] = 2; + continue; } + + if (nodeIdToComponentMap[nodeId] == newComponent) { + // The component hasn't changed. + editorLayoutLog.fine("Component for node $nodeId didn't change at all"); + changeMap[nodeId] = 0; + continue; + } + + if (nodeIdToComponentMap[nodeId].runtimeType == newComponent.runtimeType) { + // The component still exists, but it changed. + editorLayoutLog + .fine("Component for node $nodeId is the same runtime type, but changed content. Marking as changed."); + changeMap[nodeId] = 1; + continue; + } + + // The component has changed type, e.g., from an Image to a + // Paragraph. This can happen as a result of deletions. Treat + // this as a component removal. + editorLayoutLog.fine("Component for node $nodeId at index $i was removed"); + changeMap[nodeId] = -1; } // Convert the change map to lists of changes. @@ -258,6 +290,9 @@ class SingleColumnLayoutPresenter { changedComponents.add(entry.key); break; case 2: + movedComponents.add(entry.key); + break; + case 3: addedComponents.add(entry.key); break; default: @@ -268,7 +303,7 @@ class SingleColumnLayoutPresenter { } } - if (addedComponents.isEmpty && changedComponents.isEmpty && removedComponents.isEmpty) { + if (addedComponents.isEmpty && movedComponents.isEmpty && changedComponents.isEmpty && removedComponents.isEmpty) { // No changes to report. editorLayoutLog.fine("Nothing has changed in the view model. Not notifying any listeners."); return; @@ -276,11 +311,13 @@ class SingleColumnLayoutPresenter { editorLayoutLog.fine("Notifying layout presenter listeners of changes:"); editorLayoutLog.fine(" - added: $addedComponents"); + editorLayoutLog.fine(" - added: $movedComponents"); editorLayoutLog.fine(" - changed: $changedComponents"); editorLayoutLog.fine(" - removed: $removedComponents"); for (final listener in _listeners.toList()) { listener.onViewModelChange( addedComponents: addedComponents, + movedComponents: movedComponents, changedComponents: changedComponents, removedComponents: removedComponents, ); @@ -291,12 +328,12 @@ class SingleColumnLayoutPresenter { class SingleColumnLayoutPresenterChangeListener { const SingleColumnLayoutPresenterChangeListener({ VoidCallback? onPresenterMarkedDirty, - _ViewModelChangeCallback? onViewModelChange, + ViewModelChangeCallback? onViewModelChange, }) : _onPresenterMarkedDirty = onPresenterMarkedDirty, _onViewModelChange = onViewModelChange; final VoidCallback? _onPresenterMarkedDirty; - final _ViewModelChangeCallback? _onViewModelChange; + final ViewModelChangeCallback? _onViewModelChange; void onPresenterMarkedDirty() { _onPresenterMarkedDirty?.call(); @@ -304,19 +341,22 @@ class SingleColumnLayoutPresenterChangeListener { void onViewModelChange({ required List addedComponents, + required List movedComponents, required List changedComponents, required List removedComponents, }) { _onViewModelChange?.call( addedComponents: addedComponents, + movedComponents: movedComponents, changedComponents: changedComponents, removedComponents: removedComponents, ); } } -typedef _ViewModelChangeCallback = void Function({ +typedef ViewModelChangeCallback = void Function({ required List addedComponents, + required List movedComponents, required List changedComponents, required List removedComponents, }); @@ -411,12 +451,21 @@ class SingleColumnLayoutViewModel { abstract class SingleColumnLayoutComponentViewModel { SingleColumnLayoutComponentViewModel({ required this.nodeId, + required this.createdAt, this.maxWidth, required this.padding, + this.opacity = 1.0, }); final String nodeId; + /// When view model's corresponding node was created, which can be used for + /// making decisions about animated invalidations. + /// + /// Reporting the creation time is optional. Stylers must handle cases where + /// no creation timestamp is available. + DateTime? createdAt; + /// The maximum width of this component in the layout, or `null` to /// defer to the layout's preference. double? maxWidth; @@ -424,9 +473,13 @@ abstract class SingleColumnLayoutComponentViewModel { /// The padding applied around this component. EdgeInsetsGeometry padding; + /// The opacity of this whole node. + double opacity; + void applyStyles(Map styles) { - maxWidth = styles["maxWidth"] ?? double.infinity; - padding = (styles["padding"] as CascadingPadding?)?.toEdgeInsets() ?? EdgeInsets.zero; + maxWidth = styles[Styles.maxWidth] ?? double.infinity; + padding = (styles[Styles.padding] as CascadingPadding?)?.toEdgeInsets() ?? EdgeInsets.zero; + opacity = styles[Styles.opacity] ?? 1.0; } SingleColumnLayoutComponentViewModel copy(); @@ -437,9 +490,11 @@ abstract class SingleColumnLayoutComponentViewModel { other is SingleColumnLayoutComponentViewModel && runtimeType == other.runtimeType && nodeId == other.nodeId && + createdAt == other.createdAt && maxWidth == other.maxWidth && - padding == other.padding; + padding == other.padding && + opacity == other.opacity; @override - int get hashCode => nodeId.hashCode ^ maxWidth.hashCode ^ padding.hashCode; + int get hashCode => nodeId.hashCode ^ createdAt.hashCode ^ maxWidth.hashCode ^ padding.hashCode ^ opacity.hashCode; } diff --git a/super_editor/lib/src/default_editor/layout_single_column/_styler_composing_region.dart b/super_editor/lib/src/default_editor/layout_single_column/_styler_composing_region.dart new file mode 100644 index 0000000000..64129d0518 --- /dev/null +++ b/super_editor/lib/src/default_editor/layout_single_column/_styler_composing_region.dart @@ -0,0 +1,244 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +import '../../core/document.dart'; +import '_presenter.dart'; + +/// [SingleColumnLayoutStylePhase] that draws an underline beneath the text in the IME's +/// composing region. +class SingleColumnLayoutComposingRegionStyler extends SingleColumnLayoutStylePhase { + SingleColumnLayoutComposingRegionStyler({ + required Document document, + required ValueListenable composingRegion, + required bool showComposingUnderline, + }) : _document = document, + _composingRegion = composingRegion, + _showComposingRegionUnderline = showComposingUnderline { + // Our styles need to be re-applied whenever the composing region changes. + _composingRegion.addListener(markDirty); + } + + @override + void dispose() { + _composingRegion.removeListener(markDirty); + super.dispose(); + } + + final Document _document; + final ValueListenable _composingRegion; + final bool _showComposingRegionUnderline; + + @override + SingleColumnLayoutViewModel style(Document document, SingleColumnLayoutViewModel viewModel) { + editorStyleLog.info("(Re)calculating composing region view model for document layout"); + final documentComposingRegion = _composingRegion.value; + if (documentComposingRegion == null) { + // There's nothing for us to style if there's no composing region. Return the + // view model as-is. + return viewModel; + } + if (!_showComposingRegionUnderline) { + // No underline is desired for the composing region. Return the view model as-is. + return viewModel; + } + + return SingleColumnLayoutViewModel( + padding: viewModel.padding, + componentViewModels: [ + for (final previousViewModel in viewModel.componentViewModels) // + _applyComposingRegion(previousViewModel.copy(), documentComposingRegion), + ], + ); + } + + SingleColumnLayoutComponentViewModel _applyComposingRegion( + SingleColumnLayoutComponentViewModel viewModel, + DocumentRange documentComposingRegion, + ) { + final node = _document.getNodeById(viewModel.nodeId)!; + if (node is! TextNode) { + // An IME composing region is only relevant for text nodes. Do nothing to this component's viewmodel. + return viewModel; + } + if (viewModel is! TextComponentViewModel) { + // All components for TextNode's should be of type TextComponentViewModel, but we check + // just to be sure. In this case, it's not, for some reason. We can only style + // TextComponentViewModel's. Do nothing to this view model. + return viewModel; + } + + editorStyleLog.fine("Applying composing region styles to node: ${node.id}"); + + _DocumentNodeSelection? nodeSelection; + final nodesWithComposingRegion = _document.getNodesInside( + documentComposingRegion.start, + documentComposingRegion.end, + ); + nodeSelection = _computeNodeSelection( + documentRange: documentComposingRegion, + selectedNodes: nodesWithComposingRegion, + node: node, + ); + + editorStyleLog.fine("Node selection (${node.id}): $nodeSelection"); + + TextRange? textComposingRegion; + if (documentComposingRegion.start.nodeId == documentComposingRegion.end.nodeId && + documentComposingRegion.start.nodeId == node.id) { + // There's a composing region and it's entirely within this text node. + // TODO: handle the possibility of a composing region extending across multiple nodes. + final startPosition = documentComposingRegion.start.nodePosition as TextNodePosition; + final endPosition = documentComposingRegion.end.nodePosition as TextNodePosition; + textComposingRegion = TextRange(start: startPosition.offset, end: endPosition.offset); + } + + viewModel + ..composingRegion = textComposingRegion + ..showComposingRegionUnderline = true; + + return viewModel; + } + + /// Computes the [_DocumentNodeSelection] for the individual `nodeId` based on + /// the total list of selected nodes. + _DocumentNodeSelection? _computeNodeSelection({ + required DocumentRange? documentRange, + required List selectedNodes, + required DocumentNode node, + }) { + if (documentRange == null) { + return null; + } + + editorStyleLog.finer('_computeNodeSelection(): ${node.id}'); + editorStyleLog.finer(' - start: ${documentRange.start.nodeId}'); + editorStyleLog.finer(' - end: ${documentRange.end.nodeId}'); + + if (documentRange.start.nodeId == documentRange.end.nodeId) { + editorStyleLog.finer(' - selection is within 1 node.'); + if (documentRange.start.nodeId != node.id) { + // Only 1 node is selected and its not the node we're interested in. Return. + editorStyleLog.finer(' - this node is not selected. Returning null.'); + return null; + } + + editorStyleLog.finer(' - this node has the selection'); + final baseNodePosition = documentRange.start.nodePosition; + final extentNodePosition = documentRange.end.nodePosition; + late NodeSelection? nodeSelection; + try { + nodeSelection = node.computeSelection(base: baseNodePosition, extent: extentNodePosition); + } catch (exception) { + // This situation can happen in the moment between a document change and + // a corresponding selection change. For example: deleting an image and + // replacing it with an empty paragraph. Between the doc change and the + // selection change, the old image selection is applied to the new paragraph. + // This results in an exception. + // + // TODO: introduce a unified event ledger that combines related behaviors + // into atomic transactions (#423) + return null; + } + editorStyleLog.finer(' - node selection: $nodeSelection'); + + return _DocumentNodeSelection( + nodeId: node.id, + nodeSelection: nodeSelection, + ); + } else { + // Log all the selected nodes. + editorStyleLog.finer(' - selection contains multiple nodes:'); + for (final node in selectedNodes) { + editorStyleLog.finer(' - ${node.id}'); + } + + if (selectedNodes.firstWhereOrNull((selectedNode) => selectedNode.id == node.id) == null) { + // The document selection does not contain the node we're interested in. Return. + editorStyleLog.finer(' - this node is not in the selection'); + return null; + } + + if (selectedNodes.first.id == node.id) { + editorStyleLog.finer(' - this is the first node in the selection'); + // Multiple nodes are selected and the node that we're interested in + // is the top node in that selection. Therefore, this node is + // selected from a position down to its bottom. + final isBase = node.id == documentRange.start.nodeId; + return _DocumentNodeSelection( + nodeId: node.id, + nodeSelection: node.computeSelection( + base: isBase ? documentRange.start.nodePosition : node.endPosition, + extent: isBase ? node.endPosition : documentRange.end.nodePosition, + ), + ); + } else if (selectedNodes.last.id == node.id) { + editorStyleLog.finer(' - this is the last node in the selection'); + // Multiple nodes are selected and the node that we're interested in + // is the bottom node in that selection. Therefore, this node is + // selected from the beginning down to some position. + final isBase = node.id == documentRange.start.nodeId; + return _DocumentNodeSelection( + nodeId: node.id, + nodeSelection: node.computeSelection( + base: isBase ? node.beginningPosition : node.beginningPosition, + extent: isBase ? documentRange.start.nodePosition : documentRange.end.nodePosition, + ), + ); + } else { + editorStyleLog.finer(' - this node is fully selected within the selection'); + // Multiple nodes are selected and this node is neither the top + // or the bottom node, therefore this entire node is selected. + return _DocumentNodeSelection( + nodeId: node.id, + nodeSelection: node.computeSelection( + base: node.beginningPosition, + extent: node.endPosition, + ), + ); + } + } + } +} + +/// Description of a selection within a specific node in a document. +/// +/// The [nodeSelection] only describes the selection in the particular node +/// that [nodeId] points to. The document might have a selection that spans +/// multiple nodes but this only regards the part of that total selection that +/// affects the single node. +/// +/// The [SelectionType] is a generic subtype of [NodeSelection], e.g., a +/// [TextNodeSelection] that describes which characters of text are +/// selected within the text node. +class _DocumentNodeSelection { + _DocumentNodeSelection({ + required this.nodeId, + required this.nodeSelection, + }); + + /// The ID of the node that's selected. + final String nodeId; + + /// The selection within the given node. + final SelectionType? nodeSelection; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _DocumentNodeSelection && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + nodeSelection == other.nodeSelection; + + @override + int get hashCode => nodeId.hashCode ^ nodeSelection.hashCode; + + @override + String toString() { + return '[DocumentNodeSelection] - node: "$nodeId", selection: ($nodeSelection)'; + } +} diff --git a/super_editor/lib/src/default_editor/layout_single_column/_styler_per_component.dart b/super_editor/lib/src/default_editor/layout_single_column/_styler_per_component.dart index 79f0ce385d..a7fae6242c 100644 --- a/super_editor/lib/src/default_editor/layout_single_column/_styler_per_component.dart +++ b/super_editor/lib/src/default_editor/layout_single_column/_styler_per_component.dart @@ -66,13 +66,6 @@ class SingleColumnLayoutComponentStyles { final double? width; final EdgeInsetsGeometry? padding; - void applyTo(DocumentNode node) { - node.putMetadataValue(_metadataKey, { - _widthKey: width, - _paddingKey: padding, - }); - } - Map toMetadata() => { _metadataKey: { _widthKey: width, diff --git a/super_editor/lib/src/default_editor/layout_single_column/_styler_shylesheet.dart b/super_editor/lib/src/default_editor/layout_single_column/_styler_shylesheet.dart index 11714a7498..552c46a138 100644 --- a/super_editor/lib/src/default_editor/layout_single_column/_styler_shylesheet.dart +++ b/super_editor/lib/src/default_editor/layout_single_column/_styler_shylesheet.dart @@ -53,7 +53,8 @@ class SingleColumnStylesheetStyler extends SingleColumnLayoutStylePhase { // Combine all applicable style rules into a single set of styles // for this component. final aggregateStyles = { - "inlineTextStyler": _stylesheet.inlineTextStyler, + Styles.inlineTextStyler: _stylesheet.inlineTextStyler, + Styles.inlineWidgetBuilders: _stylesheet.inlineWidgetBuilders, }; for (final rule in _stylesheet.rules) { if (rule.selector.matches(document, node)) { diff --git a/super_editor/lib/src/default_editor/layout_single_column/_styler_user_selection.dart b/super_editor/lib/src/default_editor/layout_single_column/_styler_user_selection.dart index 67f9173408..5a5e3dbef9 100644 --- a/super_editor/lib/src/default_editor/layout_single_column/_styler_user_selection.dart +++ b/super_editor/lib/src/default_editor/layout_single_column/_styler_user_selection.dart @@ -1,15 +1,15 @@ +import 'package:attributed_text/attributed_text.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/core/styles.dart'; -import 'package:super_editor/src/default_editor/horizontal_rule.dart'; -import 'package:super_editor/src/default_editor/image.dart'; -import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/selection_aware_viewmodel.dart'; import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import '../../core/document.dart'; +import '../attributions.dart'; import '_presenter.dart'; /// [SingleColumnLayoutStylePhase] that applies visual selections to each component, @@ -17,11 +17,13 @@ import '_presenter.dart'; class SingleColumnLayoutSelectionStyler extends SingleColumnLayoutStylePhase { SingleColumnLayoutSelectionStyler({ required Document document, - required ValueNotifier selection, + required ValueListenable selection, required SelectionStyles selectionStyles, + SelectedTextColorStrategy? selectedTextColorStrategy, }) : _document = document, _selection = selection, - _selectionStyles = selectionStyles { + _selectionStyles = selectionStyles, + _selectedTextColorStrategy = selectedTextColorStrategy { // Our styles need to be re-applied whenever the document selection changes. _selection.addListener(markDirty); } @@ -33,7 +35,7 @@ class SingleColumnLayoutSelectionStyler extends SingleColumnLayoutStylePhase { } final Document _document; - final ValueNotifier _selection; + final ValueListenable _selection; SelectionStyles _selectionStyles; set selectionStyles(SelectionStyles selectionStyles) { @@ -45,6 +47,16 @@ class SingleColumnLayoutSelectionStyler extends SingleColumnLayoutStylePhase { markDirty(); } + SelectedTextColorStrategy? _selectedTextColorStrategy; + set selectedTextColorStrategy(SelectedTextColorStrategy? strategy) { + if (strategy == _selectedTextColorStrategy) { + return; + } + + _selectedTextColorStrategy = strategy; + markDirty(); + } + bool _shouldDocumentShowCaret = false; set shouldDocumentShowCaret(bool newValue) { if (newValue == _shouldDocumentShowCaret) { @@ -120,24 +132,35 @@ class SingleColumnLayoutSelectionStyler extends SingleColumnLayoutStylePhase { editorStyleLog.finer(' - extent: ${textSelection?.extent}'); if (viewModel is TextComponentViewModel) { + final componentTextColor = viewModel.textStyleBuilder({}).color; + + final textWithSelectionAttributions = textSelection != null && + !textSelection.isCollapsed && + _selectedTextColorStrategy != null && + componentTextColor != null + ? (viewModel.text.copyText(0) + ..addAttribution( + ColorAttribution(_selectedTextColorStrategy!( + originalTextColor: componentTextColor, + selectionHighlightColor: _selectionStyles.selectionColor, + )), + SpanRange(textSelection.start, textSelection.end - 1), + // The selected range might already have a color attribution. We want to override it + // with the selected text color. + overwriteConflictingSpans: true, + )) + : viewModel.text; + viewModel + ..text = textWithSelectionAttributions ..selection = textSelection ..selectionColor = _selectionStyles.selectionColor ..highlightWhenEmpty = highlightWhenEmpty; } } - if (viewModel is ImageComponentViewModel) { - final selection = nodeSelection == null ? null : nodeSelection.nodeSelection as UpstreamDownstreamNodeSelection; - - viewModel - ..selection = selection - ..selectionColor = _selectionStyles.selectionColor; - } - if (viewModel is HorizontalRuleComponentViewModel) { - final selection = nodeSelection == null ? null : nodeSelection.nodeSelection as UpstreamDownstreamNodeSelection; - + if (viewModel is SelectionAwareViewModelMixin) { viewModel - ..selection = selection + ..selection = nodeSelection ..selectionColor = _selectionStyles.selectionColor; } diff --git a/super_editor/lib/src/default_editor/layout_single_column/selection_aware_viewmodel.dart b/super_editor/lib/src/default_editor/layout_single_column/selection_aware_viewmodel.dart new file mode 100644 index 0000000000..5746e3fb0d --- /dev/null +++ b/super_editor/lib/src/default_editor/layout_single_column/selection_aware_viewmodel.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/src/core/document.dart' show NodeSelection; +import 'package:super_editor/src/core/styles.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/layout_single_column.dart'; + +/// Allows a [SingleColumnLayoutComponentViewModel] to be aware of the selection within its node. +/// +/// This mixin enables non-text components, to render their selection. +/// +/// During the styling pipeline, any [SingleColumnLayoutComponentViewModel] that mixes in +/// [SelectionAwareViewModelMixin] will have its [selection] and [selectionColor] properties set. +/// +/// In the [SingleColumnLayoutComponentViewModel.copy] subclass implementation, both [selection] and +/// [selectionColor] must be copied to the new instance. +mixin SelectionAwareViewModelMixin on SingleColumnLayoutComponentViewModel { + /// The selection within the node represented by this view model. + DocumentNodeSelection? selection; + + /// The color to be applied to the selection. + /// + /// During the styling pass, this color is set according to the [SelectionStyles] used. + Color selectionColor = Colors.transparent; + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is SelectionAwareViewModelMixin && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + selection == other.selection && + selectionColor == other.selectionColor; + + @override + int get hashCode => super.hashCode ^ nodeId.hashCode ^ selection.hashCode ^ selectionColor.hashCode; +} diff --git a/super_editor/lib/src/default_editor/layout_single_column/super_editor_dry_layout.dart b/super_editor/lib/src/default_editor/layout_single_column/super_editor_dry_layout.dart new file mode 100644 index 0000000000..fc7c82564d --- /dev/null +++ b/super_editor/lib/src/default_editor/layout_single_column/super_editor_dry_layout.dart @@ -0,0 +1,167 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A custom [Viewport] that wraps a given [superEditor] so that this subtree can run +/// dry layout, reporting the intrinsic height of the [superEditor]. +/// +/// This widget doesn't change the actual height or scrolling behavior of `SuperEditor` - it +/// only adds the ability to ask `SuperEditor` for its size during dry layout. +/// +/// If you're looking for a way to have `SuperEditor` expand its height to display all of its +/// content without internally scrolling, don't use this widget. Instead, set `shrinkWrap` to `true` +/// on `SuperEditor`. +/// +/// Without this widget, `SuperEditor`s sliver layout either uses a shrink wrapping viewport, +/// which doesn't support dry layout, or a regular viewport, which throws an error during dry +/// layout if there's an unbounded height. +/// +/// This widget installs a custom viewport, which supports dry layout with an unbounded height, +/// by returning the intrinsic height of [superEditor]. This works because of two things we know +/// about [superEditor]. First, we know that [superEditor] is a single widget, so we don't have to worry +/// about multiple slivers and how they interact with each other. Second, we know that +/// [superEditor] has `RenderBox`s at the top of its widget tree as ancestors above the slivers +/// deeper within [superEditor]. Therefore, the custom viewport in this widget can reach down +/// into [superEditor], find the top-level `RenderBox`, and then return the intrinsic size of +/// that `RenderBox` as the intrinsic size for the entire [superEditor]. If those two details +/// weren't true, then this approach wouldn't work. +/// +/// The child of this widget is called [superEditor] because this widget was made specifically +/// to enable dry layout for a `SuperEditor`. Generally speaking, the direct child of this widget, +/// given by [superEditor] should be a `SuperEditor` widget, without any other widgets +/// between [SuperEditorDryLayout] and `SuperEditor`. +class SuperEditorDryLayout extends CustomScrollView { + const SuperEditorDryLayout({ + super.key, + super.scrollDirection, + super.reverse, + super.controller, + super.primary, + super.physics, + super.scrollBehavior, + super.shrinkWrap, + super.center, + super.anchor, + super.cacheExtent, + super.semanticChildCount, + super.dragStartBehavior, + super.keyboardDismissBehavior, + super.restorationId, + super.clipBehavior, + super.hitTestBehavior, + required this.superEditor, + }); + + final Widget superEditor; + + @override + List get slivers => [superEditor]; + + @override + Widget buildViewport(BuildContext context, ViewportOffset offset, AxisDirection axisDirection, List slivers) { + return ViewportWithDryLayout( + axisDirection: axisDirection, + offset: offset, + slivers: slivers, + ); + } +} + +class ViewportWithDryLayout extends Viewport { + ViewportWithDryLayout({ + super.key, + super.axisDirection = AxisDirection.down, + super.crossAxisDirection, + super.anchor = 0.0, + required super.offset, + super.center, + super.cacheExtent, + super.cacheExtentStyle = CacheExtentStyle.pixel, + super.clipBehavior = Clip.hardEdge, + super.slivers = const [], + }); + + @override + RenderViewportWithDryLayout createRenderObject(BuildContext context) { + return RenderViewportWithDryLayout( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection), + anchor: anchor, + offset: offset, + cacheExtent: cacheExtent, + cacheExtentStyle: cacheExtentStyle, + clipBehavior: clipBehavior, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderViewportWithDryLayout renderObject) { + renderObject + ..axisDirection = axisDirection + ..crossAxisDirection = crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection) + ..anchor = anchor + ..offset = offset + ..cacheExtent = cacheExtent + ..cacheExtentStyle = cacheExtentStyle + ..clipBehavior = clipBehavior; + } +} + +class RenderViewportWithDryLayout extends RenderViewport { + RenderViewportWithDryLayout({ + super.axisDirection, + required super.crossAxisDirection, + required super.offset, + super.anchor = 0.0, + super.center, + super.cacheExtent, + super.cacheExtentStyle, + super.clipBehavior, + super.children, + }); + + @override + Size computeDryLayout(covariant BoxConstraints constraints) { + final layoutBox = _findFirstRenderBoxInSliverList(child); + return layoutBox.computeDryLayout(constraints); + } + + @override + double computeMaxIntrinsicWidth(double height) { + final layoutBox = _findFirstRenderBoxInSliverList(child); + return layoutBox.computeMaxIntrinsicWidth(height); + } + + @override + double computeMinIntrinsicWidth(double height) { + final layoutBox = _findFirstRenderBoxInSliverList(child); + return layoutBox.computeMinIntrinsicWidth(height); + } + + @override + double computeMaxIntrinsicHeight(double width) { + final layoutBox = _findFirstRenderBoxInSliverList(child); + return layoutBox.computeMaxIntrinsicHeight(width); + } + + @override + double computeMinIntrinsicHeight(double width) { + final layoutBox = _findFirstRenderBoxInSliverList(child); + return layoutBox.computeMinIntrinsicHeight(width); + } + + RenderSliver get child => childrenInPaintOrder.first; + + RenderBox _findFirstRenderBoxInSliverList(RenderSliver sliver) { + RenderSliver? firstSliver; + RenderBox? firstBox; + sliver.visitChildren((child) { + if (child is RenderSliver && firstSliver == null) { + firstSliver = child; + } + if (child is RenderBox && firstBox == null) { + firstBox = child; + } + }); + return firstSliver != null ? _findFirstRenderBoxInSliverList(firstSliver!) : firstBox!; + } +} diff --git a/super_editor/lib/src/default_editor/list_items.dart b/super_editor/lib/src/default_editor/list_items.dart index 3052f8ecd1..476e9a98de 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -1,84 +1,128 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/core/styles.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/blocks/indentation.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; +import 'package:super_text_layout/super_text_layout.dart'; import '../core/document.dart'; -import '../core/document_editor.dart'; import 'layout_single_column/layout_single_column.dart'; import 'paragraph.dart'; import 'text.dart'; final _log = Logger(scope: 'list_items.dart'); +@immutable class ListItemNode extends TextNode { ListItemNode.ordered({ - required String id, - required AttributedText text, - Map? metadata, - int indent = 0, - }) : type = ListItemType.ordered, - _indent = indent, - super( - id: id, - text: text, - metadata: metadata, - ) { - putMetadataValue("blockType", const NamedAttribution("listItem")); + required super.id, + required super.text, + super.metadata, + this.indent = 0, + }) : type = ListItemType.ordered { + initAddToMetadata({ + NodeMetadata.blockType: listItemAttribution, + }); } ListItemNode.unordered({ - required String id, - required AttributedText text, - Map? metadata, - int indent = 0, - }) : type = ListItemType.unordered, - _indent = indent, - super( - id: id, - text: text, - metadata: metadata, - ) { - putMetadataValue("blockType", const NamedAttribution("listItem")); + required super.id, + required super.text, + super.metadata, + this.indent = 0, + }) : type = ListItemType.unordered { + initAddToMetadata({ + NodeMetadata.blockType: listItemAttribution, + }); } ListItemNode({ - required String id, + required super.id, required ListItemType itemType, - required AttributedText text, - Map? metadata, - int indent = 0, - }) : type = itemType, - _indent = indent, - super( - id: id, - text: text, - metadata: metadata ?? {}, - ) { - if (!hasMetadataValue("blockType")) { - putMetadataValue("blockType", const NamedAttribution("listItem")); - } + required super.text, + super.metadata, + this.indent = 0, + }) : type = itemType { + initAddToMetadata({ + NodeMetadata.blockType: listItemAttribution, + }); } final ListItemType type; - int _indent; - int get indent => _indent; - set indent(int newIndent) { - if (newIndent != _indent) { - _indent = newIndent; - notifyListeners(); - } - } + final int indent; @override bool hasEquivalentContent(DocumentNode other) { return other is ListItemNode && type == other.type && indent == other.indent && text == other.text; } + ListItemNode copyListItemWith({ + String? id, + ListItemType? itemType, + AttributedText? text, + int? indent, + Map? metadata, + }) { + return ListItemNode( + id: id ?? this.id, + itemType: itemType ?? type, + text: text ?? this.text, + indent: indent ?? this.indent, + metadata: metadata ?? this.metadata, + ); + } + + @override + ListItemNode copyTextNodeWith({ + String? id, + AttributedText? text, + Map? metadata, + }) { + return copyListItemWith( + id: id ?? this.id, + text: text ?? this.text, + metadata: metadata ?? this.metadata, + ); + } + + @override + ListItemNode copyAndReplaceMetadata(Map newMetadata) { + return copyListItemWith( + metadata: newMetadata, + ); + } + + @override + ListItemNode copyWithAddedMetadata(Map newProperties) { + return copyListItemWith( + metadata: { + ...metadata, + ...newProperties, + }, + ); + } + + @override + ListItemNode copy() { + return ListItemNode( + id: id, + text: text.copyText(0), + itemType: type, + indent: indent, + metadata: Map.from(metadata), + ); + } + @override bool operator ==(Object other) => identical(this, other) || @@ -86,12 +130,18 @@ class ListItemNode extends TextNode { other is ListItemNode && runtimeType == other.runtimeType && type == other.type && - _indent == other._indent; + indent == other.indent; @override - int get hashCode => super.hashCode ^ type.hashCode ^ _indent.hashCode; + int get hashCode => super.hashCode ^ type.hashCode ^ indent.hashCode; +} + +extension ListItemNodeType on DocumentNode { + ListItemNode get asListItem => this as ListItemNode; } +const listItemAttribution = NamedAttribution("listItem"); + enum ListItemType { ordered, unordered, @@ -108,95 +158,136 @@ class ListItemComponentBuilder implements ComponentBuilder { int? ordinalValue; if (node.type == ListItemType.ordered) { - ordinalValue = 1; - DocumentNode? nodeAbove = document.getNodeBefore(node); - while (nodeAbove != null && - nodeAbove is ListItemNode && - nodeAbove.type == ListItemType.ordered && - nodeAbove.indent >= node.indent) { - if (nodeAbove.indent == node.indent) { - ordinalValue = ordinalValue! + 1; - } - nodeAbove = document.getNodeBefore(nodeAbove); - } + ordinalValue = computeListItemOrdinalValue(node, document); } - return ListItemComponentViewModel( - nodeId: node.id, - type: node.type, - indent: node.indent, - ordinalValue: ordinalValue, - text: node.text, - textStyleBuilder: noStyleBuilder, - selectionColor: const Color(0x00000000), - ); + final textDirection = getParagraphDirection(node.text.toPlainText()); + final textAlignment = textDirection == TextDirection.ltr ? TextAlign.left : TextAlign.right; + + return switch (node.type) { + ListItemType.unordered => UnorderedListItemComponentViewModel( + nodeId: node.id, + createdAt: node.metadata[NodeMetadata.createdAt], + indent: node.indent, + text: node.text, + textDirection: textDirection, + textAlignment: textAlignment, + textStyleBuilder: noStyleBuilder, + selectionColor: const Color(0x00000000), + ), + ListItemType.ordered => OrderedListItemComponentViewModel( + nodeId: node.id, + createdAt: node.metadata[NodeMetadata.createdAt], + indent: node.indent, + ordinalValue: ordinalValue, + text: node.text, + textDirection: textDirection, + textAlignment: textAlignment, + textStyleBuilder: noStyleBuilder, + selectionColor: const Color(0x00000000), + ), + }; } @override Widget? createComponent( SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { - if (componentViewModel is! ListItemComponentViewModel) { + if (componentViewModel is! UnorderedListItemComponentViewModel && + componentViewModel is! OrderedListItemComponentViewModel) { return null; } - if (componentViewModel.type == ListItemType.unordered) { + if (componentViewModel is UnorderedListItemComponentViewModel) { return UnorderedListItemComponent( - textKey: componentContext.componentKey, + componentKey: componentContext.componentKey, text: componentViewModel.text, styleBuilder: componentViewModel.textStyleBuilder, indent: componentViewModel.indent, + dotStyle: componentViewModel.dotStyle, textSelection: componentViewModel.selection, + textDirection: componentViewModel.textDirection, + textAlignment: componentViewModel.textAlignment, selectionColor: componentViewModel.selectionColor, highlightWhenEmpty: componentViewModel.highlightWhenEmpty, + underlines: componentViewModel.createUnderlines(), + inlineWidgetBuilders: componentViewModel.inlineWidgetBuilders, ); - } else if (componentViewModel.type == ListItemType.ordered) { + } else if (componentViewModel is OrderedListItemComponentViewModel) { return OrderedListItemComponent( - textKey: componentContext.componentKey, + componentKey: componentContext.componentKey, indent: componentViewModel.indent, listIndex: componentViewModel.ordinalValue!, text: componentViewModel.text, + textDirection: componentViewModel.textDirection, + textAlignment: componentViewModel.textAlignment, styleBuilder: componentViewModel.textStyleBuilder, + numeralStyle: componentViewModel.numeralStyle, textSelection: componentViewModel.selection, selectionColor: componentViewModel.selectionColor, highlightWhenEmpty: componentViewModel.highlightWhenEmpty, + underlines: componentViewModel.createUnderlines(), + inlineWidgetBuilders: componentViewModel.inlineWidgetBuilders, ); } - editorLayoutLog - .warning("Tried to build a component for a list item view model without a list item type: $componentViewModel"); + editorLayoutLog.warning( + "Tried to build a component for a list item view model without a list item itemType: $componentViewModel"); return null; } } -class ListItemComponentViewModel extends SingleColumnLayoutComponentViewModel with TextComponentViewModel { +abstract class ListItemComponentViewModel extends SingleColumnLayoutComponentViewModel with TextComponentViewModel { ListItemComponentViewModel({ - required String nodeId, - double? maxWidth, - EdgeInsetsGeometry padding = EdgeInsets.zero, - required this.type, - this.ordinalValue, + required super.nodeId, + super.createdAt, + super.maxWidth, + super.padding = EdgeInsets.zero, + super.opacity = 1.0, required this.indent, required this.text, required this.textStyleBuilder, + this.inlineWidgetBuilders = const [], this.textDirection = TextDirection.ltr, this.textAlignment = TextAlign.left, + this.maxLines, + this.overflow = TextOverflow.clip, this.selection, required this.selectionColor, this.highlightWhenEmpty = false, - }) : super(nodeId: nodeId, maxWidth: maxWidth, padding: padding); + TextRange? composingRegion, + bool showComposingRegionUnderline = false, + UnderlineStyle spellingErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.red), + List spellingErrors = const [], + UnderlineStyle grammarErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.blue), + List grammarErrors = const [], + }) { + this.composingRegion = composingRegion; + this.showComposingRegionUnderline = showComposingRegionUnderline; + + this.spellingErrorUnderlineStyle = spellingErrorUnderlineStyle; + this.spellingErrors = spellingErrors; + + this.grammarErrorUnderlineStyle = grammarErrorUnderlineStyle; + this.grammarErrors = grammarErrors; + } - ListItemType type; - int? ordinalValue; int indent; - AttributedText text; + @override + AttributedText text; @override AttributionStyleBuilder textStyleBuilder; @override + InlineWidgetBuilderChain inlineWidgetBuilders; + @override TextDirection textDirection; @override TextAlign textAlignment; @override + int? maxLines; + @override + TextOverflow overflow; + @override TextSelection? selection; @override Color selectionColor; @@ -204,20 +295,12 @@ class ListItemComponentViewModel extends SingleColumnLayoutComponentViewModel wi bool highlightWhenEmpty; @override - ListItemComponentViewModel copy() { - return ListItemComponentViewModel( - nodeId: nodeId, - maxWidth: maxWidth, - padding: padding, - type: type, - ordinalValue: ordinalValue, - indent: indent, - text: text, - textStyleBuilder: textStyleBuilder, - textDirection: textDirection, - selection: selection, - selectionColor: selectionColor, - ); + ListItemComponentViewModel internalCopy(ListItemComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as ListItemComponentViewModel; + + copy.indent = indent; + + return copy; } @override @@ -226,52 +309,242 @@ class ListItemComponentViewModel extends SingleColumnLayoutComponentViewModel wi super == other && other is ListItemComponentViewModel && runtimeType == other.runtimeType && - nodeId == other.nodeId && - type == other.type && + textViewModelEquals(other) && + indent == other.indent; + + @override + int get hashCode => super.hashCode ^ textViewModelHashCode ^ indent.hashCode; +} + +class UnorderedListItemComponentViewModel extends ListItemComponentViewModel { + UnorderedListItemComponentViewModel({ + required super.nodeId, + super.createdAt, + super.maxWidth, + super.padding = EdgeInsets.zero, + super.opacity = 1.0, + required super.indent, + required super.text, + required super.textStyleBuilder, + super.inlineWidgetBuilders = const [], + this.dotStyle = const ListItemDotStyle(), + super.textDirection = TextDirection.ltr, + super.textAlignment = TextAlign.left, + super.maxLines, + super.overflow = TextOverflow.clip, + super.selection, + required super.selectionColor, + super.highlightWhenEmpty = false, + super.composingRegion, + super.showComposingRegionUnderline = false, + super.spellingErrorUnderlineStyle, + super.spellingErrors, + super.grammarErrorUnderlineStyle, + super.grammarErrors, + }); + + ListItemDotStyle dotStyle; + + @override + void applyStyles(Map styles) { + super.applyStyles(styles); + dotStyle = dotStyle.copyWith( + color: styles[Styles.dotColor], + shape: styles[Styles.dotShape], + size: styles[Styles.dotSize], + ); + } + + @override + UnorderedListItemComponentViewModel copy() { + return internalCopy( + UnorderedListItemComponentViewModel( + nodeId: nodeId, + createdAt: createdAt, + text: text.copy(), + textStyleBuilder: textStyleBuilder, + opacity: opacity, + selectionColor: selectionColor, + indent: indent, + ), + ); + } + + @override + UnorderedListItemComponentViewModel internalCopy(UnorderedListItemComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as UnorderedListItemComponentViewModel; + + copy + ..indent = indent + ..dotStyle = dotStyle.copyWith(); + + return copy; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is UnorderedListItemComponentViewModel && + runtimeType == other.runtimeType && + dotStyle == other.dotStyle; + + @override + int get hashCode => super.hashCode ^ dotStyle.hashCode; +} + +class OrderedListItemComponentViewModel extends ListItemComponentViewModel { + OrderedListItemComponentViewModel({ + required super.nodeId, + super.createdAt, + super.maxWidth, + super.padding = EdgeInsets.zero, + super.opacity = 1.0, + required super.indent, + this.ordinalValue, + this.numeralStyle = OrderedListNumeralStyle.arabic, + required super.text, + required super.textStyleBuilder, + super.inlineWidgetBuilders = const [], + super.textDirection = TextDirection.ltr, + super.textAlignment = TextAlign.left, + super.maxLines, + super.overflow = TextOverflow.clip, + super.selection, + required super.selectionColor, + super.highlightWhenEmpty = false, + super.composingRegion, + super.showComposingRegionUnderline = false, + super.spellingErrorUnderlineStyle, + super.spellingErrors, + super.grammarErrorUnderlineStyle, + super.grammarErrors, + }); + + int? ordinalValue; + OrderedListNumeralStyle numeralStyle; + + @override + void applyStyles(Map styles) { + super.applyStyles(styles); + numeralStyle = styles[Styles.listNumeralStyle] ?? numeralStyle; + } + + @override + OrderedListItemComponentViewModel copy() { + return internalCopy( + OrderedListItemComponentViewModel( + nodeId: nodeId, + createdAt: createdAt, + text: text.copy(), + textStyleBuilder: textStyleBuilder, + opacity: opacity, + selectionColor: selectionColor, + indent: indent, + ), + ); + } + + @override + OrderedListItemComponentViewModel internalCopy(OrderedListItemComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as OrderedListItemComponentViewModel; + + copy + ..indent = indent + ..ordinalValue = ordinalValue + ..numeralStyle = numeralStyle; + + return copy; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is OrderedListItemComponentViewModel && + runtimeType == other.runtimeType && ordinalValue == other.ordinalValue && - indent == other.indent && - text == other.text && - textDirection == other.textDirection && - selection == other.selection && - selectionColor == other.selectionColor; + numeralStyle == other.numeralStyle; @override - int get hashCode => - super.hashCode ^ - nodeId.hashCode ^ - type.hashCode ^ - ordinalValue.hashCode ^ - indent.hashCode ^ - text.hashCode ^ - textDirection.hashCode ^ - selection.hashCode ^ - selectionColor.hashCode; + int get hashCode => super.hashCode ^ ordinalValue.hashCode ^ numeralStyle.hashCode; +} + +class ListItemDotStyle { + const ListItemDotStyle({ + this.color, + this.shape = BoxShape.circle, + this.size = const Size(4, 4), + }); + + final Color? color; + final BoxShape shape; + final Size size; + + /// Returns a copy of this [ListItemDotStyle] with optional new values + /// for [color], [shape], and [size]. + ListItemDotStyle copyWith({ + Color? color, + BoxShape? shape, + Size? size, + }) { + return ListItemDotStyle( + color: color ?? this.color, + shape: shape ?? this.shape, + size: size ?? this.size, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ListItemDotStyle && + runtimeType == other.runtimeType && + color == other.color && + shape == other.shape && + size == other.size; + + @override + int get hashCode => super.hashCode ^ color.hashCode ^ shape.hashCode ^ size.hashCode; } /// Displays a un-ordered list item in a document. /// /// Supports various indentation levels, e.g., 1, 2, 3, ... -class UnorderedListItemComponent extends StatelessWidget { +class UnorderedListItemComponent extends StatefulWidget { const UnorderedListItemComponent({ Key? key, - required this.textKey, + required this.componentKey, required this.text, + this.textDirection = TextDirection.ltr, + this.textAlignment = TextAlign.left, + this.maxLines, + this.overflow = TextOverflow.clip, required this.styleBuilder, + this.inlineWidgetBuilders = const [], this.dotBuilder = _defaultUnorderedListItemDotBuilder, + this.dotStyle, this.indent = 0, - this.indentCalculator = _defaultIndentCalculator, + this.indentCalculator = defaultListItemIndentCalculator, this.textSelection, this.selectionColor = Colors.lightBlueAccent, this.showCaret = false, this.caretColor = Colors.black, this.highlightWhenEmpty = false, + this.underlines = const [], this.showDebugPaint = false, }) : super(key: key); - final GlobalKey textKey; + final GlobalKey componentKey; final AttributedText text; + final TextDirection textDirection; + final TextAlign textAlignment; + final int? maxLines; + final TextOverflow overflow; final AttributionStyleBuilder styleBuilder; + final InlineWidgetBuilderChain inlineWidgetBuilders; final UnorderedListItemDotBuilder dotBuilder; + final ListItemDotStyle? dotStyle; final int indent; final double Function(TextStyle, int indent) indentCalculator; final TextSelection? textSelection; @@ -279,58 +552,138 @@ class UnorderedListItemComponent extends StatelessWidget { final bool showCaret; final Color caretColor; final bool highlightWhenEmpty; + + final List underlines; + final bool showDebugPaint; + @override + State createState() => _UnorderedListItemComponentState(); +} + +class _UnorderedListItemComponentState extends State { + /// A [GlobalKey] that connects a [ProxyTextDocumentComponent] to its + /// descendant [TextComponent]. + /// + /// The [ProxyTextDocumentComponent] doesn't know where the [TextComponent] sits + /// in its subtree, but the proxy needs access to the [TextComponent] to provide + /// access to text layout details. + /// + /// This key doesn't need to be public because the given [widget.componentKey] + /// provides clients with direct access to text layout queries, as well as + /// standard [DocumentComponent] queries. + final GlobalKey _innerTextComponentKey = GlobalKey(); + @override Widget build(BuildContext context) { - final textStyle = styleBuilder({}); - final indentSpace = indentCalculator(textStyle, indent); - final lineHeight = textStyle.fontSize! * (textStyle.height ?? 1.25); - const manualVerticalAdjustment = 3.0; - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: indentSpace, - margin: const EdgeInsets.only(top: manualVerticalAdjustment), - decoration: BoxDecoration( - border: showDebugPaint ? Border.all(width: 1, color: Colors.grey) : null, - ), - child: SizedBox( - height: lineHeight, - child: dotBuilder(context, this), - ), - ), - Expanded( - child: TextComponent( - key: textKey, - text: text, - textStyleBuilder: styleBuilder, - textSelection: textSelection, - selectionColor: selectionColor, - highlightWhenEmpty: highlightWhenEmpty, - showDebugPaint: showDebugPaint, - ), + // Usually, the font size is obtained via the stylesheet. But the attributions might + // also contain a FontSizeAttribution, which overrides the stylesheet. Use the attributions + // of the first character to determine the text style. + final attributions = widget.text.getAllAttributionsAt(0).toSet(); + final textStyle = widget.styleBuilder(attributions); + + final indentSpace = widget.indentCalculator(textStyle, widget.indent); + final textScaler = MediaQuery.textScalerOf(context); + final lineHeight = textScaler.scale(textStyle.fontSize! * (textStyle.height ?? 1.25)); + + return ProxyTextDocumentComponent( + key: widget.componentKey, + textComponentKey: _innerTextComponentKey, + child: Directionality( + textDirection: widget.textDirection, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: indentSpace, + decoration: BoxDecoration( + border: widget.showDebugPaint ? Border.all(width: 1, color: Colors.grey) : null, + ), + child: SizedBox( + height: lineHeight, + child: widget.dotBuilder(context, widget), + ), + ), + Expanded( + child: TextComponent( + key: _innerTextComponentKey, + text: widget.text, + textDirection: widget.textDirection, + textAlign: widget.textAlignment, + maxLines: widget.maxLines, + overflow: widget.overflow, + textStyleBuilder: widget.styleBuilder, + inlineWidgetBuilders: widget.inlineWidgetBuilders, + textSelection: widget.textSelection, + textScaler: textScaler, + selectionColor: widget.selectionColor, + highlightWhenEmpty: widget.highlightWhenEmpty, + underlines: widget.underlines, + showDebugPaint: widget.showDebugPaint, + ), + ), + ], ), - ], + ), ); } } +/// The styling of an ordered list numberal. +enum OrderedListNumeralStyle { + /// Arabic numeral style (e.g. 1, 2, 3, ...). + arabic, + + /// Lowercase alphabetic numeral style (e.g. a, b, c, ...). + lowerAlpha, + + /// Uppercase alphabetic numeral style (e.g. A, B, C, ...). + upperAlpha, + + /// Lowercase Roman numeral style (e.g. i, ii, iii, ...). + lowerRoman, + + /// Uppercase Roman numeral style (e.g. I, II, III, ...). + upperRoman, +} + typedef UnorderedListItemDotBuilder = Widget Function(BuildContext, UnorderedListItemComponent); Widget _defaultUnorderedListItemDotBuilder(BuildContext context, UnorderedListItemComponent component) { + // Usually, the font size is obtained via the stylesheet. But the attributions might + // also contain a FontSizeAttribution, which overrides the stylesheet. Use the attributions + // of the first character to determine the text style. + final attributions = component.text.getAllAttributionsAt(0).toSet(); + final textStyle = component.styleBuilder(attributions); + + final dotSize = component.dotStyle?.size ?? const Size(4, 4); + return Align( alignment: Alignment.centerRight, - child: Container( - width: 4, - height: 4, - margin: const EdgeInsets.only(right: 10), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: component.styleBuilder({}).color, + child: Text.rich( + TextSpan( + // Place a zero-width joiner before the bullet point to make it properly aligned. Without this, + // the bullet point is not vertically centered with the text, even when setting the textStyle + // on the whole rich text or WidgetSpan. + text: '\u200C', + style: textStyle, + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Container( + width: dotSize.width, + height: dotSize.height, + margin: const EdgeInsets.only(right: 10), + decoration: BoxDecoration( + shape: component.dotStyle?.shape ?? BoxShape.circle, + color: component.dotStyle?.color ?? textStyle.color, + ), + ), + ), + ], ), + // Don't scale the dot. + textScaler: const TextScaler.linear(1.0), ), ); } @@ -338,81 +691,148 @@ Widget _defaultUnorderedListItemDotBuilder(BuildContext context, UnorderedListIt /// Displays an ordered list item in a document. /// /// Supports various indentation levels, e.g., 1, 2, 3, ... -class OrderedListItemComponent extends StatelessWidget { +class OrderedListItemComponent extends StatefulWidget { const OrderedListItemComponent({ Key? key, - required this.textKey, + required this.componentKey, required this.listIndex, required this.text, + this.textDirection = TextDirection.ltr, + this.textAlignment = TextAlign.left, + this.maxLines, + this.overflow = TextOverflow.clip, required this.styleBuilder, + this.inlineWidgetBuilders = const [], this.numeralBuilder = _defaultOrderedListItemNumeralBuilder, + this.numeralStyle = OrderedListNumeralStyle.arabic, this.indent = 0, - this.indentCalculator = _defaultIndentCalculator, + this.indentCalculator = defaultListItemIndentCalculator, this.textSelection, this.selectionColor = Colors.lightBlueAccent, this.showCaret = false, this.caretColor = Colors.black, this.highlightWhenEmpty = false, + this.underlines = const [], this.showDebugPaint = false, }) : super(key: key); - final GlobalKey textKey; + final GlobalKey componentKey; final int listIndex; final AttributedText text; + final TextDirection textDirection; + final TextAlign textAlignment; + final int? maxLines; + final TextOverflow overflow; final AttributionStyleBuilder styleBuilder; + final InlineWidgetBuilderChain inlineWidgetBuilders; final OrderedListItemNumeralBuilder numeralBuilder; + final OrderedListNumeralStyle numeralStyle; final int indent; - final double Function(TextStyle, int indent) indentCalculator; + final TextBlockIndentCalculator indentCalculator; final TextSelection? textSelection; final Color selectionColor; final bool showCaret; final Color caretColor; final bool highlightWhenEmpty; + + final List underlines; + final bool showDebugPaint; + @override + State createState() => _OrderedListItemComponentState(); +} + +class _OrderedListItemComponentState extends State { + /// A [GlobalKey] that connects a [ProxyTextDocumentComponent] to its + /// descendant [TextComponent]. + /// + /// The [ProxyTextDocumentComponent] doesn't know where the [TextComponent] sits + /// in its subtree, but the proxy needs access to the [TextComponent] to provide + /// access to text layout details. + /// + /// This key doesn't need to be public because the given [widget.componentKey] + /// provides clients with direct access to text layout queries, as well as + /// standard [DocumentComponent] queries. + final GlobalKey _innerTextComponentKey = GlobalKey(); + @override Widget build(BuildContext context) { - final textStyle = styleBuilder({}); - final indentSpace = indentCalculator(textStyle, indent); - final lineHeight = textStyle.fontSize! * (textStyle.height ?? 1.0); - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: indentSpace, - height: lineHeight, - decoration: BoxDecoration( - border: showDebugPaint ? Border.all(width: 1, color: Colors.grey) : null, - ), - child: SizedBox( - height: lineHeight, - child: numeralBuilder(context, this), - ), - ), - Expanded( - child: TextComponent( - key: textKey, - text: text, - textStyleBuilder: styleBuilder, - textSelection: textSelection, - selectionColor: selectionColor, - highlightWhenEmpty: highlightWhenEmpty, - showDebugPaint: showDebugPaint, - ), + // Usually, the font size is obtained via the stylesheet. But the attributions might + // also contain a FontSizeAttribution, which overrides the stylesheet. Use the attributions + // of the first character to determine the text style. + final attributions = widget.text.getAllAttributionsAt(0).toSet(); + final textStyle = widget.styleBuilder(attributions); + + final indentSpace = widget.indentCalculator(textStyle, widget.indent); + final textScaler = MediaQuery.textScalerOf(context); + final lineHeight = textScaler.scale(textStyle.fontSize! * (textStyle.height ?? 1.0)); + + return ProxyTextDocumentComponent( + key: widget.componentKey, + textComponentKey: _innerTextComponentKey, + child: Directionality( + textDirection: widget.textDirection, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: indentSpace, + height: lineHeight, + decoration: BoxDecoration( + border: widget.showDebugPaint ? Border.all(width: 1, color: Colors.grey) : null, + ), + child: SizedBox( + height: lineHeight, + child: widget.numeralBuilder(context, widget), + ), + ), + Expanded( + child: TextComponent( + key: _innerTextComponentKey, + text: widget.text, + textDirection: widget.textDirection, + textAlign: widget.textAlignment, + maxLines: widget.maxLines, + overflow: widget.overflow, + textStyleBuilder: widget.styleBuilder, + inlineWidgetBuilders: widget.inlineWidgetBuilders, + textSelection: widget.textSelection, + textScaler: textScaler, + selectionColor: widget.selectionColor, + highlightWhenEmpty: widget.highlightWhenEmpty, + underlines: widget.underlines, + showDebugPaint: widget.showDebugPaint, + ), + ), + ], ), - ], + ), ); } } typedef OrderedListItemNumeralBuilder = Widget Function(BuildContext, OrderedListItemComponent); -double _defaultIndentCalculator(TextStyle textStyle, int indent) { +/// The standard [TextBlockIndentCalculator] used by list items in `SuperEditor`. +double defaultListItemIndentCalculator(TextStyle textStyle, int indent) { return (textStyle.fontSize! * 0.60) * 4 * (indent + 1); } Widget _defaultOrderedListItemNumeralBuilder(BuildContext context, OrderedListItemComponent component) { + // Usually, the font size is obtained via the stylesheet. But the attributions might + // also contain a FontSizeAttribution, which overrides the stylesheet. Use the attributions + // of the first character to determine the text style. + final attributions = component.text.getAllAttributionsAt(0).toSet(); + + // We set inherit to false because, when it's true, the Text widget merges the + // textStyle with the default textStyle. This might cause it to apply a font family, + // letter spacing, etc. That might cause the numeral to be misaligned with the + // list item's text. + final textStyle = component.styleBuilder(attributions).copyWith( + inherit: false, + ); + return OverflowBox( maxWidth: double.infinity, maxHeight: double.infinity, @@ -421,16 +841,141 @@ Widget _defaultOrderedListItemNumeralBuilder(BuildContext context, OrderedListIt child: Padding( padding: const EdgeInsets.only(right: 5.0), child: Text( - '${component.listIndex}.', + '${_numeralForIndex(component.listIndex, component.numeralStyle)}.', textAlign: TextAlign.right, - style: component.styleBuilder({}).copyWith(), + style: textStyle, ), ), ), ); } -class IndentListItemCommand implements EditorCommand { +/// Returns the text to be displayed for the given [numeral] and [numeralStyle]. +String _numeralForIndex(int numeral, OrderedListNumeralStyle numeralStyle) { + return switch (numeralStyle) { + OrderedListNumeralStyle.arabic => '$numeral', + OrderedListNumeralStyle.upperRoman => _intToRoman(numeral) ?? '$numeral', + OrderedListNumeralStyle.lowerRoman => _intToRoman(numeral)?.toLowerCase() ?? '$numeral', + OrderedListNumeralStyle.upperAlpha => _intToAlpha(numeral), + OrderedListNumeralStyle.lowerAlpha => _intToAlpha(numeral).toLowerCase(), + }; +} + +/// Converts a number to its Roman numeral representation. +/// +/// Returns `null` if the number is greater than 3999, as we don't support the +/// vinculum notation. See more at https://en.wikipedia.org/wiki/Roman_numerals#cite_ref-Ifrah2000_52-1. +String? _intToRoman(int number) { + if (number <= 0) { + throw ArgumentError('Roman numerals are only defined for positive integers'); + } + + if (number > 3999) { + // Starting from 4000, the Roman numeral representation uses a bar over the numeral to represent + // a multiplication by 1000. We don't support this notation. + return null; + } + + const values = [1000, 500, 100, 50, 10, 5, 1]; + const symbols = ["M", "D", "C", "L", "X", "V", "I"]; + + int remainingValueToConvert = number; + + final result = StringBuffer(); + + for (int i = 0; i < values.length; i++) { + final currentSymbol = symbols[i]; + final currentSymbolValue = values[i]; + + final count = remainingValueToConvert ~/ currentSymbolValue; + + if (count > 0 && count < 4) { + // The number is bigger than the current symbol's value. Add the appropriate + // number of digits, respecting the maximum of three consecutive symbols. + // For example, for 300 we would add "CCC", but for 400 we won't add "CCCC". + result.write(currentSymbol * count); + + remainingValueToConvert %= currentSymbolValue; + } + + if (remainingValueToConvert <= 0) { + // The conversion is complete. + break; + } + + // We still have some value to convert. Check if we can use subtractive notation. + if (i % 2 == 0 && i + 2 < values.length) { + // Numbers in even positions (0, 2, 4) can be subtracted with other numbers + // two positions to the right of them: + // + // - 1000 (M) can be subtracted with 100 (C). + // - 100 (C) can be subtracted with 10 (X). + // - 10 (X) can be subtracted with 1 (I). + // + // Check if we can do this subtraction. + final subtractiveValue = currentSymbolValue - values[i + 2]; + if (remainingValueToConvert >= subtractiveValue) { + result.write(symbols[i + 2] + currentSymbol); + remainingValueToConvert -= subtractiveValue; + } + } else if (i % 2 != 0 && i + 1 < values.length) { + // Numbers in odd positions (1, 3, 5) can be subtracted with the number + // immediately after it to the right: + // + // - 500 (D) can be subtracted with 100 (C). + // - 50 (L) can be subtracted with 10 (X). + // - 5 (V) can be subtracted with 1 (I). + // + // Check if we can do this subtraction. + final subtractiveValue = currentSymbolValue - values[i + 1]; + if (remainingValueToConvert >= subtractiveValue) { + result.write(symbols[i + 1] + currentSymbol); + remainingValueToConvert -= subtractiveValue; + } + } + } + + return result.toString(); +} + +/// Converts a number to a string composed of A-Z characters. +/// +/// For example: +/// - 1 -> A +/// - 2 -> B +/// - ... +/// - 26 -> Z +/// - 27 -> AA +/// - 28 -> AB +String _intToAlpha(int num) { + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const base = characters.length; + + String result = ''; + + while (num > 0) { + // Convert to 0-based index. + num -= 1; + + // Find the next character to be added. + result = characters[num % base] + result; + + // Move to the next digit. + num = num ~/ base; + } + + return result; +} + +class IndentListItemRequest implements EditRequest { + IndentListItemRequest({ + required this.nodeId, + }); + + final String nodeId; +} + +class IndentListItemCommand extends EditCommand { IndentListItemCommand({ required this.nodeId, }); @@ -438,9 +983,11 @@ class IndentListItemCommand implements EditorCommand { final String nodeId; @override - void execute(Document document, DocumentEditorTransaction transaction) { - // TODO: figure out how node changes should work in terms of - // a DocumentEditorTransaction (#67) + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; final node = document.getNodeById(nodeId); final listItem = node as ListItemNode; if (listItem.indent >= 6) { @@ -448,11 +995,30 @@ class IndentListItemCommand implements EditorCommand { return; } - listItem.indent += 1; + document.replaceNodeById( + node.id, + node.copyListItemWith( + indent: listItem.indent + 1, + ), + ); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(nodeId), + ) + ]); } } -class UnIndentListItemCommand implements EditorCommand { +class UnIndentListItemRequest implements EditRequest { + UnIndentListItemRequest({ + required this.nodeId, + }); + + final String nodeId; +} + +class UnIndentListItemCommand extends EditCommand { UnIndentListItemCommand({ required this.nodeId, }); @@ -460,22 +1026,109 @@ class UnIndentListItemCommand implements EditorCommand { final String nodeId; @override - void execute(Document document, DocumentEditorTransaction transaction) { + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; final node = document.getNodeById(nodeId); final listItem = node as ListItemNode; if (listItem.indent > 0) { - // TODO: figure out how node changes should work in terms of - // a DocumentEditorTransaction (#67) - listItem.indent -= 1; + document.replaceNodeById( + node.id, + node.copyListItemWith( + indent: listItem.indent - 1, + ), + ); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(nodeId), + ) + ]); } else { - ConvertListItemToParagraphCommand( - nodeId: nodeId, - ).execute(document, transaction); + executor.executeCommand( + ConvertListItemToParagraphCommand( + nodeId: nodeId, + ), + ); + } + } +} + +/// An [EditCommand] that inserts a newline when the caret sits within a [ListItemNode]. +/// +/// This command adds the following behaviors beyond the usual: +/// * When the caret is in the middle of a list item, splits the list item into two +/// list items. +/// +/// * When the caret is at the end of a list item, inserts a new empty list item +/// instead of an empty paragraph. +/// +/// * Inserting a newline into an empty list item converts it into a paragraph +/// instead of inserting a new list item. +class InsertNewlineInListItemAtCaretCommand extends BaseInsertNewlineAtCaretCommand { + const InsertNewlineInListItemAtCaretCommand(this.newNodeId); + + /// {@macro newNodeId} + final String newNodeId; + + @override + void doInsertNewline( + EditContext context, + CommandExecutor executor, + DocumentPosition caretPosition, + NodePosition caretNodePosition, + ) { + final node = context.document.getNodeById(caretPosition.nodeId); + if (caretNodePosition is! TextNodePosition || node is! ListItemNode) { + // We don't know how to deal with this kind of node. + return; + } + + if (node.text.isEmpty) { + // The list item is empty. Convert it to a paragraph. + executor.executeCommand( + ConvertListItemToParagraphCommand(nodeId: node.id), + ); + return; } + + // Split the list item into two. + executor + ..executeCommand( + SplitListItemCommand( + nodeId: node.id, + splitPosition: caretNodePosition, + newNodeId: newNodeId, + ), + ) + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ); } } -class ConvertListItemToParagraphCommand implements EditorCommand { +class ConvertListItemToParagraphRequest implements EditRequest { + ConvertListItemToParagraphRequest({ + required this.nodeId, + this.paragraphMetadata, + }); + + final String nodeId; + final Map? paragraphMetadata; +} + +class ConvertListItemToParagraphCommand extends EditCommand { ConvertListItemToParagraphCommand({ required this.nodeId, this.paragraphMetadata, @@ -485,20 +1138,44 @@ class ConvertListItemToParagraphCommand implements EditorCommand { final Map? paragraphMetadata; @override - void execute(Document document, DocumentEditorTransaction transaction) { + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; final node = document.getNodeById(nodeId); final listItem = node as ListItemNode; + final newMetadata = Map.from(paragraphMetadata ?? {}); + if (newMetadata["blockType"] == listItemAttribution) { + newMetadata["blockType"] = paragraphAttribution; + } final newParagraphNode = ParagraphNode( id: listItem.id, text: listItem.text, - metadata: paragraphMetadata ?? {}, + metadata: newMetadata, ); - transaction.replaceNode(oldNode: listItem, newNode: newParagraphNode); + document.replaceNodeById(listItem.id, newParagraphNode); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(listItem.id), + ) + ]); } } -class ConvertParagraphToListItemCommand implements EditorCommand { +class ConvertParagraphToListItemRequest implements EditRequest { + ConvertParagraphToListItemRequest({ + required this.nodeId, + required this.type, + }); + + final String nodeId; + final ListItemType type; +} + +class ConvertParagraphToListItemCommand extends EditCommand { ConvertParagraphToListItemCommand({ required this.nodeId, required this.type, @@ -508,7 +1185,11 @@ class ConvertParagraphToListItemCommand implements EditorCommand { final ListItemType type; @override - void execute(Document document, DocumentEditorTransaction transaction) { + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; final node = document.getNodeById(nodeId); final paragraphNode = node as ParagraphNode; @@ -517,11 +1198,27 @@ class ConvertParagraphToListItemCommand implements EditorCommand { itemType: type, text: paragraphNode.text, ); - transaction.replaceNode(oldNode: paragraphNode, newNode: newListItemNode); + document.replaceNodeById(paragraphNode.id, newListItemNode); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(paragraphNode.id), + ) + ]); } } -class ChangeListItemTypeCommand implements EditorCommand { +class ChangeListItemTypeRequest implements EditRequest { + ChangeListItemTypeRequest({ + required this.nodeId, + required this.newType, + }); + + final String nodeId; + final ListItemType newType; +} + +class ChangeListItemTypeCommand extends EditCommand { ChangeListItemTypeCommand({ required this.nodeId, required this.newType, @@ -531,7 +1228,11 @@ class ChangeListItemTypeCommand implements EditorCommand { final ListItemType newType; @override - void execute(Document document, DocumentEditorTransaction transaction) { + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; final existingListItem = document.getNodeById(nodeId) as ListItemNode; final newListItemNode = ListItemNode( @@ -539,11 +1240,29 @@ class ChangeListItemTypeCommand implements EditorCommand { itemType: newType, text: existingListItem.text, ); - transaction.replaceNode(oldNode: existingListItem, newNode: newListItemNode); + document.replaceNodeById(existingListItem.id, newListItemNode); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(existingListItem.id), + ) + ]); } } -class SplitListItemCommand implements EditorCommand { +class SplitListItemRequest implements EditRequest { + SplitListItemRequest({ + required this.nodeId, + required this.splitPosition, + required this.newNodeId, + }); + + final String nodeId; + final TextPosition splitPosition; + final String newNodeId; +} + +class SplitListItemCommand extends EditCommand { SplitListItemCommand({ required this.nodeId, required this.splitPosition, @@ -555,21 +1274,29 @@ class SplitListItemCommand implements EditorCommand { final String newNodeId; @override - void execute(Document document, DocumentEditorTransaction transaction) { + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.find(Editor.composerKey); + final node = document.getNodeById(nodeId); final listItemNode = node as ListItemNode; final text = listItemNode.text; final startText = text.copyText(0, splitPosition.offset); - final endText = splitPosition.offset < text.text.length ? text.copyText(splitPosition.offset) : AttributedText(); + final endText = splitPosition.offset < text.length ? text.copyText(splitPosition.offset) : AttributedText(); _log.log('SplitListItemCommand', 'Splitting list item:'); _log.log('SplitListItemCommand', ' - start text: "$startText"'); _log.log('SplitListItemCommand', ' - end text: "$endText"'); // Change the current node's content to just the text before the caret. _log.log('SplitListItemCommand', ' - changing the original list item text due to split'); - // TODO: figure out how node changes should work in terms of - // a DocumentEditorTransaction (#67) - listItemNode.text = startText; + final updatedListItemNode = listItemNode.copyListItemWith(text: startText); + document.replaceNodeById( + listItemNode.id, + updatedListItemNode, + ); // Create a new node that will follow the current node. Set its text // to the text that was removed from the current node. @@ -587,23 +1314,48 @@ class SplitListItemCommand implements EditorCommand { // Insert the new node after the current node. _log.log('SplitListItemCommand', ' - inserting new node in document'); - transaction.insertNodeAfter( - existingNode: node, + document.insertNodeAfter( + existingNodeId: updatedListItemNode.id, newNode: newNode, ); + // Clear the composing region to avoid keeping a region pointing to the + // node that was split. + composer.setComposingRegion(null); + _log.log('SplitListItemCommand', ' - inserted new node: ${newNode.id} after old one: ${node.id}'); + + executor.logChanges([ + SplitListItemIntention.start(), + DocumentEdit( + NodeChangeEvent(nodeId), + ), + DocumentEdit( + NodeInsertedEvent(newNodeId, document.getNodeIndexById(newNodeId)), + ), + SplitListItemIntention.end(), + ]); } } +class SplitListItemIntention extends Intention { + SplitListItemIntention.start() : super.start(); + + SplitListItemIntention.end() : super.end(); +} + ExecutionInstruction tabToIndentListItem({ - required EditContext editContext, - required RawKeyEvent keyEvent, + required SuperEditorContext editContext, + required KeyEvent keyEvent, }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.tab) { return ExecutionInstruction.continueExecution; } - if (keyEvent.isShiftPressed) { + if (HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; } @@ -613,13 +1365,17 @@ ExecutionInstruction tabToIndentListItem({ } ExecutionInstruction shiftTabToUnIndentListItem({ - required EditContext editContext, - required RawKeyEvent keyEvent, + required SuperEditorContext editContext, + required KeyEvent keyEvent, }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.tab) { return ExecutionInstruction.continueExecution; } - if (!keyEvent.isShiftPressed) { + if (!HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; } @@ -629,9 +1385,13 @@ ExecutionInstruction shiftTabToUnIndentListItem({ } ExecutionInstruction backspaceToUnIndentListItem({ - required EditContext editContext, - required RawKeyEvent keyEvent, + required SuperEditorContext editContext, + required KeyEvent keyEvent, }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { return ExecutionInstruction.continueExecution; } @@ -643,7 +1403,7 @@ ExecutionInstruction backspaceToUnIndentListItem({ return ExecutionInstruction.continueExecution; } - final node = editContext.editor.document.getNodeById(editContext.composer.selection!.extent.nodeId); + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); if (node is! ListItemNode) { return ExecutionInstruction.continueExecution; } @@ -656,19 +1416,30 @@ ExecutionInstruction backspaceToUnIndentListItem({ return wasIndented ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; } -ExecutionInstruction splitListItemWhenEnterPressed({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - if (keyEvent.logicalKey != LogicalKeyboardKey.enter) { - return ExecutionInstruction.continueExecution; +/// Computes the ordinal value of an ordered list item. +/// +/// Walks backwards counting the number of ordered list items above the [listItem] with the same indentation level. +/// +/// The ordinal value starts at 1. +int computeListItemOrdinalValue(ListItemNode listItem, Document document) { + if (listItem.type != ListItemType.ordered) { + // Unordered list items do not have an ordinal value. + return 0; } - final node = editContext.editor.document.getNodeById(editContext.composer.selection!.extent.nodeId); - if (node is! ListItemNode) { - return ExecutionInstruction.continueExecution; + int ordinalValue = 1; + DocumentNode? nodeAbove = document.getNodeBeforeById(listItem.id); + while (nodeAbove != null && nodeAbove is ListItemNode && nodeAbove.indent >= listItem.indent) { + if (nodeAbove.indent == listItem.indent) { + if (nodeAbove.type != ListItemType.ordered) { + // We found an unordered list item with the same indentation level as the ordered list item. + // Other ordered list items above this one do not belong to the same list. + break; + } + ordinalValue = ordinalValue + 1; + } + nodeAbove = document.getNodeBeforeById(nodeAbove.id); } - final didSplitListItem = editContext.commonOps.insertBlockLevelNewline(); - return didSplitListItem ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; + return ordinalValue; } diff --git a/super_editor/lib/src/default_editor/multi_node_editing.dart b/super_editor/lib/src/default_editor/multi_node_editing.dart index 5a2483461f..a465d41446 100644 --- a/super_editor/lib/src/default_editor/multi_node_editing.dart +++ b/super_editor/lib/src/default_editor/multi_node_editing.dart @@ -2,10 +2,13 @@ import 'dart:math'; import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/services.dart'; -import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; import 'package:super_editor/src/core/document.dart'; -import 'package:super_editor/src/core/document_editor.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/box_component.dart'; +import 'package:super_editor/src/default_editor/common_editor_operations.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; @@ -13,87 +16,898 @@ import 'paragraph.dart'; final _log = Logger(scope: 'multi_node_editing.dart'); -class DeleteSelectionCommand implements EditorCommand { - DeleteSelectionCommand({ - required this.documentSelection, +/// Request to paste the given structured [content] in the document at the +/// given [pastePosition]. +class PasteStructuredContentEditorRequest implements EditRequest { + PasteStructuredContentEditorRequest({ + required this.content, + required this.pastePosition, + }); + + final Document content; + final DocumentPosition pastePosition; +} + +/// Inserts given structured content, in the form of a `List` of [DocumentNode]s at a +/// given paste position within the document. +class PasteStructuredContentEditorCommand extends EditCommand { + PasteStructuredContentEditorCommand({ + required Document content, + required DocumentPosition pastePosition, + }) : _content = content, + _pastePosition = pastePosition; + + final Document _content; + final DocumentPosition _pastePosition; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + if (_content.isEmpty) { + // Nothing to paste. Return. + return; + } + + final document = context.document; + final composer = context.find(Editor.composerKey); + final currentNodeWithSelection = document.getNodeById(_pastePosition.nodeId); + if (currentNodeWithSelection is! TextNode) { + throw Exception('Can\'t handle pasting text within node of type: $currentNodeWithSelection'); + } + + editorOpsLog.info("Pasting clipboard content as Markdown in document."); + + if (_content.length == 1) { + _pasteSingleNode(executor, document, _content.first, _pastePosition, currentNodeWithSelection); + } else { + _pasteMultipleNodes(executor, document, _content, currentNodeWithSelection); + } + + editorOpsLog.fine('New selection after paste operation: ${composer.selection}'); + editorOpsLog.fine('Done with paste command.'); + } + + void _pasteSingleNode(CommandExecutor executor, MutableDocument document, DocumentNode pastedNode, + DocumentPosition pastePosition, TextNode currentNodeWithSelection) { + if (_canMergeNodes(currentNodeWithSelection, pastedNode)) { + executor.executeCommand( + InsertAttributedTextCommand( + documentPosition: pastePosition, + // Only text nodes are merge-able, therefore we know that the first pasted node + // is a TextNode. + textToInsert: (pastedNode as TextNode).text, + ), + ); + executor + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: pastePosition.nodeId, + nodePosition: TextNodePosition( + offset: (pastePosition.nodePosition as TextNodePosition).offset + pastedNode.text.length), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ) + // Clear the composing region after content change and selection move. + ..executeCommand(ChangeComposingRegionCommand(null)); + + return; + } + + late final String upstreamNodeId; + DocumentPosition? caretPositionAfterPaste; + + if (currentNodeWithSelection.text.isEmpty || + (pastePosition.nodePosition as TextNodePosition).offset == currentNodeWithSelection.text.length) { + // We're pasting into an empty node, or pasting at the very end of a non-empty `TextNode`. + // We already know we can't combine the pasted content with this node. We'll paste below + // this node. + upstreamNodeId = currentNodeWithSelection.id; + } else { + // We're pasting into the middle of a non-empty text node. We already know we can't combine + // the pasted content with this node. Split the selected node before pasting. + final (splitUpstreamNodeId, splitDownstreamNodeId) = _splitPasteParagraph( + executor, currentNodeWithSelection.id, (pastePosition.nodePosition as TextNodePosition).offset); + upstreamNodeId = splitUpstreamNodeId; + + // Since we split a non-empty paragraph, we'll insert the caret at the start + // of the 2nd half of the split text. + caretPositionAfterPaste = DocumentPosition( + nodeId: splitDownstreamNodeId, + nodePosition: const TextNodePosition(offset: 0), + ); + } + + // Insert the pasted node after the split upstream node. + document.insertNodeAfter( + existingNodeId: upstreamNodeId, + newNode: pastedNode, + ); + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(pastedNode.id, document.getNodeIndexById(pastedNode.id)), + ), + ]); + + // Maybe delete the original selected node, and maybe insert empty paragraph at end. + if (currentNodeWithSelection.text.isEmpty) { + // We pasted content below the selected node, but the selected node was empty. + // As a UX policy, let's delete that empty paragraph because a user won't expect + // it to stay around. + document.deleteNode(currentNodeWithSelection.id); + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(pastedNode.id, currentNodeWithSelection), + ), + ]); + + if (pastedNode is! TextNode) { + // The pasted content isn't text. It might be an image, table, etc. As a UX + // policy, we insert an empty paragraph after the pasted content because users + // typically expect to be able to start typing after pasting. + final newNodeId = Editor.createNodeId(); + document.insertNodeAfter( + existingNodeId: pastedNode.id, + newNode: ParagraphNode(id: newNodeId, text: AttributedText()), + ); + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(newNodeId, document.getNodeIndexById(newNodeId)), + ), + ]); + + caretPositionAfterPaste = DocumentPosition(nodeId: newNodeId, nodePosition: const TextNodePosition(offset: 0)); + } + } + + // We didn't split a non-empty paragraph, and we didn't insert a new empty paragraph + // at the end of the pasted content. Therefore, place the caret at the end of the pasted + // content. + caretPositionAfterPaste ??= DocumentPosition( + nodeId: pastedNode.id, + nodePosition: pastedNode.endPosition, + ); + + // Place the caret at the end of the pasted content. + executor + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: caretPositionAfterPaste, + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ) + // Clear the composing region after content change and selection move. + ..executeCommand(ChangeComposingRegionCommand(null)); + } + + void _pasteMultipleNodes( + CommandExecutor executor, + MutableDocument document, + Document pastedNodes, + TextNode currentNodeWithSelection, + ) { + final textNode = document.getNode(_pastePosition) as TextNode; + final pasteTextOffset = (_pastePosition.nodePosition as TextPosition).offset; + final nodesToInsert = List.from(_content); + + // Split the original node in two, around the caret. + TextNode? downstreamSplitNode; + if (pasteTextOffset < textNode.endPosition.offset) { + // The caret sits somewhere in the middle of an existing text node. Split the + // node at the caret so we can paste structured content in between. + final (_, downstreamSplitNodeId) = _splitPasteParagraph(executor, currentNodeWithSelection.id, pasteTextOffset); + downstreamSplitNode = document.getNodeById(downstreamSplitNodeId) as TextNode; + } + + // (Possibly) merge or delete the upstream split node. + bool deleteInitiallySelectedNode = false; + final firstPastedNode = nodesToInsert.first; + if (_canMergeNodes(currentNodeWithSelection, firstPastedNode)) { + // The text in the first pasted node is stylistically compatible with the + // existing text in the node where the paste was triggered. Therefore, instead + // inserting the first pasted node, merge its content with the existing node. + executor.executeCommand( + InsertAttributedTextCommand( + documentPosition: _pastePosition, + // Only text nodes are merge-able, therefore we know that the first pasted node + // is a TextNode. + textToInsert: (firstPastedNode as TextNode).text, + ), + ); + + // We've pasted the first new node. Remove it from the nodes to insert. + nodesToInsert.removeAt(0); + } else if (currentNodeWithSelection.text.length == 0) { + // The node with the selection is an empty text node. After we use that node's + // position to insert other nodes, we want to delete that first node, as if the + // pasted content replaced it. + deleteInitiallySelectedNode = true; + } + + // The caret position we want after the paste. + DocumentPosition? pasteEndPosition; + + // (Possibly) merge or delete the downstream split node. + if (nodesToInsert.isNotEmpty) { + final lastPastedNode = nodesToInsert.last; + if (downstreamSplitNode != null && _canMergeNodes(lastPastedNode, downstreamSplitNode)) { + // The text in the last pasted node is stylistically compatible with the + // existing text in the node that was split after the caret. Therefore, instead + // of inserting the last pasted node, merge its content with the existing split + // node. + executor.executeCommand( + InsertAttributedTextCommand( + documentPosition: DocumentPosition( + nodeId: downstreamSplitNode.id, + nodePosition: const TextNodePosition(offset: 0), + ), + // Only text nodes are merge-able, therefore we know that the last pasted node + // is a TextNode. + textToInsert: (lastPastedNode as TextNode).text, + ), + ); + + // We've pasted the last new node. Remove it from the nodes to insert. + nodesToInsert.removeLast(); + + // Since we combined the last paste node with the 2nd half of the original + // node, the caret position sits in the middle of that combined node. + pasteEndPosition = DocumentPosition( + nodeId: downstreamSplitNode.id, + nodePosition: TextNodePosition(offset: lastPastedNode.text.length), + ); + } + } + + // Now that the first and last pasted nodes have been merged with existing content + // (or not), insert all remaining pasted nodes into the document. + DocumentNode previousNode = currentNodeWithSelection; + for (final pastedNode in nodesToInsert) { + document.insertNodeAfter( + existingNodeId: previousNode.id, + newNode: pastedNode, + ); + previousNode = pastedNode; + + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(pastedNode.id, document.getNodeIndexById(pastedNode.id)), + ) + ]); + } + pasteEndPosition ??= DocumentPosition( + nodeId: previousNode.id, + nodePosition: previousNode.endPosition, + ); + + if (deleteInitiallySelectedNode) { + document.deleteNode(currentNodeWithSelection.id); + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(currentNodeWithSelection.id, currentNodeWithSelection), + ) + ]); + } + + // Place the caret at the end of the pasted content. + executor + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed(position: pasteEndPosition), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ) + // The content changed, and the selection moved. Clear the composing region to + // ensure we don't try to report an invalid region. + ..executeCommand(ChangeComposingRegionCommand(null)); + } + + (String upstreamNode, String downstreamNode) _splitPasteParagraph( + CommandExecutor executor, + String currentNodeWithSelectionId, + int pasteTextOffset, + ) { + final newNodeId = Editor.createNodeId(); + executor.executeCommand( + SplitParagraphCommand( + nodeId: currentNodeWithSelectionId, + splitPosition: TextPosition(offset: pasteTextOffset), + newNodeId: newNodeId, + replicateExistingMetadata: true, + ), + ); + + return (currentNodeWithSelectionId, newNodeId); + } + + bool _canMergeNodes(DocumentNode existingNode, DocumentNode newNode) { + if (existingNode is! TextNode || newNode is! TextNode) { + // We can only merge text nodes. + return false; + } + + if (existingNode.metadata['blockType'] != newNode.metadata['blockType']) { + // Text nodes with different block types cannot be merged, e.g., "Header 1" with a "Blockquote". + return false; + } + + return true; + } +} + +/// Inserts the [newNode] at the end of the document. +class InsertNodeAtEndOfDocumentRequest implements EditRequest { + InsertNodeAtEndOfDocumentRequest(this.newNode); + + final DocumentNode newNode; +} + +class InsertNodeAtIndexRequest implements EditRequest { + InsertNodeAtIndexRequest({ + required this.nodeIndex, + required this.newNode, + }); + + final int nodeIndex; + final DocumentNode newNode; +} + +class InsertNodeAtIndexCommand extends EditCommand { + InsertNodeAtIndexCommand({ + required this.nodeIndex, + required this.newNode, + }); + + final int nodeIndex; + final DocumentNode newNode; + + @override + String describe() => "Insert node at index $nodeIndex: $newNode"; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + document.insertNodeAt(nodeIndex, newNode); + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(newNode.id, nodeIndex), + ) + ]); + } +} + +class InsertNodeBeforeNodeRequest implements EditRequest { + const InsertNodeBeforeNodeRequest({ + required this.existingNodeId, + required this.newNode, + }); + + final String existingNodeId; + final DocumentNode newNode; +} + +class InsertNodeBeforeNodeCommand extends EditCommand { + InsertNodeBeforeNodeCommand({ + required this.existingNodeId, + required this.newNode, + }); + + final String existingNodeId; + final DocumentNode newNode; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final existingNode = document.getNodeById(existingNodeId)!; + + document.insertNodeBefore(existingNodeId: existingNode.id, newNode: newNode); + + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(newNode.id, document.getNodeIndexById(newNode.id)), + ) + ]); + } +} + +class InsertNodeAfterNodeRequest implements EditRequest { + const InsertNodeAfterNodeRequest({ + required this.existingNodeId, + required this.newNode, + }); + + final String existingNodeId; + final DocumentNode newNode; +} + +class InsertNodeAfterNodeCommand extends EditCommand { + InsertNodeAfterNodeCommand({ + required this.existingNodeId, + required this.newNode, + }); + + final String existingNodeId; + final DocumentNode newNode; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final existingNode = document.getNodeById(existingNodeId)!; + + document.insertNodeAfter(existingNodeId: existingNode.id, newNode: newNode); + + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(newNode.id, document.getNodeIndexById(newNode.id)), + ) + ]); + } +} + +class InsertNodeAtCaretRequest implements EditRequest { + InsertNodeAtCaretRequest({ + required this.node, + }); + + final DocumentNode node; +} + +class InsertNodeAtCaretCommand extends EditCommand { + InsertNodeAtCaretCommand({ + required this.newNode, + }); + + final DocumentNode newNode; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.find(Editor.composerKey); + + if (composer.selection == null) { + return; + } + if (composer.selection!.base.nodeId != composer.selection!.extent.nodeId) { + return; + } + + final selectedNodeId = composer.selection!.base.nodeId; + final selectedNode = document.getNodeById(selectedNodeId); + if (selectedNode is! ParagraphNode) { + return; + } + + final paragraphPosition = composer.selection!.extent.nodePosition as TextNodePosition; + final beginningOfParagraph = selectedNode.beginningPosition; + final endOfParagraph = selectedNode.endPosition; + + DocumentSelection newSelection; + if (selectedNode.text.isEmpty) { + // Insert new block node above selected paragraph. + document.insertNodeBefore(existingNodeId: selectedNode.id, newNode: newNode); + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(newNode.id, document.getNodeIndexById(newNode.id)), + ), + ]); + + newSelection = DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: selectedNodeId, + nodePosition: selectedNode.beginningPosition, + ), + ); + } else if (paragraphPosition.offset == beginningOfParagraph.offset) { + // Insert block item after the paragraph. + document.insertNodeAt(document.getNodeIndexById(selectedNode.id), newNode); + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(newNode.id, document.getNodeIndexById(newNode.id)), + ) + ]); + + newSelection = DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: selectedNode.id, + nodePosition: selectedNode.beginningPosition, + ), + ); + } else if (paragraphPosition.offset == endOfParagraph.offset) { + final emptyParagraph = ParagraphNode(id: Editor.createNodeId(), text: AttributedText()); + + // Insert block item after the paragraph and insert a new empty paragraph. + document + ..insertNodeAfter(existingNodeId: selectedNode.id, newNode: newNode) + ..insertNodeAfter(existingNodeId: newNode.id, newNode: emptyParagraph); + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(newNode.id, document.getNodeIndexById(newNode.id)), + ), + DocumentEdit( + NodeInsertedEvent(emptyParagraph.id, document.getNodeIndexById(emptyParagraph.id)), + ), + ]); + + newSelection = DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: emptyParagraph.id, + nodePosition: emptyParagraph.endPosition, + ), + ); + } else { + // Split the paragraph and inset image in between. + final textBefore = selectedNode.text.copyText(0, paragraphPosition.offset); + final textAfter = selectedNode.text.copyText(paragraphPosition.offset); + + final newParagraph = ParagraphNode(id: Editor.createNodeId(), text: textAfter); + + final updatedSelectedNode = selectedNode.copyParagraphWith(text: textBefore); + document + ..replaceNodeById(selectedNode.id, updatedSelectedNode) + ..insertNodeAfter(existingNodeId: updatedSelectedNode.id, newNode: newNode) + ..insertNodeAfter(existingNodeId: newNode.id, newNode: newParagraph); + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(selectedNodeId), + ), + DocumentEdit( + NodeInsertedEvent(newNode.id, document.getNodeIndexById(newNode.id)), + ), + DocumentEdit( + NodeInsertedEvent(newParagraph.id, document.getNodeIndexById(newParagraph.id)), + ), + ]); + + newSelection = DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newParagraph.id, + nodePosition: newParagraph.beginningPosition, + ), + ); + } + + executor + ..executeCommand(ChangeSelectionCommand( + newSelection, + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + )) + // The existing composing region is probably still valid (in terms of content), + // but the selection moved, so clear it. + ..executeCommand(ChangeComposingRegionCommand(null)); + } +} + +class MoveNodeRequest implements EditRequest { + const MoveNodeRequest({ + required this.nodeId, + required this.newIndex, + }); + + final String nodeId; + final int newIndex; +} + +class MoveNodeCommand extends EditCommand { + MoveNodeCommand({ + required this.nodeId, + required this.newIndex, + }); + + final String nodeId; + final int newIndex; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + // Log all the move changes that will happen when we move the target node + // elsewhere in the document. + final nodeMoveEvents = []; + + final targetNodeIndex = document.getNodeIndexById(nodeId); + final startIndex = min(targetNodeIndex, newIndex); + final endIndex = max(targetNodeIndex, newIndex); + + // When moving one node to another index, all nodes between those indices + // are pushed up, or down, depending on whether the new node index is + // higher or lower than the existing node index. This direction tells us + // which way the other nodes will move. + final otherNodeMovementDirection = newIndex > targetNodeIndex ? 1 : -1; + + // Collect change events for everything that will happen when we tell the + // MutableDocument to move the desired node to its new index. + for (int i = startIndex; i <= endIndex; i += 1) { + if (i == targetNodeIndex) { + // This is the node that we care about moving. Report its move to the + // new index. + nodeMoveEvents.add( + DocumentEdit( + NodeMovedEvent(nodeId: nodeId, from: targetNodeIndex, to: newIndex), + ), + ); + continue; + } + + // This is a node that got moved up/down by one spot, as a consequence of moving + // the target node. Report its change of index. + nodeMoveEvents.add( + DocumentEdit( + NodeMovedEvent(nodeId: document.getNodeAt(i)!.id, from: i, to: i - otherNodeMovementDirection), + ), + ); + } + + // Move the target node to its destination index. + document.moveNode(nodeId: nodeId, targetIndex: newIndex); + + // Report all the node movements. + executor.logChanges(nodeMoveEvents); + } +} + +class ReplaceNodeRequest implements EditRequest { + ReplaceNodeRequest({ + required this.existingNodeId, + required this.newNode, }); - final DocumentSelection documentSelection; + final String existingNodeId; + final DocumentNode newNode; +} + +class ReplaceNodeCommand extends EditCommand { + ReplaceNodeCommand({ + required this.existingNodeId, + required this.newNode, + }); + + final String existingNodeId; + final DocumentNode newNode; @override - void execute(Document document, DocumentEditorTransaction transaction) { - _log.log('DeleteSelectionCommand', 'DocumentEditor: deleting selection: $documentSelection'); - final nodes = document.getNodesInside(documentSelection.base, documentSelection.extent); + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final oldNode = document.getNodeById(existingNodeId)!; + document.replaceNodeById(oldNode.id, newNode); + + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(existingNodeId, oldNode), + ), + DocumentEdit( + NodeInsertedEvent(newNode.id, document.getNodeIndexById(newNode.id)), + ), + ]); + } +} + +class ReplaceNodeWithEmptyParagraphWithCaretRequest implements EditRequest { + const ReplaceNodeWithEmptyParagraphWithCaretRequest({ + required this.nodeId, + }); + + final String nodeId; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ReplaceNodeWithEmptyParagraphWithCaretRequest && + runtimeType == other.runtimeType && + nodeId == other.nodeId; + + @override + int get hashCode => nodeId.hashCode; +} + +class ReplaceNodeWithEmptyParagraphWithCaretCommand extends EditCommand { + ReplaceNodeWithEmptyParagraphWithCaretCommand({ + required this.nodeId, + }); + + final String nodeId; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + final oldNode = document.getNodeById(nodeId); + if (oldNode == null) { + return; + } + + final newNode = ParagraphNode( + id: oldNode.id, + text: AttributedText(), + ); + document.replaceNodeById(oldNode.id, newNode); + + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(oldNode.id, oldNode), + ), + DocumentEdit( + NodeInsertedEvent(newNode.id, document.getNodeIndexById(newNode.id)), + ), + ]); + + executor + ..executeCommand(ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNode.id, + nodePosition: newNode.beginningPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + notifyListeners: false, + )) + // The content changed, and selection moved, so the previous composing region + // no longer applies, and might even be invalid. Clear it. + ..executeCommand(ChangeComposingRegionCommand(null)); + } +} + +class DeleteContentRequest implements EditRequest { + DeleteContentRequest({ + required this.documentRange, + }); + + final DocumentRange documentRange; +} + +class DeleteContentCommand extends EditCommand { + DeleteContentCommand({ + required this.documentRange, + }); + + final DocumentRange documentRange; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + String describe() => "Delete content within range: $documentRange"; + + @override + void execute(EditContext context, CommandExecutor executor) { + _log.log('DeleteSelectionCommand', 'DocumentEditor: deleting selection: $documentRange'); + final document = context.document; + final selection = context.composer.selection; + final nodes = document.getNodesInside(documentRange.start, documentRange.end); + final normalizedRange = documentRange.normalize(document); if (nodes.length == 1) { // This is a selection within a single node. - _deleteSelectionWithinSingleNode( + + if (!nodes.first.isDeletable) { + // The node is not deletable. Abort the deletion. + if (nodes.first is BlockNode && selection?.isCollapsed == false) { + // On iOS, pressing backspace generates a non-text delta expanding the selection + // prior to its deletion. Since we can't delete the block, we'll just collapse the + // selection to the end of the block. + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodes.first.id, + nodePosition: nodes.first.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ); + } + return; + } + final changeList = _deleteSelectionWithinSingleNode( document: document, - documentSelection: documentSelection, - transaction: transaction, + normalizedRange: normalizedRange, node: nodes.first, ); - // Done handling single-node selection deletion. + executor.logChanges(changeList); + return; } - final range = document.getRangeBetween(documentSelection.base, documentSelection.extent); - - final startNode = document.getNode(range.start); - final baseNode = document.getNode(documentSelection.base); + final startNode = document.getNode(normalizedRange.start); if (startNode == null) { - throw Exception('Could not locate start node for DeleteSelectionCommand: ${range.start}'); + throw Exception('Could not locate start node for DeleteSelectionCommand: ${normalizedRange.start}'); } - final startNodePosition = startNode.id == documentSelection.base.nodeId - ? documentSelection.base.nodePosition - : documentSelection.extent.nodePosition; final startNodeIndex = document.getNodeIndexById(startNode.id); - final endNode = document.getNode(range.end); + final endNode = document.getNode(normalizedRange.end); if (endNode == null) { - throw Exception('Could not locate end node for DeleteSelectionCommand: ${range.end}'); + throw Exception('Could not locate end node for DeleteSelectionCommand: ${normalizedRange.end}'); } - final endNodePosition = startNode.id == documentSelection.base.nodeId - ? documentSelection.extent.nodePosition - : documentSelection.base.nodePosition; - final endNodeIndex = document.getNodeIndexById(endNode.id); - _deleteNodesBetweenFirstAndLast( - document: document, - startNode: startNode, - endNode: endNode, - transaction: transaction, - ); + // We expect that this command will only be called when the delete range + // contains at least one deletable node. + final firstDeletableNodeId = nodes.firstWhere((node) => node.isDeletable).id; - _log.log('DeleteSelectionCommand', ' - deleting partial selection within the starting node.'); - _deleteSelectionWithinNodeFromPositionToEnd( - document: document, - node: startNode, - nodePosition: startNodePosition, - transaction: transaction, - replaceWithParagraph: false, + executor.logChanges( + _deleteNodesBetweenFirstAndLast( + document: document, + startNode: startNode, + endNode: endNode, + ), ); - _log.log('DeleteSelectionCommand', ' - deleting partial selection within ending node.'); - _deleteSelectionWithinNodeFromStartToPosition( - document: document, - node: endNode, - nodePosition: endNodePosition, - transaction: transaction, + if (startNode.isDeletable) { + _log.log('DeleteSelectionCommand', ' - deleting partial selection within the starting node.'); + executor.logChanges( + _deleteRangeWithinNodeFromPositionToEnd( + document: document, + node: startNode, + nodePosition: normalizedRange.start.nodePosition, + replaceWithParagraph: false, + ), + ); + } + + if (endNode.isDeletable) { + _log.log('DeleteSelectionCommand', ' - deleting partial selection within ending node.'); + executor.logChanges( + _deleteRangeWithinNodeFromStartToPosition( + document: document, + node: endNode, + nodePosition: normalizedRange.end.nodePosition, + ), + ); + } + + final wereAllDeletableNodesInRangeDeleted = nodes.every( + (node) => document.getNodeById(node.id) == null || !node.isDeletable, ); + final hasNonDeletableNodesInRange = nodes.any((node) => !node.isDeletable); // If all selected nodes were deleted, e.g., the user selected from // the beginning of the first node to the end of the last node, then // we need insert an empty paragraph node so that there's a place // to position the caret. - if (document.getNodeById(startNode.id) == null && document.getNodeById(endNode.id) == null) { - final insertIndex = min(startNodeIndex, endNodeIndex); - transaction.insertNodeAt( + if (wereAllDeletableNodesInRangeDeleted) { + // If there are any non-deletable nodes in the range, insert the new node + // after the last non-deletable node. Otherwise, insert the new node at + // the position where the first selected node was. + final insertIndex = hasNonDeletableNodesInRange // + ? document.getNodeIndexById(nodes.lastWhere((node) => !node.isDeletable).id) + 1 + : startNodeIndex; + + // If one of the edge nodes is deletable, we can use it as the ID for the + // new empty paragraph. Otherwise, use the ID of the first deletable node in the range. + // We expect that this method is never called when there are no deletable nodes + // in the range. + final emptyParagraphId = startNode.isDeletable + ? startNode.id + : endNode.isDeletable + ? endNode.id + : firstDeletableNodeId; + + document.insertNodeAt( insertIndex, - ParagraphNode(id: baseNode!.id, text: AttributedText()), + ParagraphNode(id: emptyParagraphId, text: AttributedText()), ); - return; + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(emptyParagraphId), + ) + ]); } // The start/end nodes may have been deleted due to empty content. @@ -106,155 +920,262 @@ class DeleteSelectionCommand implements EditorCommand { // then we need to consider merging them if one or both are // empty. if (startNodeAfterDeletion is! TextNode || endNodeAfterDeletion is! TextNode) { + // Neither of the end nodes are `TextNode`s, so there's nothing + // for us to merge. We're done. return; } _log.log('DeleteSelectionCommand', ' - combining last node text with first node text'); - startNodeAfterDeletion.text = startNodeAfterDeletion.text.copyAndAppend(endNodeAfterDeletion.text); + executor.logChanges([ + DocumentEdit( + TextInsertionEvent( + nodeId: startNodeAfterDeletion.id, + offset: startNodeAfterDeletion.text.length, + text: endNodeAfterDeletion.text, + ), + ), + ]); - _log.log('DeleteSelectionCommand', ' - deleting last node'); - transaction.deleteNode(endNodeAfterDeletion); + document.replaceNodeById( + startNodeAfterDeletion.id, + startNodeAfterDeletion.copyTextNodeWith( + text: startNodeAfterDeletion.text.copyAndAppend(endNodeAfterDeletion.text), + ), + ); + _log.log('DeleteSelectionCommand', ' - deleting last node'); + document.deleteNode(endNodeAfterDeletion.id); + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(endNodeAfterDeletion.id, endNodeAfterDeletion), + ) + ]); _log.log('DeleteSelectionCommand', ' - done with selection deletion'); } - void _deleteSelectionWithinSingleNode({ - required Document document, - required DocumentSelection documentSelection, - required DocumentEditorTransaction transaction, + List _deleteSelectionWithinSingleNode({ + required MutableDocument document, + required DocumentRange normalizedRange, required DocumentNode node, }) { _log.log('_deleteSelectionWithinSingleNode', ' - deleting selection within single node'); - final basePosition = documentSelection.base.nodePosition; - final extentPosition = documentSelection.extent.nodePosition; + final startPosition = normalizedRange.start.nodePosition; + final endPosition = normalizedRange.end.nodePosition; - if (basePosition is UpstreamDownstreamNodePosition) { - if (basePosition == extentPosition) { + if (startPosition is UpstreamDownstreamNodePosition) { + if (startPosition == endPosition) { // The selection is collapsed. Nothing to delete. - return; + return []; } - // The selection is expanded within a block-level node. The only + // The range is expanded within a block-level node. The only // possibility is that the entire node is selected. Delete the node // and replace it with an empty paragraph. - transaction.replaceNode( - oldNode: node, - newNode: ParagraphNode(id: node.id, text: AttributedText()), + document.replaceNodeById( + node.id, + ParagraphNode(id: node.id, text: AttributedText()), ); + + return [ + DocumentEdit( + NodeChangeEvent(node.id), + ) + ]; } else if (node is TextNode) { _log.log('_deleteSelectionWithinSingleNode', ' - its a TextNode'); - final baseOffset = (basePosition as TextPosition).offset; - final extentOffset = (extentPosition as TextPosition).offset; - final startOffset = baseOffset < extentOffset ? baseOffset : extentOffset; - final endOffset = baseOffset < extentOffset ? extentOffset : baseOffset; + final startOffset = (startPosition as TextPosition).offset; + final endOffset = (endPosition as TextPosition).offset; _log.log('_deleteSelectionWithinSingleNode', ' - deleting from $startOffset to $endOffset'); - node.text = node.text.removeRegion( - startOffset: startOffset, - endOffset: endOffset, + final deletedText = node.text.copyText(startOffset, endOffset); + document.replaceNodeById( + node.id, + node.copyTextNodeWith( + text: node.text.removeRegion( + startOffset: startOffset, + endOffset: endOffset, + ), + ), ); + + return [ + DocumentEdit( + TextDeletedEvent( + node.id, + deletedText: deletedText, + offset: startOffset, + ), + ), + ]; } + + return []; } - void _deleteNodesBetweenFirstAndLast({ - required Document document, + List _deleteNodesBetweenFirstAndLast({ + required MutableDocument document, required DocumentNode startNode, required DocumentNode endNode, - required DocumentEditorTransaction transaction, }) { + if (startNode.id == endNode.id) { + // The start and end nodes are the same. Nothing to delete. + return []; + } + // Delete all nodes between the first node and the last node. - final startIndex = document.getNodeIndexById(startNode.id); - final endIndex = document.getNodeIndexById(endNode.id); + if (document.getAffinityBetweenNodes(startNode, endNode) != TextAffinity.downstream) { + throw Exception( + "Tried to delete the nodes between a start and end node, but the start node doesn't appear before the end node. Start: ${startNode.id}, End: ${endNode.id}.", + ); + } - _log.log('_deleteNodesBetweenFirstAndLast', ' - start node index: $startIndex'); - _log.log('_deleteNodesBetweenFirstAndLast', ' - end node index: $endIndex'); - _log.log('_deleteNodesBetweenFirstAndLast', ' - initially ${document.nodes.length} nodes'); + _log.log('_deleteNodesBetweenFirstAndLast', ' - start node: ${startNode.id}'); + _log.log('_deleteNodesBetweenFirstAndLast', ' - end node: ${endNode.id}'); + _log.log('_deleteNodesBetweenFirstAndLast', ' - initially ${document.nodeCount} nodes'); // Remove nodes from last to first so that indices don't get // screwed up during removal. - for (int i = endIndex - 1; i > startIndex; --i) { - _log.log('_deleteNodesBetweenFirstAndLast', ' - deleting node $i: ${document.getNodeAt(i)?.id}'); - transaction.deleteNodeAt(i); + final changes = []; + var nodeToDelete = document.getNodeAfter(startNode); + while (nodeToDelete != null && nodeToDelete != endNode) { + _log.log('_deleteNodesBetweenFirstAndLast', ' - deleting node: ${nodeToDelete.id}'); + final nextNode = document.getNodeAfter(nodeToDelete); + if (nodeToDelete.isDeletable) { + // This node is deletable, so delete it. + changes.add(DocumentEdit( + NodeRemovedEvent(nodeToDelete.id, nodeToDelete), + )); + document.deleteNode(nodeToDelete.id); + } + + // Move to the next node. + nodeToDelete = nextNode; } + return changes; } - void _deleteSelectionWithinNodeFromPositionToEnd({ - required Document document, + List _deleteRangeWithinNodeFromPositionToEnd({ + required MutableDocument document, required DocumentNode node, - required dynamic nodePosition, - required DocumentEditorTransaction transaction, + required NodePosition nodePosition, required bool replaceWithParagraph, }) { if (nodePosition is UpstreamDownstreamNodePosition) { if (nodePosition.affinity == TextAffinity.downstream) { // The position is already at the end of the node. Nothing to do. - return; + return []; } // The position is on the upstream side of block-level content. // Delete the whole block. - _deleteBlockLevelNode( + return _deleteBlockLevelNode( document: document, node: node, - transaction: transaction, replaceWithParagraph: replaceWithParagraph, ); } else if (nodePosition is TextPosition && node is TextNode) { if (nodePosition == node.beginningPosition) { // All text is selected. Delete the node. - transaction.deleteNode(node); + document.deleteNode(node.id); + + return [ + DocumentEdit( + NodeRemovedEvent(node.id, node), + ) + ]; } else { + final textNodePosition = nodePosition as TextNodePosition; + // Delete part of the text. - node.text = node.text.removeRegion( - startOffset: nodePosition.offset, - endOffset: node.text.text.length, + final deletedText = node.text.copyText(textNodePosition.offset); + + document.replaceNodeById( + node.id, + node.copyTextNodeWith( + text: node.text.removeRegion( + startOffset: textNodePosition.offset, + endOffset: node.text.length, + ), + ), ); + + return [ + DocumentEdit( + TextDeletedEvent( + node.id, + offset: textNodePosition.offset, + deletedText: deletedText, + ), + ) + ]; } } else { throw Exception('Unknown node position type: $nodePosition, for node: $node'); } } - void _deleteSelectionWithinNodeFromStartToPosition({ - required Document document, + List _deleteRangeWithinNodeFromStartToPosition({ + required MutableDocument document, required DocumentNode node, - required dynamic nodePosition, - required DocumentEditorTransaction transaction, + required NodePosition nodePosition, }) { if (nodePosition is UpstreamDownstreamNodePosition) { if (nodePosition.affinity == TextAffinity.upstream) { // The position is already at the beginning of the node. Nothing to do. - return; + return []; } // The position is on the downstream side of block-level content. // Delete the whole block. - _deleteBlockLevelNode( + return _deleteBlockLevelNode( document: document, node: node, - transaction: transaction, replaceWithParagraph: false, ); } else if (nodePosition is TextPosition && node is TextNode) { if (nodePosition == node.endPosition) { // All text is selected. Delete the node. - transaction.deleteNode(node); + document.deleteNode(node.id); + + return [ + DocumentEdit( + NodeRemovedEvent(node.id, node), + ) + ]; } else { + final textNodePosition = nodePosition as TextNodePosition; + // Delete part of the text. - node.text = node.text.removeRegion( - startOffset: 0, - endOffset: nodePosition.offset, + final deletedText = node.text.copyText(0, textNodePosition.offset); + + document.replaceNodeById( + node.id, + node.copyTextNodeWith( + text: node.text.removeRegion( + startOffset: 0, + endOffset: textNodePosition.offset, + ), + ), ); + + return [ + DocumentEdit( + TextDeletedEvent( + node.id, + offset: 0, + deletedText: deletedText, + ), + ), + ]; } } else { throw Exception('Unknown node position type: $nodePosition, for node: $node'); } } - void _deleteBlockLevelNode({ - required Document document, + List _deleteBlockLevelNode({ + required MutableDocument document, required DocumentNode node, - required DocumentEditorTransaction transaction, required bool replaceWithParagraph, }) { if (replaceWithParagraph) { @@ -271,15 +1192,204 @@ class DeleteSelectionCommand implements EditorCommand { _log.log('_deleteBlockNode', ' - replacing block-level node with a ParagraphNode: ${node.id}'); final newNode = ParagraphNode(id: node.id, text: AttributedText()); - transaction.replaceNode(oldNode: node, newNode: newNode); + document.replaceNodeById(node.id, newNode); + + return [ + DocumentEdit( + NodeRemovedEvent(node.id, node), + ), + DocumentEdit( + NodeInsertedEvent(newNode.id, document.getNodeIndexById(newNode.id)), + ), + ]; } else { _log.log('_deleteBlockNode', ' - deleting block level node'); - transaction.deleteNode(node); + document.deleteNode(node.id); + + return [ + DocumentEdit( + NodeRemovedEvent(node.id, node), + ) + ]; } } } -class DeleteNodeCommand implements EditorCommand { +/// Deletes the selected content within the document. +/// +/// Any selected, non-deletable nodes are retained without removal. +/// +/// The [affinity] defines the direction to where the user is trying to +/// delete. For example, if the users presses the backspace key, the +/// [affinity] should be [TextAffinity.upstream]. If the user presses the +/// delete key, the [affinity] should be [TextAffinity.downstream]. The +/// [affinity] influences the new selection after the deletion when the +/// dowstream of upstream node is non-deletable. For example, pressing +/// backspace when the upstream node is not deletable doesn't change +/// the selection, but pressing delete does. +class DeleteSelectionRequest implements EditRequest { + const DeleteSelectionRequest(this.affinity); + + final TextAffinity affinity; +} + +class DeleteSelectionCommand extends EditCommand { + DeleteSelectionCommand({ + required this.affinity, + }); + + final TextAffinity affinity; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + String describe() => "Delete selected content"; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.composer; + + final selection = composer.selection; + if (selection == null) { + return; + } + + if (selection.base.nodeId == selection.extent.nodeId) { + // The selection is contained within a single node. Prevent the deletion + // if the node is non-deletable. When there are multiple nodes selected, + // non-deletable nodes are ignored inside DeleteContentCommand. + final node = document.getNodeById(selection.base.nodeId)!; + if (!node.isDeletable) { + if (node is BlockNode && !selection.isCollapsed) { + // On iOS, pressing backspace generates a non-text delta expanding the selection + // prior to its deletion. Since we can't delete the block, we'll just collapse the + // selection to the end of the block. + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: node.id, + nodePosition: node.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ); + } + return; + } + } + + final nodes = document.getNodesInside(selection.start, selection.end); + if (nodes.every((node) => !node.isDeletable)) { + // All selected nodes are non-deletable. Do nothing. + return; + } + + if (nodes.length == 2) { + final normalizedSelection = selection.normalize(document); + final nodeAbove = document.getNode(normalizedSelection.start)!; + final nodeBelow = document.getNode(normalizedSelection.end)!; + + if (nodeAbove is BlockNode && + !nodeAbove.isDeletable && + normalizedSelection.end.nodePosition.isEquivalentTo(nodeBelow.beginningPosition)) { + // We have the following scenario, where |> and <| represent the selection: + // + // |> + // <|text + + if (affinity == TextAffinity.upstream) { + // The user is trying to delete using backspace (we assume this because the deletion is in + // upstream direction). Do nothing. + return; + } + + // The user is trying to delete using the delete key (we assume this because the deletion is in + // upstream direction). Move the selection to the node below. + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed(position: normalizedSelection.end), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ); + return; + } + + if (nodeBelow is BlockNode && + !nodeBelow.isDeletable && + normalizedSelection.start.nodePosition.isEquivalentTo(nodeAbove.endPosition)) { + // We have the following scenario, where |> and <| represent the selection: + // + // text|> + // <| + + if (affinity == TextAffinity.downstream) { + // The user is trying to delete using the delete key (we assume this because the deletion is in + // downstream direction). Do nothing. + return; + } + } + } + + final newSelectionPosition = CommonEditorOperations.getDocumentPositionAfterExpandedDeletion( + document: document, + selection: selection, + ); + + executor.executeCommand( + DeleteContentCommand( + documentRange: selection, + ), + ); + + if (newSelectionPosition != null) { + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed(position: newSelectionPosition), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ); + } + + // We expect that the selection is now collapsed, and also is probably in a different + // location. Clear the composing region. + executor.executeCommand(ChangeComposingRegionCommand(null)); + } +} + +/// Request to handle a collapsed selection upstream deletion at the +/// beginning of a [node]. +/// +/// When this request is submitted, the caret should be at the beginning of +/// the given [node]. +/// +/// This request is likely to be handled differently based on the type of +/// [node] where this upstream deletion takes place. For example, a paragraph +/// might combine with the paragraph above it. A list item might convert +/// to a regular paragraph. +class DeleteUpstreamAtBeginningOfNodeRequest implements EditRequest { + DeleteUpstreamAtBeginningOfNodeRequest(this.node); + + /// The [DocumentNode] where an upstream deletion should take + /// place at the beginning end of the node. + final DocumentNode node; +} + +class DeleteNodeRequest implements EditRequest { + DeleteNodeRequest({ + required this.nodeId, + }); + + final String nodeId; +} + +class DeleteNodeCommand extends EditCommand { DeleteNodeCommand({ required this.nodeId, }); @@ -287,9 +1397,13 @@ class DeleteNodeCommand implements EditorCommand { final String nodeId; @override - void execute(Document document, DocumentEditorTransaction transaction) { + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { _log.log('DeleteNodeCommand', 'DocumentEditor: deleting node: $nodeId'); + final document = context.document; final node = document.getNodeById(nodeId); if (node == null) { _log.log('DeleteNodeCommand', 'No such node. Returning.'); @@ -297,8 +1411,119 @@ class DeleteNodeCommand implements EditorCommand { } _log.log('DeleteNodeCommand', ' - deleting node'); - transaction.deleteNode(node); - + document.deleteNode(node.id); _log.log('DeleteNodeCommand', ' - done with node deletion'); + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(node.id, node), + ) + ]); + } +} + +/// An [EditRequest] that replaces the current document content with the given +/// [nodes]. +/// +/// This request deletes all existing content, clears the selection, and then +/// inserts the new nodes. +class ReplaceDocumentRequest implements EditRequest { + const ReplaceDocumentRequest(this.nodes); + + final List nodes; +} + +class ReplaceDocumentCommand extends EditCommand { + const ReplaceDocumentCommand(this.nodes); + + final List nodes; + + @override + void execute(EditContext context, CommandExecutor executor) { + executor + // Clear selection before deleting content so that we don't have + // a momentarily illegal selection. + ..executeCommand( + const ChangeSelectionCommand( + null, + SelectionChangeType.alteredContent, + SelectionReason.contentChange, + ), + ) + ..executeCommand(ClearDocumentCommand()) + // Clear selection again because `ClearDocumentCommand` sets a selection. + // + // Note: `ClearDocumentCommand` also clears the composing region, which we + // want here as well. + ..executeCommand( + const ChangeSelectionCommand( + null, + SelectionChangeType.alteredContent, + SelectionReason.contentChange, + ), + ) + ..executeCommand(DeleteNodeCommand(nodeId: context.document.first.id)); + + for (final node in nodes) { + executor.executeCommand( + InsertNodeAtIndexCommand( + nodeIndex: context.document.length, + newNode: node, + ), + ); + } + } +} + +/// An [EditRequest] to clear the document's content. +/// +/// This request: +/// +/// - Removes all nodes from the document. +/// - Adds a new empty paragraph. +/// - Places the caret at the beginning of the new paragraph. +/// - Clears the composing region. +class ClearDocumentRequest implements EditRequest { + const ClearDocumentRequest(); +} + +class ClearDocumentCommand extends EditCommand { + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + for (final node in document) { + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(node.id, node), + ) + ]); + } + + document.clear(); + + final newNodeId = Editor.createNodeId(); + executor + ..executeCommand( + InsertNodeAtIndexCommand( + nodeIndex: 0, + newNode: ParagraphNode( + id: newNodeId, + text: AttributedText(), + ), + ), + ) + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ) + ..executeCommand(ChangeComposingRegionCommand(null)); } } diff --git a/super_editor/lib/src/default_editor/paragraph.dart b/super_editor/lib/src/default_editor/paragraph.dart index 6d95042e4d..ec5dce9053 100644 --- a/super_editor/lib/src/default_editor/paragraph.dart +++ b/super_editor/lib/src/default_editor/paragraph.dart @@ -1,35 +1,103 @@ import 'package:attributed_text/attributed_text.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/painting.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; -import 'package:super_editor/src/core/document_editor.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/blocks/indentation.dart'; +import 'package:super_editor/src/default_editor/box_component.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/composable_text.dart'; +import 'package:super_editor/src/infrastructure/key_event_extensions.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; -import 'package:super_editor/src/infrastructure/raw_key_event_extensions.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_text_layout/super_text_layout.dart'; -import 'document_input_keyboard.dart'; import 'layout_single_column/layout_single_column.dart'; import 'text_tools.dart'; +@immutable class ParagraphNode extends TextNode { ParagraphNode({ - required String id, - required AttributedText text, - Map? metadata, - }) : super( - id: id, - text: text, - metadata: metadata, - ) { + required super.id, + required super.text, + this.indent = 0, + super.metadata, + }) { if (getMetadataValue("blockType") == null) { - putMetadataValue("blockType", const NamedAttribution("paragraph")); + initAddToMetadata({ + "blockType": paragraphAttribution, + }); } } + + /// The indent level of this paragraph - `0` is no indent. + final int indent; + + ParagraphNode copyParagraphWith({ + String? id, + AttributedText? text, + int? indent, + Map? metadata, + }) { + return ParagraphNode( + id: id ?? this.id, + text: text ?? this.text, + indent: indent ?? this.indent, + metadata: metadata ?? this.metadata, + ); + } + + @override + ParagraphNode copyTextNodeWith({ + String? id, + AttributedText? text, + Map? metadata, + }) { + return copyParagraphWith( + id: id, + text: text, + metadata: metadata, + ); + } + + @override + ParagraphNode copyAndReplaceMetadata(Map newMetadata) { + return copyParagraphWith( + metadata: newMetadata, + ); + } + + @override + ParagraphNode copyWithAddedMetadata(Map newProperties) { + return copyParagraphWith( + metadata: { + ...metadata, + ...newProperties, + }, + ); + } + + @override + ParagraphNode copy() { + return ParagraphNode(id: id, text: text.copyText(0), metadata: Map.from(metadata)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && other is ParagraphNode && runtimeType == other.runtimeType && indent == other.indent; + + @override + int get hashCode => super.hashCode ^ indent.hashCode; } class ParagraphComponentBuilder implements ComponentBuilder { @@ -41,7 +109,7 @@ class ParagraphComponentBuilder implements ComponentBuilder { return null; } - final textDirection = getParagraphDirection(node.text.text); + final textDirection = getParagraphDirection(node.text.toPlainText()); TextAlign textAlign = (textDirection == TextDirection.ltr) ? TextAlign.left : TextAlign.right; final textAlignName = node.getMetadataValue('textAlign'); @@ -62,7 +130,10 @@ class ParagraphComponentBuilder implements ComponentBuilder { return ParagraphComponentViewModel( nodeId: node.id, - blockType: node.getMetadataValue('blockType'), + createdAt: node.metadata[NodeMetadata.createdAt], + blockType: node.getMetadataValue(NodeMetadata.blockType), + indent: node.indent, + indentCalculator: defaultParagraphIndentCalculator, text: node.text, textStyleBuilder: noStyleBuilder, textDirection: textDirection, @@ -72,7 +143,7 @@ class ParagraphComponentBuilder implements ComponentBuilder { } @override - TextComponent? createComponent( + Widget? createComponent( SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { if (componentViewModel is! ParagraphComponentViewModel) { return null; @@ -88,47 +159,79 @@ class ParagraphComponentBuilder implements ComponentBuilder { editorLayoutLog.finer(' - not painting any text selection'); } - return TextComponent( + return ParagraphComponent( key: componentContext.componentKey, - text: componentViewModel.text, - textStyleBuilder: componentViewModel.textStyleBuilder, - metadata: componentViewModel.blockType != null - ? { - 'blockType': componentViewModel.blockType, - } - : {}, - textAlign: componentViewModel.textAlignment, - textDirection: componentViewModel.textDirection, - textSelection: componentViewModel.selection, - selectionColor: componentViewModel.selectionColor, - highlightWhenEmpty: componentViewModel.highlightWhenEmpty, + viewModel: componentViewModel, ); } } class ParagraphComponentViewModel extends SingleColumnLayoutComponentViewModel with TextComponentViewModel { ParagraphComponentViewModel({ - required String nodeId, - double? maxWidth, - EdgeInsetsGeometry padding = EdgeInsets.zero, + required super.nodeId, + super.createdAt, + super.maxWidth, + super.padding = EdgeInsets.zero, + super.opacity = 1.0, this.blockType, + this.indent = 0, + this.indentCalculator = defaultParagraphIndentCalculator, required this.text, required this.textStyleBuilder, + this.inlineWidgetBuilders = const [], this.textDirection = TextDirection.ltr, this.textAlignment = TextAlign.left, + this.textScaler, + this.maxLines, + this.overflow = TextOverflow.clip, this.selection, required this.selectionColor, this.highlightWhenEmpty = false, - }) : super(nodeId: nodeId, maxWidth: maxWidth, padding: padding); + Set customUnderlines = const {}, + TextRange? composingRegion, + bool showComposingRegionUnderline = false, + UnderlineStyle spellingErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.red), + List spellingErrors = const [], + UnderlineStyle grammarErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.blue), + List grammarErrors = const [], + }) { + this.customUnderlines = customUnderlines; + + this.composingRegion = composingRegion; + this.showComposingRegionUnderline = showComposingRegionUnderline; + + this.spellingErrorUnderlineStyle = spellingErrorUnderlineStyle; + this.spellingErrors = spellingErrors; + + this.grammarErrorUnderlineStyle = grammarErrorUnderlineStyle; + this.grammarErrors = grammarErrors; + } Attribution? blockType; + + int indent; + TextBlockIndentCalculator indentCalculator; + + @override AttributedText text; @override AttributionStyleBuilder textStyleBuilder; @override + InlineWidgetBuilderChain inlineWidgetBuilders; + @override TextDirection textDirection; @override TextAlign textAlignment; + @override + int? maxLines; + @override + TextOverflow overflow; + + /// The text scaling policy. + /// + /// Defaults to `MediaQuery.textScalerOf()`. + TextScaler? textScaler; + @override TextSelection? selection; @override @@ -138,19 +241,32 @@ class ParagraphComponentViewModel extends SingleColumnLayoutComponentViewModel w @override ParagraphComponentViewModel copy() { - return ParagraphComponentViewModel( - nodeId: nodeId, - maxWidth: maxWidth, - padding: padding, - blockType: blockType, - text: text, - textStyleBuilder: textStyleBuilder, - textDirection: textDirection, - textAlignment: textAlignment, - selection: selection, - selectionColor: selectionColor, - highlightWhenEmpty: highlightWhenEmpty, + final copy = internalCopy( + ParagraphComponentViewModel( + nodeId: nodeId, + createdAt: createdAt, + // FIXME: Do we need to send in the `text`? Isn't the superclass already doing it? + text: text.copy(), + textStyleBuilder: textStyleBuilder, + opacity: opacity, + selectionColor: selectionColor, + ), ); + + return copy; + } + + @override + ParagraphComponentViewModel internalCopy(ParagraphComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as ParagraphComponentViewModel; + + copy + ..blockType = blockType + ..indent = indent + ..indentCalculator = indentCalculator + ..textScaler = textScaler; + + return copy; } @override @@ -159,26 +275,420 @@ class ParagraphComponentViewModel extends SingleColumnLayoutComponentViewModel w super == other && other is ParagraphComponentViewModel && runtimeType == other.runtimeType && - nodeId == other.nodeId && + textViewModelEquals(other) && blockType == other.blockType && - text == other.text && - textDirection == other.textDirection && - textAlignment == other.textAlignment && - selection == other.selection && - selectionColor == other.selectionColor && - highlightWhenEmpty == other.highlightWhenEmpty; + indent == other.indent && + textScaler == other.textScaler; @override int get hashCode => - super.hashCode ^ - nodeId.hashCode ^ - blockType.hashCode ^ - text.hashCode ^ - textDirection.hashCode ^ - textAlignment.hashCode ^ - selection.hashCode ^ - selectionColor.hashCode ^ - highlightWhenEmpty.hashCode; + super.hashCode ^ textViewModelHashCode ^ blockType.hashCode ^ indent.hashCode ^ textScaler.hashCode; +} + +/// A [ComponentBuilder] for rendering hint text in the first node of a document, +/// when its an empty text node. +class HintComponentBuilder extends ParagraphComponentBuilder { + const HintComponentBuilder( + this.hint, + this.hintStyleBuilder, + ); + + final String hint; + final TextStyle Function(BuildContext) hintStyleBuilder; + + @override + SingleColumnLayoutComponentViewModel? createViewModel( + Document document, + DocumentNode node, + ) { + if (node is! ParagraphNode) { + return null; + } + + final nodeIndex = document.getNodeIndexById( + node.id, + ); + + if (nodeIndex > 0) { + // This isn't the first node, we don't ever want to show hint text. + return null; + } + + if (document.length > 1) { + // There are more than one nodes in the document, we don't want to show + // hint text. + return null; + } + + return HintComponentViewModel.fromParagraphViewModel( + super.createViewModel(document, node)! as ParagraphComponentViewModel, + hintText: hint, + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, + SingleColumnLayoutComponentViewModel componentViewModel, + ) { + if (componentViewModel is! HintComponentViewModel) { + return null; + } + + return TextWithHintComponent( + key: componentContext.componentKey, + text: componentViewModel.text, + inlineWidgetBuilders: componentViewModel.inlineWidgetBuilders, + textStyleBuilder: componentViewModel.textStyleBuilder, + hintText: AttributedText(componentViewModel.hintText), + hintStyleBuilder: (attributions) => hintStyleBuilder(componentContext.context), + textSelection: componentViewModel.selection, + selectionColor: componentViewModel.selectionColor, + underlines: componentViewModel.createUnderlines(), + metadata: { + if (componentViewModel.blockType != null) // + 'blockType': componentViewModel.blockType, + }, + ); + } +} + +class HintComponentViewModel extends SingleColumnLayoutComponentViewModel with TextComponentViewModel { + factory HintComponentViewModel.fromParagraphViewModel( + ParagraphComponentViewModel viewModel, { + required String hintText, + }) { + return HintComponentViewModel( + nodeId: viewModel.nodeId, + createdAt: viewModel.createdAt, + maxWidth: viewModel.maxWidth, + padding: viewModel.padding, + opacity: viewModel.opacity, + blockType: viewModel.blockType, + text: viewModel.text, + hintText: hintText, + inlineWidgetBuilders: viewModel.inlineWidgetBuilders, + textAlignment: viewModel.textAlignment, + textDirection: viewModel.textDirection, + maxLines: viewModel.maxLines, + overflow: viewModel.overflow, + textStyleBuilder: viewModel.textStyleBuilder, + selectionColor: viewModel.selectionColor, + indent: viewModel.indent, + selection: viewModel.selection, + highlightWhenEmpty: viewModel.highlightWhenEmpty, + ); + } + + HintComponentViewModel({ + required super.nodeId, + required super.createdAt, + super.maxWidth, + required super.padding, + super.opacity = 1.0, + this.blockType, + required this.text, + required this.hintText, + this.inlineWidgetBuilders = const [], + this.textAlignment = TextAlign.left, + this.textDirection = TextDirection.ltr, + this.maxLines, + this.overflow = TextOverflow.clip, + required this.textStyleBuilder, + required this.selectionColor, + this.indent = 0, + this.selection, + this.highlightWhenEmpty = false, + }); + + String hintText; + + Attribution? blockType; + + @override + AttributedText text; + @override + AttributionStyleBuilder textStyleBuilder; + @override + InlineWidgetBuilderChain inlineWidgetBuilders; + @override + TextDirection textDirection; + @override + TextAlign textAlignment; + @override + int? maxLines; + @override + TextOverflow overflow; + int indent; + @override + TextSelection? selection; + @override + Color selectionColor; + @override + bool highlightWhenEmpty; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + + @override + HintComponentViewModel copy() { + return internalCopy( + HintComponentViewModel( + nodeId: nodeId, + createdAt: createdAt, + padding: padding, + text: text.copy(), + inlineWidgetBuilders: inlineWidgetBuilders, + textStyleBuilder: textStyleBuilder, + opacity: opacity, + selectionColor: selectionColor, + hintText: hintText, + ), + ); + } + + @override + HintComponentViewModel internalCopy(HintComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as HintComponentViewModel; + + copy + ..blockType = blockType + ..indent = indent + ..hintText = hintText; + + return copy; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is HintComponentViewModel && + runtimeType == other.runtimeType && + textViewModelEquals(other) && + blockType == other.blockType && + indent == other.indent && + hintText == hintText; + + @override + int get hashCode => super.hashCode ^ textViewModelHashCode ^ blockType.hashCode ^ indent.hashCode ^ hintText.hashCode; +} + +/// The standard [TextBlockIndentCalculator] used by paragraphs in `SuperEditor`. +double defaultParagraphIndentCalculator(TextStyle textStyle, int indent) { + return ((textStyle.fontSize ?? 16) * 0.60) * 4 * indent; +} + +/// A document component that displays a paragraph. +class ParagraphComponent extends StatefulWidget { + const ParagraphComponent({ + Key? key, + required this.viewModel, + this.showDebugPaint = false, + }) : super(key: key); + + final ParagraphComponentViewModel viewModel; + final bool showDebugPaint; + + @override + State createState() => _ParagraphComponentState(); +} + +class _ParagraphComponentState extends State + with ProxyDocumentComponent, ProxyTextComposable { + final _textKey = GlobalKey(); + + @override + GlobalKey> get childDocumentComponentKey => _textKey; + + @override + TextComposable get childTextComposable => childDocumentComponentKey.currentState as TextComposable; + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: widget.viewModel.textDirection, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Indent spacing on left. + SizedBox( + width: widget.viewModel.indentCalculator( + widget.viewModel.textStyleBuilder({}), + widget.viewModel.indent, + ), + ), + // The actual paragraph UI. + Expanded( + child: TextComponent( + key: _textKey, + text: widget.viewModel.text, + textDirection: widget.viewModel.textDirection, + textAlign: widget.viewModel.textAlignment, + textScaler: widget.viewModel.textScaler, + maxLines: widget.viewModel.maxLines, + overflow: widget.viewModel.overflow, + textStyleBuilder: widget.viewModel.textStyleBuilder, + inlineWidgetBuilders: widget.viewModel.inlineWidgetBuilders, + metadata: widget.viewModel.blockType != null + ? { + 'blockType': widget.viewModel.blockType, + } + : {}, + textSelection: widget.viewModel.selection, + selectionColor: widget.viewModel.selectionColor, + highlightWhenEmpty: widget.viewModel.highlightWhenEmpty, + underlines: widget.viewModel.createUnderlines(), + showDebugPaint: widget.showDebugPaint, + ), + ), + ], + ), + ); + } +} + +class ChangeParagraphAlignmentRequest implements EditRequest { + ChangeParagraphAlignmentRequest({ + required this.nodeId, + required this.alignment, + }); + + final String nodeId; + final TextAlign alignment; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ChangeParagraphAlignmentRequest && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + alignment == other.alignment; + + @override + int get hashCode => nodeId.hashCode ^ alignment.hashCode; +} + +class ChangeParagraphAlignmentCommand extends EditCommand { + const ChangeParagraphAlignmentCommand({ + required this.nodeId, + required this.alignment, + }); + + final String nodeId; + final TextAlign alignment; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + final existingNode = document.getNodeById(nodeId)! as ParagraphNode; + + String? alignmentName; + switch (alignment) { + case TextAlign.left: + case TextAlign.start: + alignmentName = 'left'; + break; + case TextAlign.center: + alignmentName = 'center'; + break; + case TextAlign.right: + case TextAlign.end: + alignmentName = 'right'; + break; + case TextAlign.justify: + alignmentName = 'justify'; + break; + } + + document.replaceNodeById( + existingNode.id, + existingNode.copyParagraphWith( + metadata: { + ...existingNode.metadata, + "textAlign": alignmentName, + }, + ), + ); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(nodeId), + ), + ]); + } +} + +class ChangeParagraphBlockTypeRequest implements EditRequest { + ChangeParagraphBlockTypeRequest({ + required this.nodeId, + required this.blockType, + }); + + final String nodeId; + final Attribution? blockType; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ChangeParagraphBlockTypeRequest && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + blockType == other.blockType; + + @override + int get hashCode => nodeId.hashCode ^ blockType.hashCode; +} + +class ChangeParagraphBlockTypeCommand extends EditCommand { + const ChangeParagraphBlockTypeCommand({ + required this.nodeId, + required this.blockType, + }); + + final String nodeId; + final Attribution? blockType; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + final existingNode = document.getNodeById(nodeId)! as ParagraphNode; + document.replaceNodeById( + existingNode.id, + existingNode.copyParagraphWith( + metadata: { + ...existingNode.metadata, + "blockType": blockType, + }, + ), + ); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(nodeId), + ), + ]); + } +} + +/// [EditRequest] to combine the [ParagraphNode] with [firstNodeId] with the [ParagraphNode] after it, which +/// should have the [secondNodeId]. +class CombineParagraphsRequest implements EditRequest { + CombineParagraphsRequest({ + required this.firstNodeId, + required this.secondNodeId, + }) : assert(firstNodeId != secondNodeId); + + final String firstNodeId; + final String secondNodeId; } /// Combines two consecutive `ParagraphNode`s, indicated by `firstNodeId` @@ -188,7 +698,7 @@ class ParagraphComponentViewModel extends SingleColumnLayoutComponentViewModel w /// in reverse order, the command fizzles. /// /// If both nodes are not `ParagraphNode`s, the command fizzles. -class CombineParagraphsCommand implements EditorCommand { +class CombineParagraphsCommand extends EditCommand { CombineParagraphsCommand({ required this.firstNodeId, required this.secondNodeId, @@ -198,21 +708,42 @@ class CombineParagraphsCommand implements EditorCommand { final String secondNodeId; @override - void execute(Document document, DocumentEditorTransaction transaction) { + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { editorDocLog.info('Executing CombineParagraphsCommand'); editorDocLog.info(' - merging "$firstNodeId" <- "$secondNodeId"'); + final document = context.document; final secondNode = document.getNodeById(secondNodeId); if (secondNode is! TextNode) { editorDocLog.info('WARNING: Cannot merge node of type: $secondNode into node above.'); return; } - final nodeAbove = document.getNodeBefore(secondNode); + DocumentNode? nodeAbove = document.getNodeBefore(secondNode); if (nodeAbove == null) { editorDocLog.info('At top of document. Cannot merge with node above.'); return; } - if (nodeAbove.id != firstNodeId) { + + // Search for a node above the second node that has the id equal to `firstNodeId`. + // + // A `CombineParagraphsRequest` might reference nodes that are not contiguous. + // For example, we might have: + // - Paragraph 1 + // -
(non-selectable, non-deletable) + // - Paragraph 2 + // + // If this case, it's possible to combine Paragraph 1 and Paragraph 2. + // + // Because of this, we need to loop until we find the node instead of just + // comparing with the node immediately above the second node. + while (nodeAbove != null && nodeAbove.id != firstNodeId) { + nodeAbove = document.getNodeBefore(nodeAbove); + } + + if (nodeAbove == null) { editorDocLog.info('The specified `firstNodeId` is not the node before `secondNodeId`.'); return; } @@ -222,41 +753,111 @@ class CombineParagraphsCommand implements EditorCommand { } // Combine the text and delete the currently selected node. - final isTopNodeEmpty = nodeAbove.text.text.isEmpty; - nodeAbove.text = nodeAbove.text.copyAndAppend(secondNode.text); - if (isTopNodeEmpty) { + final isTopNodeEmpty = nodeAbove.text.isEmpty; + + // Avoid overriding the metadata when the nodeAbove isn't a ParagraphNode. + // + // If we are combining different kinds of nodes, e.g., a list item and a paragraph, + // overriding the metadata will cause the nodeAbove to end up with an incorrect blockType. + // This will cause incorrect styles to be applied. + if (isTopNodeEmpty && nodeAbove is ParagraphNode) { // If the top node was empty, we want to retain everything in the // bottom node, including the block attribution and styles. - nodeAbove.metadata = secondNode.metadata; + document.replaceNodeById( + nodeAbove.id, + nodeAbove.copyTextNodeWith( + text: nodeAbove.text.copyAndAppend(secondNode.text), + metadata: secondNode.metadata, + ), + ); + } else { + document.replaceNodeById( + nodeAbove.id, + nodeAbove.copyTextNodeWith( + text: nodeAbove.text.copyAndAppend(secondNode.text), + ), + ); } - bool didRemove = transaction.deleteNode(secondNode); + + bool didRemove = document.deleteNode(secondNode.id); if (!didRemove) { editorDocLog.info('ERROR: Failed to delete the currently selected node from the document.'); } + + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(secondNode.id, secondNode), + ), + DocumentEdit( + NodeChangeEvent(nodeAbove.id), + ), + ]); } } +class SplitParagraphRequest implements EditRequest { + SplitParagraphRequest({ + required this.nodeId, + required this.splitPosition, + required this.newNodeId, + required this.replicateExistingMetadata, + this.attributionsToExtendToNewParagraph = defaultAttributionsToExtendToNewParagraph, + }); + + final String nodeId; + final TextPosition splitPosition; + final String newNodeId; + final bool replicateExistingMetadata; + // TODO: remove the attribution filter and move the decision to an EditReaction in #1296 + final AttributionFilter attributionsToExtendToNewParagraph; +} + +/// The default [Attribution]s, which will be carried over from the end of a paragraph +/// to the beginning of a new paragraph, when splitting a paragraph at the very end. +/// +/// In practice, this means that when a user places the caret at the end of paragraph +/// and presses ENTER, these [Attribution]s will be applied to the beginning of the +/// new paragraph. +// TODO: remove the attribution filter and move the decision to an EditReaction in #1296 +bool defaultAttributionsToExtendToNewParagraph(Attribution attribution) { + return _defaultAttributionsToExtend.contains(attribution); +} + +final _defaultAttributionsToExtend = { + boldAttribution, + italicsAttribution, + underlineAttribution, + strikethroughAttribution, +}; + /// Splits the `ParagraphNode` affiliated with the given `nodeId` at the /// given `splitPosition`, placing all text after `splitPosition` in a /// new `ParagraphNode` with the given `newNodeId`, inserted after the /// original node. -class SplitParagraphCommand implements EditorCommand { +class SplitParagraphCommand extends EditCommand { SplitParagraphCommand({ required this.nodeId, required this.splitPosition, required this.newNodeId, required this.replicateExistingMetadata, + this.attributionsToExtendToNewParagraph = defaultAttributionsToExtendToNewParagraph, }); final String nodeId; final TextPosition splitPosition; final String newNodeId; final bool replicateExistingMetadata; + // TODO: remove the attribution filter and move the decision to an EditReaction in #1296 + final AttributionFilter attributionsToExtendToNewParagraph; @override - void execute(Document document, DocumentEditorTransaction transaction) { + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { editorDocLog.info('Executing SplitParagraphCommand'); + final document = context.document; final node = document.getNodeById(nodeId); if (node is! ParagraphNode) { editorDocLog.info('WARNING: Cannot split paragraph for node of type: $node.'); @@ -267,12 +868,39 @@ class SplitParagraphCommand implements EditorCommand { final startText = text.copyText(0, splitPosition.offset); final endText = text.copyText(splitPosition.offset); editorDocLog.info('Splitting paragraph:'); - editorDocLog.info(' - start text: "${startText.text}"'); - editorDocLog.info(' - end text: "${endText.text}"'); + editorDocLog.info(' - start text: "${startText.toPlainText()}"'); + editorDocLog.info(' - end text: "${endText.toPlainText()}"'); + + if (splitPosition.offset == text.length) { + // The paragraph was split at the very end, the user is creating a new, + // empty paragraph. We should only extend desired attributions from the end + // of one paragraph, to the beginning of a new paragraph. + final newParagraphAttributions = endText.getAttributionSpansInRange( + attributionFilter: (a) => true, + range: const SpanRange(0, 0), + ); + for (final attributionRange in newParagraphAttributions) { + if (attributionsToExtendToNewParagraph(attributionRange.attribution)) { + // This is an attribution that should continue into a new paragraph. + // Letting it stay. + continue; + } + + // This attribution shouldn't extend from one paragraph to another. Remove it. + endText.removeAttribution( + attributionRange.attribution, + attributionRange.range, + ); + } + } // Change the current nodes content to just the text before the caret. editorDocLog.info(' - changing the original paragraph text due to split'); - node.text = startText; + final updatedNode = node.copyParagraphWith(text: startText); + document.replaceNodeById( + node.id, + updatedNode, + ); // Create a new node that will follow the current node. Set its text // to the text that was removed from the current node. And create a @@ -280,23 +908,252 @@ class SplitParagraphCommand implements EditorCommand { final newNode = ParagraphNode( id: newNodeId, text: endText, + indent: node.indent, metadata: replicateExistingMetadata ? node.copyMetadata() : {}, ); // Insert the new node after the current node. editorDocLog.info(' - inserting new node in document'); - transaction.insertNodeAfter( - existingNode: node, + document.insertNodeAfter( + existingNodeId: updatedNode.id, newNode: newNode, ); editorDocLog.info(' - inserted new node: ${newNode.id} after old one: ${node.id}'); + + // Move the caret to the new node. + final composer = context.find(Editor.composerKey); + final oldSelection = composer.selection; + final oldComposingRegion = composer.composingRegion.value; + final newSelection = DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ); + + composer.setSelectionWithReason(newSelection, SelectionReason.userInteraction); + composer.setComposingRegion(null); + + final documentChanges = [ + DocumentEdit( + NodeChangeEvent(node.id), + ), + DocumentEdit( + NodeInsertedEvent(newNodeId, document.getNodeIndexById(newNodeId)), + ), + SelectionChangeEvent( + oldSelection: oldSelection, + newSelection: newSelection, + changeType: SelectionChangeType.insertContent, + reason: SelectionReason.userInteraction, + ), + ComposingRegionChangeEvent( + oldComposingRegion: oldComposingRegion, + newComposingRegion: null, + ), + ]; + + if (newNode.text.isEmpty) { + executor.logChanges([ + SubmitParagraphIntention.start(), + ...documentChanges, + SubmitParagraphIntention.end(), + ]); + } else { + executor.logChanges([ + SplitParagraphIntention.start(), + ...documentChanges, + SplitParagraphIntention.end(), + ]); + } } } +class DeleteUpstreamAtBeginningOfParagraphCommand extends EditCommand { + DeleteUpstreamAtBeginningOfParagraphCommand(this.node); + + final DocumentNode node; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + if (node is! ParagraphNode) { + return; + } + + final deletionPosition = DocumentPosition(nodeId: node.id, nodePosition: node.beginningPosition); + if (deletionPosition.nodePosition is! TextNodePosition) { + return; + } + + final document = context.document; + final composer = context.find(Editor.composerKey); + final documentLayoutEditable = context.find(Editor.layoutKey); + + final paragraphNode = node as ParagraphNode; + if (paragraphNode.metadata["blockType"] != paragraphAttribution) { + executor.executeCommand( + ChangeParagraphBlockTypeCommand( + nodeId: node.id, + blockType: paragraphAttribution, + ), + ); + return; + } + + DocumentNode? nodeBefore = document.getNodeBefore(node); + while (nodeBefore is BlockNode && !nodeBefore.isDeletable) { + nodeBefore = document.getNodeBefore(nodeBefore); + } + + if (nodeBefore == null) { + return; + } + + if (nodeBefore is TextNode) { + // The caret is at the beginning of one TextNode and is preceded by + // another TextNode. Merge the two TextNodes. + mergeTextNodeWithUpstreamTextNode(executor, document, composer); + return; + } + + final componentBefore = documentLayoutEditable.documentLayout.getComponentByNodeId(nodeBefore.id)!; + if (!componentBefore.isVisualSelectionSupported()) { + // The node/component above is not selectable. Delete it. + executor.executeCommand( + DeleteNodeCommand(nodeId: nodeBefore.id), + ); + return; + } + + moveSelectionToEndOfPrecedingNode(executor, document, composer); + + if ((node as TextNode).text.isEmpty) { + // The caret is at the beginning of an empty TextNode and the preceding + // node is not a TextNode. Delete the current TextNode and move the + // selection up to the preceding node if exist. + executor.executeCommand( + DeleteNodeCommand(nodeId: node.id), + ); + } + } + + /// Merges the selected [TextNode] with the upstream [TextNode]. + /// + /// If there are non-deletable [BlockNode]s between the two [TextNode]s, + /// the [BlockNode]s are retained without modification. + bool mergeTextNodeWithUpstreamTextNode( + CommandExecutor executor, + MutableDocument document, + MutableDocumentComposer composer, + ) { + final node = document.getNodeById(composer.selection!.extent.nodeId); + if (node == null) { + return false; + } + + DocumentNode? nodeAbove = document.getNodeBefore(node); + while (nodeAbove is BlockNode && !nodeAbove.isDeletable) { + nodeAbove = document.getNodeBefore(nodeAbove); + } + + if (nodeAbove == null) { + return false; + } + if (nodeAbove is! TextNode) { + return false; + } + + final aboveParagraphLength = nodeAbove.text.length; + + // Send edit command. + executor + ..executeCommand( + CombineParagraphsCommand( + firstNodeId: nodeAbove.id, + secondNodeId: node.id, + ), + ) + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeAbove.id, + nodePosition: TextNodePosition(offset: aboveParagraphLength), + ), + ), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ); + + return true; + } + + void moveSelectionToEndOfPrecedingNode( + CommandExecutor executor, + MutableDocument document, + MutableDocumentComposer composer, + ) { + if (composer.selection == null) { + return; + } + + final node = document.getNodeById(composer.selection!.extent.nodeId); + if (node == null) { + return; + } + + final nodeBefore = document.getNodeBefore(node); + if (nodeBefore == null) { + return; + } + + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeBefore.id, + nodePosition: nodeBefore.endPosition, + ), + ), + SelectionChangeType.collapseSelection, + SelectionReason.userInteraction, + ), + ); + } +} + +class Intention extends EditEvent { + Intention.start() : _isStart = true; + + Intention.end() : _isStart = false; + + final bool _isStart; + + bool get isStart => _isStart; + + bool get isEnd => !_isStart; +} + +class SplitParagraphIntention extends Intention { + SplitParagraphIntention.start() : super.start(); + + SplitParagraphIntention.end() : super.end(); +} + +class SubmitParagraphIntention extends Intention { + SubmitParagraphIntention.start() : super.start(); + + SubmitParagraphIntention.end() : super.end(); +} + ExecutionInstruction anyCharacterToInsertInParagraph({ - required EditContext editContext, - required RawKeyEvent keyEvent, + required SuperEditorContext editContext, + required KeyEvent keyEvent, }) { if (editContext.composer.selection == null) { return ExecutionInstruction.continueExecution; @@ -304,7 +1161,7 @@ ExecutionInstruction anyCharacterToInsertInParagraph({ // Do nothing if CMD or CTRL are pressed because this signifies an attempted // shortcut. - if (keyEvent.isControlPressed || keyEvent.isMetaPressed) { + if (HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) { return ExecutionInstruction.continueExecution; } @@ -331,36 +1188,40 @@ ExecutionInstruction anyCharacterToInsertInParagraph({ final didInsertCharacter = editContext.commonOps.insertCharacter(character); - if (didInsertCharacter && character == ' ') { - editContext.commonOps.convertParagraphByPatternMatching( - editContext.composer.selection!.extent.nodeId, - ); - } - return didInsertCharacter ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; } -class DeleteParagraphsCommand implements EditorCommand { - DeleteParagraphsCommand({ +class DeleteParagraphCommand extends EditCommand { + DeleteParagraphCommand({ required this.nodeId, }); final String nodeId; @override - void execute(Document document, DocumentEditorTransaction transaction) { - editorDocLog.info('Executing DeleteParagraphsCommand'); + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + editorDocLog.info('Executing DeleteParagraphCommand'); editorDocLog.info(' - deleting "$nodeId"'); + final document = context.document; final node = document.getNodeById(nodeId); if (node is! TextNode) { editorDocLog.shout('WARNING: Cannot delete node of type: $node.'); return; } - bool didRemove = transaction.deleteNode(node); + bool didRemove = document.deleteNode(node.id); if (!didRemove) { editorDocLog.shout('ERROR: Failed to delete node "$node" from the document.'); } + + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(node.id, node), + ) + ]); } } @@ -368,9 +1229,13 @@ class DeleteParagraphsCommand implements EditorCommand { /// and backspace is pressed, clear any existing block type, e.g., /// header 1, header 2, blockquote. ExecutionInstruction backspaceToClearParagraphBlockType({ - required EditContext editContext, - required RawKeyEvent keyEvent, + required SuperEditorContext editContext, + required KeyEvent keyEvent, }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { return ExecutionInstruction.continueExecution; } @@ -383,7 +1248,7 @@ ExecutionInstruction backspaceToClearParagraphBlockType({ return ExecutionInstruction.continueExecution; } - final node = editContext.editor.document.getNodeById(editContext.composer.selection!.extent.nodeId); + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); if (node is! ParagraphNode) { return ExecutionInstruction.continueExecution; } @@ -397,22 +1262,321 @@ ExecutionInstruction backspaceToClearParagraphBlockType({ return didClearBlockType ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; } +/// Un-indents the current paragraph if the paragraph is empty and the user +/// pressed Enter. +ExecutionInstruction enterToUnIndentParagraph({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.enter && keyEvent.logicalKey != LogicalKeyboardKey.numpadEnter) { + return ExecutionInstruction.continueExecution; + } + + final selection = editContext.composer.selection; + if (selection == null) { + return ExecutionInstruction.continueExecution; + } + if (!selection.isCollapsed) { + return ExecutionInstruction.continueExecution; + } + + final paragraph = editContext.document.getNodeById(selection.extent.nodeId); + if (paragraph is! ParagraphNode) { + // This policy only applies to paragraphs. + return ExecutionInstruction.continueExecution; + } + if (paragraph.indent == 0) { + // Nothing to un-indent. + return ExecutionInstruction.continueExecution; + } + if (paragraph.text.isNotEmpty) { + // We only un-indent when the user presses Enter in an empty paragraph. + return ExecutionInstruction.continueExecution; + } + + // Un-indent the paragraph. + editContext.editor.execute([ + UnIndentParagraphRequest(paragraph.id), + ]); + + return ExecutionInstruction.haltExecution; +} + ExecutionInstruction enterToInsertBlockNewline({ - required EditContext editContext, - required RawKeyEvent keyEvent, + required SuperEditorContext editContext, + required KeyEvent keyEvent, }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.enter && keyEvent.logicalKey != LogicalKeyboardKey.numpadEnter) { return ExecutionInstruction.continueExecution; } - final didInsertBlockNewline = editContext.commonOps.insertBlockLevelNewline(); + editContext.editor.execute([ + InsertNewlineAtCaretRequest(Editor.createNodeId()), + ]); + + return ExecutionInstruction.haltExecution; +} + +ExecutionInstruction tabToIndentParagraph({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.tab) { + return ExecutionInstruction.continueExecution; + } + + if (HardwareKeyboard.instance.isShiftPressed) { + // Don't indent if Shift is pressed - that's for un-indenting. + return ExecutionInstruction.continueExecution; + } + + final selection = editContext.composer.selection; + if (selection == null) { + return ExecutionInstruction.continueExecution; + } + + if (selection.base.nodeId != selection.extent.nodeId) { + // Selection spans nodes, so even if this selection includes a paragraph, + // it includes other stuff, too. So we can't treat this as a paragraph indentation. + return ExecutionInstruction.continueExecution; + } + + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); + if (node is! ParagraphNode) { + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + IndentParagraphRequest(node.id), + ]); - return didInsertBlockNewline ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; + return ExecutionInstruction.haltExecution; +} + +class SetParagraphIndentRequest implements EditRequest { + const SetParagraphIndentRequest( + this.nodeId, { + required this.level, + }); + + final String nodeId; + final int level; +} + +class SetParagraphIndentCommand extends EditCommand { + const SetParagraphIndentCommand( + this.nodeId, { + required this.level, + }); + + final String nodeId; + final int level; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + final paragraph = document.getNodeById(nodeId); + if (paragraph is! ParagraphNode) { + // The specified node isn't a paragraph. Nothing for us to indent. + return; + } + + // Decrease the paragraph indentation of the desired paragraph. + document.replaceNodeById( + paragraph.id, + paragraph.copyParagraphWith( + indent: level, + ), + ); + + // Log all changes. + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(paragraph.id), + ), + ]); + } +} + +class IndentParagraphRequest implements EditRequest { + const IndentParagraphRequest(this.nodeId); + + final String nodeId; +} + +class IndentParagraphCommand extends EditCommand { + const IndentParagraphCommand(this.nodeId); + + final String nodeId; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + final paragraph = document.getNodeById(nodeId); + if (paragraph is! ParagraphNode) { + // The specified node isn't a paragraph. Nothing for us to indent. + return; + } + + // Increase the paragraph indentation. + document.replaceNodeById( + paragraph.id, + paragraph.copyParagraphWith(indent: paragraph.indent + 1), + ); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(paragraph.id), + ), + ]); + } +} + +ExecutionInstruction shiftTabToUnIndentParagraph({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.tab) { + return ExecutionInstruction.continueExecution; + } + if (!HardwareKeyboard.instance.isShiftPressed) { + return ExecutionInstruction.continueExecution; + } + + final selection = editContext.composer.selection; + if (selection == null) { + return ExecutionInstruction.continueExecution; + } + + if (selection.base.nodeId != selection.extent.nodeId) { + // Selection spans nodes, so even if this selection includes a paragraph, + // it includes other stuff, too. So we can't treat this as a paragraph indentation. + return ExecutionInstruction.continueExecution; + } + + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); + if (node is! ParagraphNode) { + return ExecutionInstruction.continueExecution; + } + + if (node.indent == 0) { + // Can't un-indent any further. + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + UnIndentParagraphRequest(node.id), + ]); + + return ExecutionInstruction.haltExecution; +} + +class UnIndentParagraphRequest implements EditRequest { + const UnIndentParagraphRequest(this.nodeId); + + final String nodeId; +} + +class UnIndentParagraphCommand extends EditCommand { + const UnIndentParagraphCommand(this.nodeId); + + final String nodeId; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + final paragraph = document.getNodeById(nodeId); + if (paragraph is! ParagraphNode) { + // The specified node isn't a paragraph. Nothing for us to indent. + return; + } + + if (paragraph.indent == 0) { + // This paragraph is already at minimum indent. Nothing to do. + return; + } + + // Decrease the paragraph indentation of the desired paragraph. + document.replaceNodeById( + paragraph.id, + paragraph.copyParagraphWith(indent: paragraph.indent - 1), + ); + + // Log all changes. + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(paragraph.id), + ), + ]); + } +} + +ExecutionInstruction backspaceToUnIndentParagraph({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + + final selection = editContext.composer.selection; + if (selection == null) { + return ExecutionInstruction.continueExecution; + } + + if (selection.base.nodeId != selection.extent.nodeId) { + // Selection spans nodes, so even if this selection includes a paragraph, + // it includes other stuff, too. So we can't treat this as a paragraph indentation. + return ExecutionInstruction.continueExecution; + } + + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); + if (node is! ParagraphNode) { + return ExecutionInstruction.continueExecution; + } + if ((editContext.composer.selection!.extent.nodePosition as TextPosition).offset > 0) { + // Backspace should only un-indent if the caret is at the start of the text. + return ExecutionInstruction.continueExecution; + } + + if (node.indent == 0) { + // Can't un-indent any further. + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + UnIndentParagraphRequest(node.id), + ]); + + return ExecutionInstruction.haltExecution; } ExecutionInstruction moveParagraphSelectionUpWhenBackspaceIsPressed({ - required EditContext editContext, - required RawKeyEvent keyEvent, + required SuperEditorContext editContext, + required KeyEvent keyEvent, }) { if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { return ExecutionInstruction.continueExecution; @@ -424,16 +1588,16 @@ ExecutionInstruction moveParagraphSelectionUpWhenBackspaceIsPressed({ return ExecutionInstruction.continueExecution; } - final node = editContext.editor.document.getNodeById(editContext.composer.selection!.extent.nodeId); + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); if (node is! ParagraphNode) { return ExecutionInstruction.continueExecution; } - if (node.text.text.isEmpty) { + if (node.text.isEmpty) { return ExecutionInstruction.continueExecution; } - final nodeAbove = editContext.editor.document.getNodeBefore(node); + final nodeAbove = editContext.document.getNodeBeforeById(node.id); if (nodeAbove == null) { return ExecutionInstruction.continueExecution; } @@ -442,9 +1606,111 @@ ExecutionInstruction moveParagraphSelectionUpWhenBackspaceIsPressed({ nodePosition: nodeAbove.endPosition, ); - editContext.composer.selection = DocumentSelection.collapsed( - position: newDocumentPosition, - ); + editContext.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: newDocumentPosition, + ), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ]); return ExecutionInstruction.haltExecution; } + +ExecutionInstruction doNothingWithEnterOnWeb({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.enter && keyEvent.logicalKey != LogicalKeyboardKey.numpadEnter) { + return ExecutionInstruction.continueExecution; + } + + if (CurrentPlatform.isWeb) { + // On web, pressing enter generates both a key event and a `TextInputAction.newline` action. + // We handle the newline action and ignore the key event. + // We return blocked so the OS can process it. + return ExecutionInstruction.blocked; + } + + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction doNothingWithBackspaceOnWeb({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + + if (CurrentPlatform.isWeb) { + // On web, pressing backspace generates both a key event and a deletion delta. + // We handle the deletion delta and ignore the key event. + // We return blocked so the OS can process it. + return ExecutionInstruction.blocked; + } + + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction doNothingWithCtrlOrCmdAndZOnWeb({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.keyZ) { + return ExecutionInstruction.continueExecution; + } + + if (CurrentPlatform.isApple && !HardwareKeyboard.instance.isMetaPressed) { + return ExecutionInstruction.continueExecution; + } + + if (!CurrentPlatform.isApple && !HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + + if (CurrentPlatform.isWeb) { + // On web, pressing Cmd + Z on Mac or Ctrl + Z on Windows and Linux + // triggers the UNDO action of the HTML text input, which doesn't work for us. + // Prevent the browser from handling the shortcut. + return ExecutionInstruction.haltExecution; + } + + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction doNothingWithDeleteOnWeb({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.delete) { + return ExecutionInstruction.continueExecution; + } + + if (CurrentPlatform.isWeb) { + // On web, pressing delete generates both a key event and a deletion delta. + // We handle the deletion delta and ignore the key event. + // We return blocked so the OS can process it. + return ExecutionInstruction.blocked; + } + + return ExecutionInstruction.continueExecution; +} diff --git a/super_editor/lib/src/default_editor/selection_binary.dart b/super_editor/lib/src/default_editor/selection_binary.dart index 06a5113b39..c703482278 100644 --- a/super_editor/lib/src/default_editor/selection_binary.dart +++ b/super_editor/lib/src/default_editor/selection_binary.dart @@ -11,6 +11,9 @@ class BinaryNodePosition implements NodePosition { final bool isIncluded; + @override + bool isEquivalentTo(NodePosition other) => this == other; + @override String toString() => "[BinaryNodePosition] - is included: $isIncluded"; diff --git a/super_editor/lib/src/default_editor/selection_upstream_downstream.dart b/super_editor/lib/src/default_editor/selection_upstream_downstream.dart index d2e847dc66..155480c4ed 100644 --- a/super_editor/lib/src/default_editor/selection_upstream_downstream.dart +++ b/super_editor/lib/src/default_editor/selection_upstream_downstream.dart @@ -11,6 +11,9 @@ class UpstreamDownstreamNodePosition implements NodePosition { final TextAffinity affinity; + @override + bool isEquivalentTo(NodePosition other) => this == other; + @override String toString() => "[UpstreamDownstreamNodePosition] - $affinity"; diff --git a/super_editor/lib/src/default_editor/spelling_and_grammar/spelling_and_grammar_styler.dart b/super_editor/lib/src/default_editor/spelling_and_grammar/spelling_and_grammar_styler.dart new file mode 100644 index 0000000000..6318eb9e18 --- /dev/null +++ b/super_editor/lib/src/default_editor/spelling_and_grammar/spelling_and_grammar_styler.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A [SingleColumnLayoutStylePhase] that applies spelling and grammar error +/// underlines to [TextNode]s in the document that have reported errors. +/// +/// Provide a [spellingErrorUnderlineStyle] and/or [grammarErrorUnderlineStyle] +/// to override all other style preferences for spelling and grammar error underline +/// styles across all text components. +class SpellingAndGrammarStyler extends SingleColumnLayoutStylePhase { + SpellingAndGrammarStyler({ + UnderlineStyle? spellingErrorUnderlineStyle, + UnderlineStyle? grammarErrorUnderlineStyle, + this.selectionHighlightColor, + }) : _spellingErrorUnderlineStyle = spellingErrorUnderlineStyle, + _grammarErrorUnderlineStyle = grammarErrorUnderlineStyle; + + UnderlineStyle? _spellingErrorUnderlineStyle; + set spellingErrorUnderlineStyle(UnderlineStyle? style) { + if (style == _spellingErrorUnderlineStyle) { + return; + } + + _spellingErrorUnderlineStyle = style; + markDirty(); + } + + UnderlineStyle? _grammarErrorUnderlineStyle; + set grammarErrorUnderlineStyle(UnderlineStyle? style) { + if (style == _grammarErrorUnderlineStyle) { + return; + } + + _grammarErrorUnderlineStyle = style; + markDirty(); + } + + /// Whether or not we should override the default selection color with [selectionHighlightColor]. + /// + /// On mobile platforms, when the suggestions popover is opened, the selected text uses a different + /// highlight color. + bool _overrideSelectionColor = false; + + /// The color to use for the selection highlight [overrideSelectionColor] is called. + final Color? selectionHighlightColor; + + /// Configure this styler to override the default selection color with [selectionHighlightColor]. + /// + /// The default editor selection styler phase configures a selection color for all selections. + /// Call this method to use [selectionHighlightColor] instead. This is useful to highlight a + /// selected misspelled word with a color that is different from the default selection color. + /// + /// Call [useDefaultSelectionColor] to stop overriding the default selection color. + void overrideSelectionColor() { + _overrideSelectionColor = true; + markDirty(); + } + + /// Stop overriding the default selection color. + /// + /// After calling this method, all selections will use the default selection color. + void useDefaultSelectionColor() { + _overrideSelectionColor = false; + markDirty(); + } + + Set getErrorsForNode(String nodeId) => + _errorsByNode[nodeId] != null ? Set.from(_errorsByNode[nodeId]!) : {}; + final _errorsByNode = >{}; + final _dirtyNodes = {}; + + void addErrors(String nodeId, Set errors) { + _errorsByNode[nodeId] ??= {}; + _errorsByNode[nodeId]!.addAll(errors); + _dirtyNodes.add(nodeId); + + markDirty(); + } + + void clearSomeErrorsForNode(String nodeId, Set errors) { + _errorsByNode[nodeId]?.removeAll(errors); + + markDirty(); + } + + void clearErrorsForNode(String nodeId) { + _errorsByNode.remove(nodeId); + _dirtyNodes.add(nodeId); + + markDirty(); + } + + void clearAllErrors() { + _dirtyNodes.addAll(_errorsByNode.keys); + _errorsByNode.clear(); + + markDirty(); + } + + @override + SingleColumnLayoutViewModel style(Document document, SingleColumnLayoutViewModel viewModel) { + final updatedViewModel = SingleColumnLayoutViewModel( + padding: viewModel.padding, + componentViewModels: [ + for (final previousViewModel in viewModel.componentViewModels) // + _applyErrors(previousViewModel.copy()), + ], + ); + + _dirtyNodes.clear(); + + return updatedViewModel; + } + + SingleColumnLayoutComponentViewModel _applyErrors(SingleColumnLayoutComponentViewModel viewModel) { + if (!_errorsByNode.containsKey(viewModel.nodeId)) { + return viewModel; + } + if (viewModel is! TextComponentViewModel) { + editorSpellingAndGrammarLog + .warning("Tried to apply spelling/grammar errors to a non-text view model: ${viewModel.runtimeType}"); + return viewModel; + } + + final spellingErrors = _errorsByNode[viewModel.nodeId]!.where((error) => error.type == TextErrorType.spelling); + if (_spellingErrorUnderlineStyle != null) { + // The user explicitly requested this style be used for spelling errors. + // Apply it. + viewModel.spellingErrorUnderlineStyle = _spellingErrorUnderlineStyle!; + } + viewModel.spellingErrors = spellingErrors.map((error) => error.range).toList(); + + if (_overrideSelectionColor && selectionHighlightColor != null) { + viewModel.selectionColor = selectionHighlightColor!; + } + + final grammarErrors = _errorsByNode[viewModel.nodeId]!.where((error) => error.type == TextErrorType.grammar); + if (_grammarErrorUnderlineStyle != null) { + // The user explicitly requested this style be used for grammar errors. + // Apply it. + viewModel.grammarErrorUnderlineStyle = _grammarErrorUnderlineStyle!; + } + viewModel.grammarErrors = grammarErrors.map((error) => error.range).toList(); + + return viewModel; + } +} + +/// A spelling or grammar error within a [TextNode]. +/// +/// Each error refers to the node with the error, the text range in the node that +/// constitutes the error, the type of error, the text with the error, and (possibly) +/// suggested corrections for that error. +class TextError { + const TextError.spelling({ + required this.nodeId, + required this.range, + required this.value, + this.suggestions = const [], + }) : type = TextErrorType.spelling; + + const TextError.grammar({ + required this.nodeId, + required this.range, + required this.value, + this.suggestions = const [], + }) : type = TextErrorType.grammar; + + const TextError({ + required this.nodeId, + required this.range, + required this.type, + required this.value, + this.suggestions = const [], + }); + + final String nodeId; + final TextRange range; + final TextErrorType type; + final String value; + final List suggestions; + + @override + String toString() => + "[TextError] - node: $nodeId, type: $type, range: $range, value: $value, suggestion: $suggestions"; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TextError && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + range == other.range && + type == other.type && + value == other.value; + + @override + int get hashCode => nodeId.hashCode ^ range.hashCode ^ type.hashCode ^ value.hashCode; +} + +enum TextErrorType { + spelling, + grammar; +} + +const defaultSpellingErrorUnderlineStyle = SquiggleUnderlineStyle(color: Colors.red); +const defaultGrammarErrorUnderlineStyle = SquiggleUnderlineStyle(color: Colors.blue); diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index 451841f8cf..3c1247f36a 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -1,29 +1,53 @@ import 'package:attributed_text/attributed_text.dart'; -import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; +import 'package:flutter/foundation.dart' show ValueListenable, defaultTargetPlatform; import 'package:flutter/material.dart' hide SelectableText; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_debug_paint.dart'; -import 'package:super_editor/src/core/document_editor.dart'; import 'package:super_editor/src/core/document_interaction.dart'; import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/core/styles.dart'; import 'package:super_editor/src/default_editor/common_editor_operations.dart'; +import 'package:super_editor/src/default_editor/debug_visualization.dart'; import 'package:super_editor/src/default_editor/document_gestures_touch_android.dart'; import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; import 'package:super_editor/src/default_editor/document_scrollable.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_styler_composing_region.dart'; import 'package:super_editor/src/default_editor/list_items.dart'; -import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/default_editor/tap_handlers/tap_handlers.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/document_scaffold.dart'; +import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/toolbar.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; +import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; +import 'package:super_editor/src/infrastructure/signal_notifier.dart'; +import 'package:super_editor/src/infrastructure/text_input.dart'; +import 'package:super_editor/src/undo_redo.dart'; import 'package:super_text_layout/super_text_layout.dart'; +import '../infrastructure/document_gestures_interaction_overrides.dart'; +import '../infrastructure/platforms/ios/ios_system_context_menu.dart'; +import '../infrastructure/platforms/mobile_documents.dart'; import 'attributions.dart'; import 'blockquote.dart'; import 'document_caret_overlay.dart'; +import 'document_focus_and_selection_policies.dart'; import 'document_gestures_mouse.dart'; -import 'document_input_ime.dart'; -import 'document_input_keyboard.dart'; -import 'document_keyboard_actions.dart'; +import 'document_hardware_keyboard/document_input_keyboard.dart'; +import 'document_ime/document_input_ime.dart'; import 'horizontal_rule.dart'; import 'image.dart'; import 'layout_single_column/layout_single_column.dart'; @@ -33,111 +57,60 @@ import 'unknown_component.dart'; /// A rich text editor that displays a document in a single-column layout. /// -/// A [SuperEditor] brings together the key pieces needed -/// to display a user-editable document: -/// * document model -/// * document editor -/// * document layout -/// * document interaction (tapping, dragging, typing, scrolling) -/// * document composer (current selection, and styles to apply to next character) +/// A [SuperEditor] brings together the key pieces needed to display a user-editable document: +/// * An editor, which includes a document and a composer (which holds the user selection and styles). +/// * A document layout, which positions components for every piece of content in the document. +/// * User interactions with the document layout (tapping, dragging, typing, scrolling) /// -/// A [SuperEditor] determines the visual styling by way of: +/// A [SuperEditor] applies visual styles based on: /// * [stylesheet], which applies styles throughout the document layout, /// including text styles and block padding. -/// * [componentStyles], which applies targeted styles to specific components -/// in the document layout. -/// * [componentBuilders], which produce every visual component within the document layout. /// * [selectionStyles], which dictates the color of the caret and the color of /// selected text and components +/// * [componentStyles], which applies targeted styles to specific components +/// in the document layout. +/// * [componentBuilders], which produce every visual component [Widget] within the document layout. /// -/// A [SuperEditor] determines how a physical keyboard interacts with the document -/// by way of [keyboardActions]. Software keyboards are integrated with the -/// [softwareKeyboardHandler]. +/// [keyboardActions] decides how physical keyboard key presses alter the document within +/// a [SuperEditor]. +/// +/// [imePolicies], [imeConfiguration], and [softwareKeyboardController] decide how user interactions +/// with the operating system's Input Method Editor (IME) alters the document within a +/// [SuperEditor]. /// /// ## Deeper explanation of core artifacts: /// -/// The document model is responsible for holding the content of a -/// document in a structured and query-able manner. +/// A [Document] is responsible for holding the content of a document in a structured +/// and query-able manner. /// -/// The document editor is responsible for mutating the document -/// structure. +/// A [DocumentComposer] is responsible for holding the user's selection, as well as any inline +/// text styles that should be applied as the user types. /// -/// Document layout is responsible for positioning and rendering the -/// various visual components in the document. It's also responsible -/// for linking logical document nodes to visual document components -/// to facilitate user interactions like tapping and dragging. +/// An [Editor] is responsible for executing every request that alters a [Document] or +/// [DocumentComposer]. The [Editor] provides hooks for reactions, which can further alter +/// content after a command, such as parsing inline Markdown, or creating hash tags. The +/// [Editor] implements undo/redo control. /// -/// Document interaction is responsible for taking appropriate actions -/// in response to user taps, drags, and key presses. +/// A [DocumentLayout] is responsible for positioning and rendering the various visual +/// components in the document. It's also responsible for linking logical document nodes +/// to visual document components to facilitate user interactions like tapping and dragging. /// -/// Document composer is responsible for owning document selection and -/// the current text entry mode. +/// Document interaction is responsible for taking appropriate actions in response to user +/// taps, drags, and key presses. class SuperEditor extends StatefulWidget { - @Deprecated("Use unnamed SuperEditor() constructor instead") - SuperEditor.standard({ - Key? key, - this.focusNode, - required this.editor, - this.composer, - this.scrollController, - this.documentLayoutKey, - Stylesheet? stylesheet, - this.customStylePhases = const [], - this.inputSource = DocumentInputSource.keyboard, - this.gestureMode = DocumentGestureMode.mouse, - this.androidHandleColor, - this.androidToolbarBuilder, - this.iOSHandleColor, - this.iOSToolbarBuilder, - this.createOverlayControlsClipper, - this.debugPaint = const DebugPaintConfig(), - this.autofocus = false, - }) : componentBuilders = defaultComponentBuilders, - keyboardActions = defaultKeyboardActions, - softwareKeyboardHandler = null, - stylesheet = stylesheet ?? defaultStylesheet, - selectionStyles = defaultSelectionStyle, - documentOverlayBuilders = [const DefaultCaretOverlayBuilder()], - super(key: key); - - @Deprecated("Use unnamed SuperEditor() constructor instead") - SuperEditor.custom({ - Key? key, - this.focusNode, - required this.editor, - this.composer, - this.scrollController, - this.documentLayoutKey, - Stylesheet? stylesheet, - this.customStylePhases = const [], - List? componentBuilders, - SelectionStyles? selectionStyle, - this.inputSource = DocumentInputSource.keyboard, - this.gestureMode = DocumentGestureMode.mouse, - List? keyboardActions, - this.softwareKeyboardHandler, - this.androidHandleColor, - this.androidToolbarBuilder, - this.iOSHandleColor, - this.iOSToolbarBuilder, - this.createOverlayControlsClipper, - this.debugPaint = const DebugPaintConfig(), - this.autofocus = false, - }) : stylesheet = stylesheet ?? defaultStylesheet, - selectionStyles = selectionStyle ?? defaultSelectionStyle, - keyboardActions = keyboardActions ?? defaultKeyboardActions, - documentOverlayBuilders = [const DefaultCaretOverlayBuilder()], - componentBuilders = componentBuilders != null - ? [...componentBuilders, const UnknownComponentBuilder()] - : [...defaultComponentBuilders, const UnknownComponentBuilder()], - super(key: key); - /// Creates a `Super Editor` with common (but configurable) defaults for /// visual components, text styles, and user interaction. SuperEditor({ Key? key, this.focusNode, + this.autofocus = false, + this.tapRegionGroupId, required this.editor, + @Deprecated( + "The document is now retrieved from the Editor. You should remove this property from your SuperEditor widget.") + this.document, + @Deprecated( + "The composer is now retrieved from the Editor. You should remove this property from your SuperEditor widget.") this.composer, this.scrollController, this.documentLayoutKey, @@ -145,32 +118,58 @@ class SuperEditor extends StatefulWidget { this.customStylePhases = const [], List? componentBuilders, SelectionStyles? selectionStyle, + this.selectionPolicies = const SuperEditorSelectionPolicies(), this.inputSource, + this.softwareKeyboardController, + this.inputRole, + this.imePolicies = const SuperEditorImePolicies(), + this.imeConfiguration, + this.imeOverrides, + this.isImeConnected, + this.keyboardActions, + this.selectorHandlers, this.gestureMode, - List? keyboardActions, - this.softwareKeyboardHandler, + this.contentTapDelegateFactories = const [superEditorLaunchLinkTapHandlerFactory], + this.selectionLayerLinks, + this.documentUnderlayBuilders = const [], + this.documentOverlayBuilders = defaultSuperEditorDocumentOverlayBuilders, + this.overlayController, this.androidHandleColor, this.androidToolbarBuilder, this.iOSHandleColor, this.iOSToolbarBuilder, this.createOverlayControlsClipper, - this.documentOverlayBuilders = const [DefaultCaretOverlayBuilder()], + this.plugins = const {}, this.debugPaint = const DebugPaintConfig(), - this.autofocus = false, + this.shrinkWrap = false, + this.log = const SuperEditorPrintLog(), }) : stylesheet = stylesheet ?? defaultStylesheet, selectionStyles = selectionStyle ?? defaultSelectionStyle, - keyboardActions = keyboardActions ?? defaultKeyboardActions, - componentBuilders = componentBuilders != null - ? [...componentBuilders, const UnknownComponentBuilder()] - : [...defaultComponentBuilders, const UnknownComponentBuilder()], + componentBuilders = [ + for (final plugin in plugins) ...plugin.componentBuilders, + if (componentBuilders != null) + ...componentBuilders + else ...[...defaultComponentBuilders, TaskComponentBuilder(editor)], + const UnknownComponentBuilder(), + ], super(key: key); /// [FocusNode] for the entire `SuperEditor`. final FocusNode? focusNode; - /// Whether or not the [SuperEditor] should autofocus + /// Whether or not the [SuperEditor] should autofocus upon initial display. final bool autofocus; + /// {@template super_editor_tap_region_group_id} + /// A group ID for a tap region that surrounds the editor + /// and also surrounds any related widgets, such as drag handles and a toolbar. + /// + /// When the editor is inside a [TapRegion], tapping at a drag handle causes + /// [TapRegion.onTapOutside] to be called. To prevent that, provide a + /// [tapRegionGroupId] with the same value as the ancestor [TapRegion] groupId. + /// {@endtemplate} + final String? tapRegionGroupId; + /// The [ScrollController] that governs this `SuperEditor`'s scroll /// offset. /// @@ -178,6 +177,33 @@ class SuperEditor extends StatefulWidget { /// `Scrollable`. final ScrollController? scrollController; + /// An editing pipeline, which is responsible for all changes made to a document from + /// this [SuperEditor]. + /// + /// All [SuperEditor] interactions apply changes to a document by submitting requests to + /// this [editor]. The [editor] takes requests, runs corresponding commands, runs reactions + /// to those commands (e.g., parsing Markdown), and then notifies listeners about what + /// changed. + /// + /// The editing pipeline within the [editor] applies to a set of [Editable]s. These are the + /// things that can be changed through editing. For example, every [editor] is expected to + /// contain a [MutableDocument] and a [MutableDocumentComposer] within the set of [Editable]s. + /// That way, edit commands can alter the document and the composer. + /// + /// See [Editor] for more details. + final Editor editor; + + /// The [Document] that's edited by the [editor]. + @Deprecated( + "The Document is now retrieved from the Editor. You should remove this property from your SuperEditor widget.") + final Document? document; + + /// Owns the editor's current selection, the current attributions for + /// text input, and other transitive editor configurations. + @Deprecated( + "The DocumentComposer is now retrieved from the Editor. You should remove this property from your SuperEditor widget.") + final DocumentComposer? composer; + /// [GlobalKey] that's bound to the [DocumentLayout] within /// this `SuperEditor`. /// @@ -191,6 +217,10 @@ class SuperEditor extends StatefulWidget { /// Styles applied to selected content. final SelectionStyles selectionStyles; + /// Policies that determine how selection is modified by other factors, such as + /// gaining or losing focus. + final SuperEditorSelectionPolicies selectionPolicies; + /// Custom style phases that are added to the standard style phases. /// /// Documents are styled in a series of phases. A number of such @@ -210,21 +240,126 @@ class SuperEditor extends StatefulWidget { final List customStylePhases; /// The `SuperEditor` input source, e.g., keyboard or Input Method Engine. - final DocumentInputSource? inputSource; + final TextInputSource? inputSource; + + /// Opens and closes the software keyboard. + /// + /// Typically, this controller should only be used when the keyboard is configured + /// for manual control, e.g., [SuperEditorImePolicies.openKeyboardOnSelectionChange] and + /// [SuperEditorImePolicies.clearSelectionWhenEditorLosesFocus] are `false`. Otherwise, + /// the automatic behavior might conflict with commands to this controller. + final SoftwareKeyboardController? softwareKeyboardController; + + /// A name/ID that differentiates this [SuperEditor]'s purpose from any other [SuperEditor] + /// that might be on screen. + /// + /// The [inputRole] is used to control access to the operating system's IME. Imagine that you have + /// Editor1 and Editor2 on screen. You want Editor1 to be able to hold onto the IME connection + /// across widget tree rebuilds, which requires a global connection, but you don't want Editor2 to + /// accidentally take over that global IME connection. The solution is to pass a different [inputRole] + /// for Editor1 and Editor2. + /// + /// If you're sure that you'll only have one editor on screen, you don't need to provide an [inputRole]. + /// + /// The value for [inputRole] is arbitrary. It can be any name you choose, so long as other editors + /// use different names. + final String? inputRole; + + /// Policies that dictate when and how [SuperEditor] should interact with the + /// platform IME, such as automatically opening the software keyboard when + /// [SuperEditor]'s selection changes. + final SuperEditorImePolicies imePolicies; + + /// Preferences for how the platform IME should look and behave during editing. + final SuperEditorImeConfiguration? imeConfiguration; + + /// Overrides for IME actions. + /// + /// When the user edits document content in IME mode, those edits and actions + /// are reported to a [DeltaTextInputClient], which is then responsible for + /// applying those changes to a document. [SuperEditor] includes an implementation + /// for all relevant editing behaviors. However, some apps may wish to implement + /// their own custom behavior, such as when the user presses the action button, + /// such as "Next" or "Done". + /// + /// Provide a [DeltaTextInputClientDecorator], to override the default [SuperEditor] + /// behaviors for various IME messages. + final DeltaTextInputClientDecorator? imeOverrides; + + /// A (optional) notifier that's notified when the IME connection opens or closes. + /// + /// A `true` value means [SuperEditor] is connected to the platform's IME, a `false` + /// value means [SuperEditor] isn't connected to the platforms IME. + final ValueNotifier? isImeConnected; /// The `SuperEditor` gesture mode, e.g., mouse or touch. final DocumentGestureMode? gestureMode; + /// List of factories that create a [ContentTapDelegate], which is given an + /// opportunity to respond to taps on content before the editor, itself. + /// + /// A [ContentTapDelegate] might be used, for example, to launch a URL + /// when a user taps on a link. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + final List? contentTapDelegateFactories; + + /// Leader links that connect leader widgets near the user's selection + /// to carets, handles, and other things that want to follow the selection. + /// + /// These links are always created and used within [SuperEditor]. By providing + /// an explicit [selectionLayerLinks], external widgets can also follow the + /// user's selection. + final SelectionLayerLinks? selectionLayerLinks; + + /// Layers that are displayed under the document layout, aligned + /// with the location and size of the document layout. + final List documentUnderlayBuilders; + + /// Layers that are displayed on top of the document layout, aligned + /// with the location and size of the document layout. + final List documentOverlayBuilders; + + /// Priority list of widget factories that create instances of + /// each visual component displayed in the document layout, e.g., + /// paragraph component, image component, horizontal rule component, etc. + final List componentBuilders; + + /// All actions that this editor takes in response to key + /// events, e.g., text entry, newlines, character deletion, + /// copy, paste, etc. + /// + /// If [keyboardActions] is `null`, [SuperEditor] uses [defaultKeyboardActions] + /// when the gesture mode is [TextInputSource.keyboard], and + /// [defaultImeKeyboardActions] when the gesture mode is [TextInputSource.ime]. + final List? keyboardActions; + + /// Handlers for all Mac OS "selectors" reported by the IME. + /// + /// The IME reports selectors as unique `String`s, therefore selector handlers are + /// defined as a mapping from selector names to handler functions. + final Map? selectorHandlers; + + /// Shows, hides, and positions a floating toolbar and magnifier. + @Deprecated( + "To configure overlay controls, surround SuperEditor with a SuperEditorIosControlsScope and/or SuperEditorAndroidControlsScope") + final MagnifierAndToolbarController? overlayController; + /// Color of the text selection drag handles on Android. + @Deprecated("To configure handle color, surround SuperEditor with a SuperEditorAndroidControlsScope, instead") final Color? androidHandleColor; /// Builder that creates a floating toolbar when running on Android. + @Deprecated("To configure a toolbar builder, surround SuperEditor with a SuperEditorAndroidControlsScope, instead") final WidgetBuilder? androidToolbarBuilder; /// Color of the text selection drag handles on iOS. + @Deprecated("To configure handle color, surround SuperEditor with a SuperEditorIosControlsScope, instead") final Color? iOSHandleColor; /// Builder that creates a floating toolbar when running on iOS. + @Deprecated("To configure a toolbar builder, surround SuperEditor with a SuperEditorIosControlsScope, instead") final WidgetBuilder? iOSToolbarBuilder; /// Creates a clipper that applies to overlay controls, like drag @@ -234,41 +369,29 @@ class SuperEditor extends StatefulWidget { /// If no clipper factory method is provided, then the overlay controls /// will be allowed to appear anywhere in the overlay in which they sit /// (probably the entire screen). + @Deprecated( + "To configure an overlay clipper, surround SuperEditor with a SuperEditorIosControlsScope and/or a SuperEditorAndroidControlsScope") final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; - /// Contains a [Document] and alters that document as desired. - final DocumentEditor editor; - - /// Layers that are displayed on top of the document layout, aligned - /// with the location and size of the document layout. - final List documentOverlayBuilders; - - /// Owns the editor's current selection, the current attributions for - /// text input, and other transitive editor configurations. - final DocumentComposer? composer; - - /// Priority list of widget factories that create instances of - /// each visual component displayed in the document layout, e.g., - /// paragraph component, image component, horizontal rule component, etc. - final List componentBuilders; - - /// All actions that this editor takes in response to key - /// events, e.g., text entry, newlines, character deletion, - /// copy, paste, etc. - /// - /// These actions are only used when in [DocumentInputSource.keyboard] - /// mode. - final List keyboardActions; - - /// Applies all software keyboard edits to the document. - /// - /// This handler is only used when in [DocumentInputSource.ime] mode. - final SoftwareKeyboardHandler? softwareKeyboardHandler; + /// Plugins that add sets of behaviors to the editing experience. + final Set plugins; /// Paints some extra visual ornamentation to help with /// debugging. final DebugPaintConfig debugPaint; + /// Whether the scroll view used by the editor should shrink-wrap its contents. + /// Only used when editor is not inside an scrollable. + final bool shrinkWrap; + + /// A log that reports specific errors and exceptional events that occur while + /// running a [SuperEditor]. + /// + /// This log was introduced to create a place to report errors to apps that those + /// apps might want to send to their own issue tracker to gain visibility into why + /// issues are happening in their editor. + final SuperEditorPrintLog? log; + @override SuperEditorState createState() => SuperEditorState(); } @@ -278,91 +401,161 @@ class SuperEditorState extends State { // GlobalKey used to access the [DocumentLayoutState] to figure // out where in the document the user taps or drags. late GlobalKey _docLayoutKey; + final _documentLayoutLink = LayerLink(); SingleColumnLayoutPresenter? _docLayoutPresenter; late SingleColumnStylesheetStyler _docStylesheetStyler; late SingleColumnLayoutCustomComponentStyler _docLayoutPerComponentBlockStyler; + final _customUnderlineStyler = CustomUnderlineStyler(); late SingleColumnLayoutSelectionStyler _docLayoutSelectionStyler; - late FocusNode _focusNode; @visibleForTesting FocusNode get focusNode => _focusNode; + late FocusNode _focusNode; + final _primaryFocusListener = ValueNotifier(false); late DocumentComposer _composer; + DocumentScroller? _scroller; + late ScrollController _scrollController; late AutoScrollController _autoScrollController; - DocumentPosition? _previousSelectionExtent; + // Signal that's notified every time the scroll offset changes for SuperEditor, + // including the cases where SuperEditor controls an ancestor Scrollable. + final _scrollChangeSignal = SignalNotifier(); @visibleForTesting - late EditContext editContext; - late SoftwareKeyboardHandler _softwareKeyboardHandler; - final _floatingCursorController = FloatingCursorController(); + late SuperEditorContext editContext; + + late DocumentLayoutEditable _documentLayoutEditable; + + List? _contentTapHandlers; + + final _dragHandleAutoScroller = ValueNotifier(null); + + // GlobalKey for the iOS editor controls scope so that the scope's controller doesn't + // continuously replace itself every time we rebuild. We want to retain the same + // controls because they're shared throughout a number of disconnected widgets. + final _iosControlsContextKey = GlobalKey(); + final _iosControlsController = SuperEditorIosControlsController(); + + // GlobalKey for the Android editor controls scope so that the scope's controller doesn't + // continuously replace itself every time we rebuild. We want to retain the same + // controls because they're shared throughout a number of disconnected widgets. + final _androidControlsContextKey = GlobalKey(); + final _androidControlsController = SuperEditorAndroidControlsController(); + + // Leader links that connect leader widgets near the user's selection + // to carets, handles, and other things that want to follow the selection. + late SelectionLayerLinks _selectionLinks; @visibleForTesting SingleColumnLayoutPresenter get presenter => _docLayoutPresenter!; + late SoftwareKeyboardController _softwareKeyboardController; + + late ValueNotifier _isImeConnected; + final ValueNotifier _isScribbleInProgress = ValueNotifier(false); + @override void initState() { super.initState(); + if (widget.editor.maybeDocument == null) { + throw Exception( + "No Document is available to SuperEditor. The Editor given to SuperEditor must contain a MutableDocument in the set of Editables."); + } + if (widget.editor.maybeComposer == null) { + throw Exception( + "No DocumentComposer is available to SuperEditor. The Editor given to SuperEditor must contain a MutableDocumentComposer in the set of Editables."); + } + _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); - _composer = widget.composer ?? DocumentComposer(); - _composer.addListener(_updateComposerPreferencesAtSelection); + _composer = widget.editor.composer; + _scrollController = widget.scrollController ?? ScrollController(); _autoScrollController = AutoScrollController(); _docLayoutKey = widget.documentLayoutKey ?? GlobalKey(); + _selectionLinks = widget.selectionLayerLinks ?? SelectionLayerLinks(); + + _softwareKeyboardController = widget.softwareKeyboardController ?? SoftwareKeyboardController(); + + _isImeConnected = widget.isImeConnected ?? ValueNotifier(false); + + _documentLayoutEditable = DocumentLayoutEditable(() => _docLayoutKey.currentState as DocumentLayout); + widget.editor.context.put( + Editor.layoutKey, + _documentLayoutEditable, + ); + _createEditContext(); _createLayoutPresenter(); - - _softwareKeyboardHandler = widget.softwareKeyboardHandler ?? - SoftwareKeyboardHandler( - editor: editContext.editor, - composer: editContext.composer, - commonOps: editContext.commonOps, - ); } @override void didUpdateWidget(SuperEditor oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.composer != oldWidget.composer) { - _composer.removeListener(_updateComposerPreferencesAtSelection); - _composer = widget.composer ?? DocumentComposer(); - _composer.addListener(_updateComposerPreferencesAtSelection); - } - if (widget.editor != oldWidget.editor) { - // The content displayed in this Editor was switched - // out. Remove any content selection from the previous - // document. - _composer.selection = null; - } if (widget.focusNode != oldWidget.focusNode) { _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); + _onFocusChange(); } + if (widget.documentLayoutKey != oldWidget.documentLayoutKey) { _docLayoutKey = widget.documentLayoutKey ?? GlobalKey(); } - if (widget.softwareKeyboardHandler != oldWidget.softwareKeyboardHandler) { - _softwareKeyboardHandler = widget.softwareKeyboardHandler ?? - SoftwareKeyboardHandler( - editor: editContext.editor, - composer: editContext.composer, - commonOps: editContext.commonOps, - ); + + if (widget.selectionLayerLinks != oldWidget.selectionLayerLinks) { + _selectionLinks = widget.selectionLayerLinks ?? SelectionLayerLinks(); + } + + if (widget.editor.maybeComposer != oldWidget.editor.composer) { + if (widget.editor.maybeComposer == null) { + throw Exception( + "No DocumentComposer is available to SuperEditor. The Editor given to SuperEditor must contain a MutableDocumentComposer in the set of Editables."); + } + + _composer = widget.editor.composer; } if (widget.editor != oldWidget.editor) { + for (final plugin in oldWidget.plugins) { + plugin._detachFromSuperEditor(oldWidget.editor); + } + + // Replace the old document layout `Editable` with a new one. + oldWidget.editor.context.remove( + Editor.layoutKey, + _documentLayoutEditable, + ); + _documentLayoutEditable = DocumentLayoutEditable(() => _docLayoutKey.currentState as DocumentLayout); + widget.editor.context.put( + Editor.layoutKey, + _documentLayoutEditable, + ); + _createEditContext(); _createLayoutPresenter(); - } else if (widget.selectionStyles != oldWidget.selectionStyles) { - _docLayoutSelectionStyler.selectionStyles = widget.selectionStyles; + } else { + if (widget.selectionStyles != oldWidget.selectionStyles) { + _docLayoutSelectionStyler.selectionStyles = widget.selectionStyles; + } + if (widget.stylesheet != oldWidget.stylesheet) { + _createLayoutPresenter(); + } } - if (widget.stylesheet != oldWidget.stylesheet) { - _docStylesheetStyler.stylesheet = widget.stylesheet; + if (widget.scrollController != oldWidget.scrollController) { + _scrollController = widget.scrollController ?? ScrollController(); + } + + if (widget.softwareKeyboardController != oldWidget.softwareKeyboardController) { + _softwareKeyboardController = widget.softwareKeyboardController ?? SoftwareKeyboardController(); + } + + if (widget.isImeConnected != oldWidget.isImeConnected) { + _isImeConnected = widget.isImeConnected ?? ValueNotifier(false); } _recomputeIfLayoutShouldShowCaret(); @@ -370,10 +563,21 @@ class SuperEditorState extends State { @override void dispose() { - if (widget.composer == null) { - _composer.dispose(); + if (_contentTapHandlers != null) { + for (final handler in _contentTapHandlers!) { + handler.dispose(); + } + } + + for (final plugin in widget.plugins) { + plugin._detachFromSuperEditor(widget.editor); } + _iosControlsController.dispose(); + _androidControlsController.dispose(); + + widget.editor.context.remove(Editor.layoutKey, _documentLayoutEditable); + _focusNode.removeListener(_onFocusChange); if (widget.focusNode == null) { // We are using our own private FocusNode. Dispose it. @@ -384,16 +588,41 @@ class SuperEditorState extends State { } void _createEditContext() { - editContext = EditContext( + if (_scroller != null) { + _scroller!.dispose(); + } + _scroller = DocumentScroller()..addScrollChangeListener(_scrollChangeSignal.notifyListeners); + + editContext = SuperEditorContext( + editorFocusNode: _focusNode, editor: widget.editor, + document: widget.editor.document, composer: _composer, getDocumentLayout: () => _docLayoutKey.currentState as DocumentLayout, + scroller: _scroller!, commonOps: CommonEditorOperations( editor: widget.editor, + document: widget.editor.document, composer: _composer, documentLayoutResolver: () => _docLayoutKey.currentState as DocumentLayout, ), ); + + for (final plugin in widget.plugins) { + plugin._attachToSuperEditor(widget.editor); + + // Notify plugin of focus state at time of attachment. + plugin.onFocusChange(_focusNode); + } + + // The ContentTapDelegate depends upon the EditContext. Recreate the + // handlers, now that we've created a new EditContext. + if (_contentTapHandlers != null) { + for (final handler in _contentTapHandlers!) { + handler.dispose(); + } + } + _contentTapHandlers = widget.contentTapDelegateFactories?.map((factory) => factory.call(editContext)).toList(); } void _createLayoutPresenter() { @@ -401,7 +630,7 @@ class SuperEditorState extends State { _docLayoutPresenter!.dispose(); } - final document = editContext.editor.document; + final document = editContext.document; _docStylesheetStyler = SingleColumnStylesheetStyler(stylesheet: widget.stylesheet); @@ -411,18 +640,33 @@ class SuperEditorState extends State { document: document, selection: editContext.composer.selectionNotifier, selectionStyles: widget.selectionStyles, + selectedTextColorStrategy: widget.stylesheet.selectedTextColorStrategy, ); + final showComposingUnderline = defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android; + _docLayoutPresenter = SingleColumnLayoutPresenter( document: document, componentBuilders: widget.componentBuilders, pipeline: [ _docStylesheetStyler, _docLayoutPerComponentBlockStyler, + _customUnderlineStyler, ...widget.customStylePhases, - // Selection changes are very volatile. Put that phase last + if (showComposingUnderline) + SingleColumnLayoutComposingRegionStyler( + document: document, + composingRegion: editContext.composer.composingRegion, + showComposingUnderline: true, + ), + // Selection changes are very volatile. Put that phase last, + // just before the phases that the app wants to be at the end // to minimize view model recalculations. _docLayoutSelectionStyler, + for (final plugin in widget.plugins) // + ...plugin.appendedStylePhases, ], ); @@ -431,58 +675,20 @@ class SuperEditorState extends State { void _onFocusChange() { _recomputeIfLayoutShouldShowCaret(); - } + _primaryFocusListener.value = _focusNode.hasPrimaryFocus; - void _recomputeIfLayoutShouldShowCaret() { - _docLayoutSelectionStyler.shouldDocumentShowCaret = - _focusNode.hasFocus && _gestureMode == DocumentGestureMode.mouse; - } - - void _updateComposerPreferencesAtSelection() { - if (_composer.selection?.extent == _previousSelectionExtent) { - return; - } - _previousSelectionExtent = _composer.selection?.extent; - - _composer.preferences.clearStyles(); - - if (_composer.selection == null || !_composer.selection!.isCollapsed) { - return; - } - - final node = widget.editor.document.getNodeById(_composer.selection!.extent.nodeId); - if (node is! TextNode) { - return; + // Notify plugins about focus change. + for (final plugin in widget.plugins) { + plugin.onFocusChange(_focusNode); } + } - final textPosition = _composer.selection!.extent.nodePosition as TextPosition; - - if (textPosition.offset == 0) { - if (node.text.text.isEmpty) { - return; - } - - // Inserted text at the very beginning of a text blob assumes the - // attributions immediately following it (except links). - // TODO: attribution expansion policy should probably be configurable - final allStyles = node.text - .getAllAttributionsAt(textPosition.offset + 1) - .where((attribution) => attribution is! LinkAttribution) - .toSet(); - _composer.preferences.addStyles(allStyles); - } else { - // Inserted text assumes the attributions immediately preceding it - // (except links). - // TODO: attribution expansion policy should probably be configurable - final allStyles = node.text - .getAllAttributionsAt(textPosition.offset - 1) - .where((attribution) => attribution is! LinkAttribution) - .toSet(); - _composer.preferences.addStyles(allStyles); - } + void _recomputeIfLayoutShouldShowCaret() { + _docLayoutSelectionStyler.shouldDocumentShowCaret = _focusNode.hasFocus && gestureMode == DocumentGestureMode.mouse; } - DocumentGestureMode get _gestureMode { + @visibleForTesting + DocumentGestureMode get gestureMode { if (widget.gestureMode != null) { return widget.gestureMode!; } @@ -496,201 +702,754 @@ class SuperEditorState extends State { } } - /// Returns the [DocumentInputSource] which should be used. + /// Returns the [TextInputSource] which should be used. /// /// If the `inputSource` is configured, it is used. Otherwise, - /// the [DocumentInputSource] is chosen based on the platform. - DocumentInputSource get _inputSource { - if (widget.inputSource != null) { - return widget.inputSource!; - } - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.iOS: - return DocumentInputSource.ime; - default: - return DocumentInputSource.keyboard; + /// the [TextInputSource] is chosen based on the platform. + @visibleForTesting + TextInputSource get inputSource => widget.inputSource ?? TextInputSource.ime; + + /// Returns the key handlers that respond to keyboard events within [SuperEditor]. + List get _keyboardActions => + widget.keyboardActions ?? + (inputSource == TextInputSource.ime ? defaultImeKeyboardActions : defaultKeyboardActions); + + void _openSoftwareKeyboard() { + if (!_softwareKeyboardController.hasDelegate) { + // There is no IME connection. It isn't possible to request the keyboard. + return; } + + _softwareKeyboardController.open(viewId: View.of(context).viewId); } @override Widget build(BuildContext context) { - return _buildInputSystem( - child: _buildGestureSystem( - documentLayout: SingleColumnDocumentLayout( - key: _docLayoutKey, - presenter: _docLayoutPresenter!, - componentBuilders: widget.componentBuilders, - showDebugPaint: widget.debugPaint.layout, - ), - ), + return _buildGestureControlsScope( + // We add a Builder immediately beneath the gesture controls scope so that + // all descendant widgets built within SuperEditor can access that scope. + child: Builder(builder: (controlsScopeContext) { + return SuperEditorFocusDebugVisuals( + focusNode: _focusNode, + child: EditorSelectionAndFocusPolicy( + focusNode: _focusNode, + editor: widget.editor, + document: widget.editor.document, + selection: _composer.selectionNotifier, + isDocumentLayoutAvailable: () => + (_docLayoutKey.currentContext?.findRenderObject() as RenderSliver?)?.hasSize == true, + getDocumentLayout: () => editContext.documentLayout, + placeCaretAtEndOfDocumentOnGainFocus: widget.selectionPolicies.placeCaretAtEndOfDocumentOnGainFocus, + restorePreviousSelectionOnGainFocus: widget.selectionPolicies.restorePreviousSelectionOnGainFocus, + clearSelectionWhenEditorLosesFocus: widget.selectionPolicies.clearSelectionWhenEditorLosesFocus, + child: DocumentScaffold( + documentLayoutLink: _documentLayoutLink, + documentLayoutKey: _docLayoutKey, + viewportDecorationBuilder: _buildPlatformSpecificViewportDecorations, + textInputBuilder: _buildTextInputSystem, + gestureBuilder: _buildGestureInteractor, + scrollController: _scrollController, + autoScrollController: _autoScrollController, + scroller: _scroller, + isScribbleInProgress: _isScribbleInProgress, + presenter: presenter, + componentBuilders: widget.componentBuilders, + shrinkWrap: widget.shrinkWrap, + underlays: [ + // Add all underlays from plugins. + for (final plugin in widget.plugins) // + for (final underlayBuilder in plugin.documentUnderlayBuilders) // + (context) => underlayBuilder.build(context, editContext), + // Add all underlays that the app wants. + for (final underlayBuilder in widget.documentUnderlayBuilders) // + (context) => underlayBuilder.build(context, editContext), + ], + overlays: [ + // Layer that positions and sizes leader widgets at the bounds + // of the users selection so that carets, handles, toolbars, and + // other things can follow the selection. + (context) { + return _SelectionLeadersDocumentLayerBuilder( + links: _selectionLinks, + showDebugLeaderBounds: false, + ).build(context, editContext); + }, + // Add all overlays from plugins. + for (final plugin in widget.plugins) // + for (final overlayBuilder in plugin.documentOverlayBuilders) // + (context) => overlayBuilder.build(context, editContext), + // Add all overlays that the app wants. + for (final overlayBuilder in widget.documentOverlayBuilders) // + (context) => overlayBuilder.build(context, editContext), + ], + debugPaint: widget.debugPaint, + ), + ), + ); + }), ); } + /// Build an [InheritedWidget] that holds a shared controller scope for editor controls, + /// e.g., caret, handles, magnifier, toolbar. + /// + /// This controller may be shared by multiple widgets within [SuperEditor]. It's also + /// possible that a client app has wrapped [SuperEditor] with its own controller scope + /// [InheritedWidget], in which case the controller is shared with widgets inside + /// of [SuperEditor], and widgets outside of [SuperEditor]. + /// + /// The specific scope that's added to the widget tree is selected by the given [gestureMode]. + Widget _buildGestureControlsScope({ + required Widget child, + }) { + switch (gestureMode) { + case DocumentGestureMode.mouse: + return child; + case DocumentGestureMode.android: + return SuperEditorAndroidControlsScope( + key: _androidControlsContextKey, + controller: _androidControlsController, + child: child, + ); + case DocumentGestureMode.iOS: + return SuperEditorIosControlsScope( + key: _iosControlsContextKey, + controller: _iosControlsController, + child: child, + ); + } + } + /// Builds the widget tree that applies user input, e.g., key /// presses from a keyboard, or text deltas from the IME. - Widget _buildInputSystem({ + Widget _buildTextInputSystem( + BuildContext context, { required Widget child, }) { - switch (_inputSource) { - case DocumentInputSource.keyboard: - return DocumentKeyboardInteractor( + switch (inputSource) { + case TextInputSource.keyboard: + return SuperEditorHardwareKeyHandler( focusNode: _focusNode, autofocus: widget.autofocus, editContext: editContext, - keyboardActions: widget.keyboardActions, + keyboardActions: [ + for (final plugin in widget.plugins) // + ...plugin.keyboardActions, + ..._keyboardActions, + ], child: child, ); - case DocumentInputSource.ime: - return DocumentImeInteractor( + case TextInputSource.ime: + return SuperEditorImeInteractor( focusNode: _focusNode, autofocus: widget.autofocus, editContext: editContext, - softwareKeyboardHandler: _softwareKeyboardHandler, - hardwareKeyboardActions: widget.keyboardActions, - floatingCursorController: _floatingCursorController, + inputRole: widget.inputRole, + clearSelectionWhenEditorLosesFocus: widget.selectionPolicies.clearSelectionWhenEditorLosesFocus, + clearSelectionWhenImeConnectionCloses: widget.selectionPolicies.clearSelectionWhenImeConnectionCloses, + softwareKeyboardController: _softwareKeyboardController, + imePolicies: widget.imePolicies, + imeConfiguration: widget.imeConfiguration ?? + SuperEditorImeConfiguration( + keyboardBrightness: Theme.of(context).brightness, + ), + imeOverrides: widget.imeOverrides, + hardwareKeyboardActions: [ + for (final plugin in widget.plugins) // + ...plugin.keyboardActions, + ..._keyboardActions, + ], + selectorHandlers: widget.selectorHandlers ?? defaultEditorSelectorHandlers, + isImeConnected: _isImeConnected, + isScribbleInProgress: _isScribbleInProgress, + log: widget.log?.imeDeltas, child: child, ); } } - /// Builds the widget tree that handles user gesture interaction - /// with the document, e.g., mouse input on desktop, or touch input - /// on mobile. - Widget _buildGestureSystem({ - required Widget documentLayout, + /// Builds any widgets that a platform wants to wrap around the editor viewport, + /// e.g., editor toolbar, floating cursor display for iOS. + Widget _buildPlatformSpecificViewportDecorations( + BuildContext context, { + required Widget child, }) { - switch (_gestureMode) { + switch (gestureMode) { + case DocumentGestureMode.iOS: + return SuperEditorIosToolbarOverlayManager( + tapRegionGroupId: widget.tapRegionGroupId, + defaultToolbarBuilder: (overlayContext, mobileToolbarKey, focalPoint) => defaultIosEditorToolbarBuilder( + overlayContext, + mobileToolbarKey, + focalPoint, + editContext.commonOps, + SuperEditorIosControlsScope.rootOf(context), + ), + child: SuperEditorIosMagnifierOverlayManager( + child: EditorFloatingCursor( + editor: widget.editor, + document: widget.editor.document, + getDocumentLayout: () => _docLayoutKey.currentState as DocumentLayout, + selection: widget.editor.composer.selectionNotifier, + scrollChangeSignal: _scrollChangeSignal, + child: child, + ), + ), + ); + case DocumentGestureMode.android: + return SuperEditorAndroidControlsOverlayManager( + tapRegionGroupId: widget.tapRegionGroupId, + document: editContext.document, + getDocumentLayout: () => _docLayoutKey.currentState as DocumentLayout, + selection: _composer.selectionNotifier, + setSelection: (newSelection) { + if (newSelection == null) { + editContext.editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.clearSelection, + SelectionReason.userInteraction, + ), + ]); + return; + } + + editContext.editor.execute([ + ChangeSelectionRequest( + newSelection, + newSelection.isCollapsed ? SelectionChangeType.pushCaret : SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + }, + isImeConnected: _isImeConnected, + scrollChangeSignal: _scrollChangeSignal, + dragHandleAutoScroller: _dragHandleAutoScroller, + defaultToolbarBuilder: (overlayContext, mobileToolbarKey, focalPoint) => defaultAndroidEditorToolbarBuilder( + overlayContext, + mobileToolbarKey, + editContext.commonOps, + SuperEditorAndroidControlsScope.rootOf(context), + editContext.composer.selectionNotifier, + focalPoint, + ), + child: child, + ); + case DocumentGestureMode.mouse: + return child; + } + } + + Widget _buildGestureInteractor(BuildContext context, {required Widget child}) { + // Ensure that gesture object fill entire viewport when not being + // in user specified scrollable. + final fillViewport = context.findAncestorScrollableWithVerticalScroll == null; + switch (gestureMode) { case DocumentGestureMode.mouse: - return _buildDesktopGestureSystem(documentLayout); + return DocumentMouseInteractor( + focusNode: _focusNode, + editor: editContext.editor, + document: editContext.document, + getDocumentLayout: () => editContext.documentLayout, + selectionChanges: editContext.composer.selectionChanges, + selectionNotifier: editContext.composer.selectionNotifier, + contentTapHandlers: [ + ..._contentTapHandlers ?? [], + for (final plugin in widget.plugins) // + ...plugin.contentTapHandlers, + ], + autoScroller: _autoScrollController, + fillViewport: fillViewport, + showDebugPaint: widget.debugPaint.gestures, + child: child, + ); case DocumentGestureMode.android: return AndroidDocumentTouchInteractor( focusNode: _focusNode, - document: editContext.editor.document, + editor: editContext.editor, + document: editContext.document, getDocumentLayout: () => editContext.documentLayout, selection: editContext.composer.selectionNotifier, - scrollController: widget.scrollController, - documentKey: _docLayoutKey, - handleColor: widget.androidHandleColor ?? Theme.of(context).primaryColor, - popoverToolbarBuilder: widget.androidToolbarBuilder ?? (_) => const SizedBox(), - createOverlayControlsClipper: widget.createOverlayControlsClipper, + isImeConnected: _isImeConnected, + isScribbleInProgress: _isScribbleInProgress, + openKeyboardWhenTappingExistingSelection: widget.selectionPolicies.openKeyboardWhenTappingExistingSelection, + openKeyboardOnSelectionChange: widget.imePolicies.openKeyboardOnSelectionChange, + openSoftwareKeyboard: _openSoftwareKeyboard, + contentTapHandlers: [ + ..._contentTapHandlers ?? [], + for (final plugin in widget.plugins) // + ...plugin.contentTapHandlers, + ], + scrollController: _scrollController, + dragHandleAutoScroller: _dragHandleAutoScroller, + fillViewport: fillViewport, showDebugPaint: widget.debugPaint.gestures, - child: documentLayout, + child: child, ); case DocumentGestureMode.iOS: - return IOSDocumentTouchInteractor( + return IosDocumentTouchInteractor( focusNode: _focusNode, - document: editContext.editor.document, + editor: editContext.editor, + document: editContext.document, getDocumentLayout: () => editContext.documentLayout, selection: editContext.composer.selectionNotifier, - scrollController: widget.scrollController, - documentKey: _docLayoutKey, - handleColor: widget.iOSHandleColor ?? Theme.of(context).primaryColor, - popoverToolbarBuilder: widget.iOSToolbarBuilder ?? (_) => const SizedBox(), - floatingCursorController: _floatingCursorController, - createOverlayControlsClipper: widget.createOverlayControlsClipper, + isImeConnected: _isImeConnected, + isScribbleInProgress: _isScribbleInProgress, + openKeyboardWhenTappingExistingSelection: widget.selectionPolicies.openKeyboardWhenTappingExistingSelection, + openKeyboardOnSelectionChange: widget.imePolicies.openKeyboardOnSelectionChange, + openSoftwareKeyboard: _openSoftwareKeyboard, + contentTapHandlers: [ + ..._contentTapHandlers ?? [], + for (final plugin in widget.plugins) // + ...plugin.contentTapHandlers, + ], + scrollController: _scrollController, + dragHandleAutoScroller: _dragHandleAutoScroller, + fillViewport: fillViewport, showDebugPaint: widget.debugPaint.gestures, - child: documentLayout, + child: child, ); } } +} - Widget _buildDesktopGestureSystem(Widget documentLayout) { - return LayoutBuilder(builder: (context, viewportConstraints) { - return DocumentScrollable( - autoScroller: _autoScrollController, - scrollController: widget.scrollController, - scrollingMinimapId: widget.debugPaint.scrollingMinimapId, - showDebugPaint: widget.debugPaint.scrolling, - child: ConstrainedBox( - constraints: BoxConstraints( - // When SuperEditor installs its own Viewport, we want the gesture - // detection to span throughout the Viewport. Because the gesture - // system sits around the DocumentLayout, within the Viewport, we - // have to explicitly tell the gesture area to be at least as tall - // as the viewport (in case the document content is shorter than - // the viewport). - minWidth: viewportConstraints.maxWidth < double.infinity ? viewportConstraints.maxWidth : 0, - minHeight: viewportConstraints.maxHeight < double.infinity ? viewportConstraints.maxHeight : 0, - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - // A layer that sits beneath the document and handles gestures. - // It's beneath the document so that components that include - // interactive UI, like a Checkbox, can intercept their own - // touch events. - Positioned.fill( - child: DocumentMouseInteractor( - focusNode: _focusNode, - document: editContext.editor.document, - getDocumentLayout: () => editContext.documentLayout, - selection: editContext.composer.selectionNotifier, - autoScroller: _autoScrollController, - showDebugPaint: widget.debugPaint.gestures, - child: const SizedBox(), - ), - ), - // The document that the user is editing. - Align( - alignment: Alignment.topCenter, - child: Stack( - children: [ - documentLayout, - // We display overlay builders in this inner-Stack so that they - // match the document size, rather than the viewport size. - for (final overlayBuilder in widget.documentOverlayBuilders) - Positioned.fill( - child: overlayBuilder.build(context, editContext), - ), - ], - ), - ), - ], - ), - ), - ); - }); +/// A [DocumentFloatingToolbarBuilder] that displays the iOS system popover toolbar, if the version of +/// iOS is recent enough, otherwise builds [defaultIosEditorToolbarBuilder]. +Widget iOSSystemPopoverEditorToolbarWithFallbackBuilder( + BuildContext context, + Key floatingToolbarKey, + LeaderLink focalPoint, + CommonEditorOperations editorOps, + SuperEditorIosControlsController editorControlsController, +) { + if (CurrentPlatform.isWeb) { + // On web, we defer to the browser's internal overlay controls for mobile. + return const SizedBox(); } + + if (IOSSystemContextMenu.isSupported(context)) { + return IOSSystemContextMenu( + leaderLink: focalPoint, + ); + } + + return defaultIosEditorToolbarBuilder( + context, + floatingToolbarKey, + focalPoint, + editorOps, + editorControlsController, + ); +} + +/// Builds a standard editor-style iOS floating toolbar. +Widget defaultIosEditorToolbarBuilder( + BuildContext context, + Key floatingToolbarKey, + LeaderLink focalPoint, + CommonEditorOperations editorOps, + SuperEditorIosControlsController editorControlsController, +) { + if (CurrentPlatform.isWeb) { + // On web, we defer to the browser's internal overlay controls for mobile. + return const SizedBox(); + } + + return DefaultIosEditorToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + editorOps: editorOps, + editorControlsController: editorControlsController, + ); +} + +/// An iOS floating toolbar, which includes standard buttons for an editor use-case. +class DefaultIosEditorToolbar extends StatelessWidget { + const DefaultIosEditorToolbar({ + super.key, + this.floatingToolbarKey, + required this.focalPoint, + required this.editorOps, + required this.editorControlsController, + }); + + final Key? floatingToolbarKey; + final LeaderLink focalPoint; + final CommonEditorOperations editorOps; + final SuperEditorIosControlsController editorControlsController; + + @override + Widget build(BuildContext context) { + return IOSTextEditingFloatingToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + onCutPressed: _cut, + onCopyPressed: _copy, + onPastePressed: _paste, + ); + } + + void _cut() { + editorOps.cut(); + editorControlsController.hideToolbar(); + } + + void _copy() { + editorOps.copy(); + editorControlsController.hideToolbar(); + } + + void _paste() { + editorOps.paste(); + editorControlsController.hideToolbar(); + } +} + +/// Builds a standard editor-style Android floating toolbar. +Widget defaultAndroidEditorToolbarBuilder( + BuildContext context, + Key floatingToolbarKey, + CommonEditorOperations editorOps, + SuperEditorAndroidControlsController editorControlsController, + ValueListenable selectionNotifier, + LeaderLink focalPoint, +) { + return DefaultAndroidEditorToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + editorOps: editorOps, + editorControlsController: editorControlsController, + selectionNotifier: selectionNotifier, + ); +} + +/// An Android floating toolbar, which includes standard buttons for an editor use-case. +class DefaultAndroidEditorToolbar extends StatelessWidget { + const DefaultAndroidEditorToolbar({ + super.key, + this.floatingToolbarKey, + required this.editorOps, + required this.editorControlsController, + required this.selectionNotifier, + required this.focalPoint, + }); + + final Key? floatingToolbarKey; + final LeaderLink focalPoint; + final CommonEditorOperations editorOps; + final SuperEditorAndroidControlsController editorControlsController; + final ValueListenable selectionNotifier; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: selectionNotifier, + builder: (context, selection, child) { + return AndroidTextEditingFloatingToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + onCopyPressed: selection == null || !selection.isCollapsed // + ? _copy + : null, + onCutPressed: selection == null || !selection.isCollapsed // + ? _cut + : null, + onPastePressed: _paste, + onSelectAllPressed: _selectAll, + ); + }, + ); + } + + void _cut() { + editorOps.cut(); + editorControlsController.hideToolbar(); + } + + void _copy() { + editorOps.copy(); + editorControlsController.hideToolbar(); + } + + void _paste() { + editorOps.paste(); + editorControlsController.hideToolbar(); + } + + void _selectAll() { + editorOps.selectAll(); + } +} + +/// A [SuperEditorLayerBuilder] that builds a [SelectionLeadersDocumentLayer], which positions +/// leader widgets at the base and extent of the user's selection, so that other widgets +/// can position themselves relative to the user's selection. +class _SelectionLeadersDocumentLayerBuilder implements SuperEditorLayerBuilder { + const _SelectionLeadersDocumentLayerBuilder({ + required this.links, + // ignore: unused_element + this.showDebugLeaderBounds = false, + }); + + /// Collections of [LayerLink]s, which are given to leader widgets that are + /// positioned at the selection bounds, and around the full selection. + final SelectionLayerLinks links; + + /// Whether to paint colorful bounds around the leader widgets, for debugging purposes. + final bool showDebugLeaderBounds; + + @override + ContentLayerWidget build(BuildContext context, SuperEditorContext editContext) { + return SelectionLeadersDocumentLayer( + document: editContext.document, + selection: editContext.composer.selectionNotifier, + links: links, + showDebugLeaderBounds: showDebugLeaderBounds, + ); + } +} + +/// A [SuperEditor] plugin. +/// +/// A [SuperEditorPlugin] can be thought of as a combination of two plugins. +/// +/// First, there's the part that extends the behavior of an [Editor]. Those extensions +/// are added in [attach]. +/// +/// Second, there's the part that extends the behavior of a [SuperEditor] widget, directly. +/// Those behaviors are collected through various properties, such as [keyboardActions] and +/// [componentBuilders]. +/// +/// An [Editor] is a logical pipeline of requests, commands, and reactions. It has no direct +/// connection to a user interface. +/// +/// A [SuperEditor] widget is a complete editor user interface. When a plugin is given to a +/// [SuperEditor] widget, the [SuperEditor] widget [attach]s the plugin to its [Editor], and +/// then the [SuperEditor] widget pulls out UI related behaviors from the plugin for things +/// like keyboard handlers and component builders. +/// +/// [Editor] extensions are applied differently than the [SuperEditor] UI extensions, because +/// an [Editor] is mutable, meaning it can be altered. But a [SuperEditor] widget, like all other +/// widgets, is immutable, and must be rebuilt when properties change. As a result, each plugin +/// is instructed to alter an [Editor] as desired, but [SuperEditor] UI extensions are queried +/// from the plugin, so that the [SuperEditor] widget can pass those extensions as properties +/// during a widget build. +abstract class SuperEditorPlugin { + SuperEditorPlugin(); + + /// The reference count of the number of times [_attachToSuperEditor] was + /// called for each editor. + /// + /// This reference count is here due to order of operation nuances in Flutter's + /// widget tree rebuild process. If a [SuperEditor] subtree is replaced with a new + /// one (which happens a lot without carefully using `GlobalKey`s), then this + /// plugin will be told to attach before being told to detach. Without reference + /// counting, we would then run attach (NEW), followed by detach (OLD), and undo + /// the attachment we just ran. + final _attachCount = {}; + + void _attachToSuperEditor(Editor editor) { + _attachCount[editor] ??= 0; + + if (_attachCount[editor] == 0) { + attach(editor); + } + + _attachCount[editor] = _attachCount[editor]! + 1; + } + + void _detachFromSuperEditor(Editor editor) { + _attachCount[editor] = _attachCount[editor]! - 1; + + if (_attachCount[editor] == 0) { + detach(editor); + } + } + + /// Adds desired behaviors to the given [editor]. + void attach(Editor editor) {} + + /// Removes behaviors from the given [editor], which were added in [attach]. + void detach(Editor editor) {} + + /// Hook, which is invoked when the attached `SuperEditor` widget gains or + /// loses focus. + /// + /// This hook might be called at times when no change in focus actually took place. + /// It's the job of implementers to handle repeat invocations without focus changes. + void onFocusChange(FocusNode editorFocusNode) {} + + /// Additional [SuperEditorKeyboardAction]s that will be added to a given [SuperEditor] widget. + List get keyboardActions => []; + + /// Additional [ComponentBuilder]s that will be added to a given [SuperEditor] widget. + List get componentBuilders => []; + + /// Additional underlay [SuperEditorLayerBuilder]s that will be added to a given [SuperEditor]. + List get documentUnderlayBuilders => []; + + /// Additional overlay [SuperEditorLayerBuilder]s that will be added to a given [SuperEditor]. + List get documentOverlayBuilders => []; + + /// Optional handlers that respond to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + List get contentTapHandlers => const []; + + /// Custom style phases that are added to the very end of the [SuperEditor] style phases. + List get appendedStylePhases => const []; +} + +/// A collection of policies that dictate how a [SuperEditor]'s selection will change +/// based on other behaviors, such as focus changes. +class SuperEditorSelectionPolicies { + const SuperEditorSelectionPolicies({ + this.placeCaretAtEndOfDocumentOnGainFocus = true, + this.restorePreviousSelectionOnGainFocus = true, + this.openKeyboardWhenTappingExistingSelection = true, + this.clearSelectionWhenEditorLosesFocus = true, + this.clearSelectionWhenImeConnectionCloses = true, + }); + + /// Whether the editor should automatically place the caret at the end of the document, + /// if the editor receives focus without an existing selection. + /// + /// [restorePreviousSelectionOnGainFocus] takes priority over this policy. + final bool placeCaretAtEndOfDocumentOnGainFocus; + + /// Whether the editor's previous selection should be restored when the editor re-gains + /// focus, after having previous lost focus. + final bool restorePreviousSelectionOnGainFocus; + + /// {@template openKeyboardWhenTappingExistingSelection} + /// Whether the software keyboard should be opened when the user taps on the existing + /// selection. + /// + /// Defaults to `true`. + /// + /// Typically, when an editor has a selection, the software keyboard is already open. + /// However, in some cases, the user might want to temporarily close the keyboard. For + /// example, the user might replace the keyboard with a custom emoji picker panel. + /// + /// When the user is done with the temporary keyboard replacement, the user then wants to + /// open the keyboard again, so the user taps on the caret. If this property is `true` + /// then tapping on the caret will open the keyboard again. + /// + /// In other, similar cases, the user might want to be able to tap on the editor without + /// opening the keyboard. For example, the user might open a keyboard panel that can insert + /// various types of content. In that case, the user might want to move the caret to then + /// insert something from the panel. In this case, it's easy to accidentally tap on the + /// existing caret, which would then close the panel and open the keyboard. To avoid this + /// annoyance, this property can be set to `false`, in which case tapping on the caret won't + /// automatically open the keyboard. It's left to the app to re-open the keyboard when desired. + /// {@endtemplate} + final bool openKeyboardWhenTappingExistingSelection; + + /// Whether the editor's selection should be removed when the editor loses + /// all focus (not just primary focus). + /// + /// If `true`, when focus moves to a different subtree, such as a popup text + /// field, or a button somewhere else on the screen, the editor will remove + /// its selection. When focus returns to the editor, the previous selection can + /// be restored, but that's controlled by other policies. + /// + /// If `false`, the editor will retain its selection, including a visual caret + /// and selected content, even when the editor doesn't have any focus, and can't + /// process any input. + final bool clearSelectionWhenEditorLosesFocus; + + /// Whether the editor's selection should be removed when the editor closes or loses + /// its IME connection. + /// + /// Defaults to `true`. + /// + /// Apps that include a custom input mode, such as an editing panel that sometimes + /// replaces the software keyboard, should set this to `false` and instead control the + /// IME connection manually. + final bool clearSelectionWhenImeConnectionCloses; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorSelectionPolicies && + runtimeType == other.runtimeType && + placeCaretAtEndOfDocumentOnGainFocus == other.placeCaretAtEndOfDocumentOnGainFocus && + restorePreviousSelectionOnGainFocus == other.restorePreviousSelectionOnGainFocus && + clearSelectionWhenEditorLosesFocus == other.clearSelectionWhenEditorLosesFocus && + clearSelectionWhenImeConnectionCloses == other.clearSelectionWhenImeConnectionCloses; + + @override + int get hashCode => + placeCaretAtEndOfDocumentOnGainFocus.hashCode ^ + restorePreviousSelectionOnGainFocus.hashCode ^ + clearSelectionWhenEditorLosesFocus.hashCode ^ + clearSelectionWhenImeConnectionCloses.hashCode; } /// Builds widgets that are displayed at the same position and size as /// the document layout within a [SuperEditor]. -abstract class DocumentLayerBuilder { - Widget build(BuildContext context, EditContext editContext); +abstract class SuperEditorLayerBuilder { + ContentLayerWidget build(BuildContext context, SuperEditorContext editContext); } -/// A [DocumentLayerBuilder] that's implemented with a given function, so -/// that simple use-cases don't need to sub-class [DocumentLayerBuilder]. -class FunctionalDocumentLayerBuilder implements DocumentLayerBuilder { - const FunctionalDocumentLayerBuilder(this._delegate); +/// A [SuperEditorLayerBuilder] that's implemented with a given function, so +/// that simple use-cases don't need to sub-class [SuperEditorLayerBuilder]. +class FunctionalSuperEditorLayerBuilder implements SuperEditorLayerBuilder { + const FunctionalSuperEditorLayerBuilder(this._delegate); - final Widget Function(BuildContext context, EditContext editContext) _delegate; + final ContentLayerWidget Function(BuildContext context, SuperEditorContext editContext) _delegate; @override - Widget build(BuildContext context, EditContext editContext) => _delegate(context, editContext); + ContentLayerWidget build(BuildContext context, SuperEditorContext editContext) => _delegate(context, editContext); } -/// A [DocumentLayerBuilder] that paints a caret at the primary selection extent +/// A [SuperEditorLayerBuilder] that paints a caret at the primary selection extent /// in a [SuperEditor]. -class DefaultCaretOverlayBuilder implements DocumentLayerBuilder { - const DefaultCaretOverlayBuilder([ +class DefaultCaretOverlayBuilder implements SuperEditorLayerBuilder { + const DefaultCaretOverlayBuilder({ this.caretStyle = const CaretStyle( width: 2, color: Colors.black, ), - ]); + this.platformOverride, + this.displayOnAllPlatforms = false, + this.displayCaretWithExpandedSelection = true, + this.blinkTimingMode = BlinkTimingMode.ticker, + }); /// Styles applied to the caret that's painted by this caret overlay. final CaretStyle caretStyle; + /// The platform to use to determine caret behavior, defaults to [defaultTargetPlatform]. + final TargetPlatform? platformOverride; + + /// Whether to display a caret on all platforms, including mobile. + /// + /// By default, the caret is only displayed on desktop. + final bool displayOnAllPlatforms; + + /// Whether to display the caret when the selection is expanded. + /// + /// Defaults to `true`. + final bool displayCaretWithExpandedSelection; + + /// The timing mechanism used to blink, e.g., `Ticker` or `Timer`. + /// + /// `Timer`s are not expected to work in tests. + final BlinkTimingMode blinkTimingMode; + @override - Widget build(BuildContext context, EditContext editContext) { + ContentLayerWidget build(BuildContext context, SuperEditorContext editContext) { return CaretDocumentOverlay( composer: editContext.composer, documentLayoutResolver: () => editContext.documentLayout, caretStyle: caretStyle, - document: editContext.editor.document, + platformOverride: platformOverride, + displayOnAllPlatforms: displayOnAllPlatforms, + displayCaretWithExpandedSelection: displayCaretWithExpandedSelection, + blinkTimingMode: blinkTimingMode, ); } } @@ -699,38 +1458,80 @@ class DefaultCaretOverlayBuilder implements DocumentLayerBuilder { /// /// These builders are in priority order. The first builder /// to return a non-null component is used. -final defaultComponentBuilders = [ - const BlockquoteComponentBuilder(), - const ParagraphComponentBuilder(), - const ListItemComponentBuilder(), - const ImageComponentBuilder(), - const HorizontalRuleComponentBuilder(), +const defaultComponentBuilders = [ + BlockquoteComponentBuilder(), + ParagraphComponentBuilder(), + ListItemComponentBuilder(), + BitmapImageComponentBuilder(), + ImageComponentBuilder(), + HorizontalRuleComponentBuilder(), +]; + +/// Default list of document overlays that are displayed on top of the document +/// layout in a [SuperEditor]. +const defaultSuperEditorDocumentOverlayBuilders = [ + // Adds a Leader around the document selection at a focal point for the + // iOS floating toolbar. + SuperEditorIosToolbarFocalPointDocumentLayerBuilder(), + // Displays caret and drag handles, specifically for iOS. + SuperEditorIosHandlesDocumentLayerBuilder(), + + // Adds a Leader around the document selection at a focal point for the + // Android floating toolbar. + SuperEditorAndroidToolbarFocalPointDocumentLayerBuilder(), + // Displays caret and drag handles, specifically for Android. + SuperEditorAndroidHandlesDocumentLayerBuilder(), + + // Displays caret for typical desktop use-cases. + DefaultCaretOverlayBuilder(), ]; /// Keyboard actions for the standard [SuperEditor]. -final defaultKeyboardActions = [ +final defaultKeyboardActions = [ + toggleInteractionModeWhenCmdOrCtrlPressed, doNothingWhenThereIsNoSelection, + scrollOnPageUpKeyPress, + scrollOnPageDownKeyPress, + scrollOnCtrlOrCmdAndHomeKeyPress, + scrollOnCtrlOrCmdAndEndKeyPress, pasteWhenCmdVIsPressed, copyWhenCmdCIsPressed, cutWhenCmdXIsPressed, + undoWhenCmdZOrCtrlZIsPressed, + redoWhenCmdShiftZOrCtrlShiftZIsPressed, collapseSelectionWhenEscIsPressed, selectAllWhenCmdAIsPressed, - moveUpDownLeftAndRightWithArrowKeys, + moveLeftAndRightWithArrowKeys, + moveUpAndDownWithArrowKeys, moveToLineStartWithHome, moveToLineEndWithEnd, tabToIndentListItem, shiftTabToUnIndentListItem, backspaceToUnIndentListItem, + tabToIndentTask, + shiftTabToUnIndentTask, + backspaceToUnIndentTask, + tabToIndentParagraph, + shiftTabToUnIndentParagraph, + backspaceToUnIndentParagraph, + backspaceToConvertTaskToParagraph, backspaceToClearParagraphBlockType, cmdBToToggleBold, cmdIToToggleItalics, shiftEnterToInsertNewlineInBlock, + enterToInsertNewTask, + enterToUnIndentParagraph, enterToInsertBlockNewline, - backspaceToRemoveUpstreamContent, - deleteToRemoveDownstreamContent, moveToLineStartOrEndWithCtrlAOrE, - deleteLineWithCmdBksp, - deleteWordWithAltBksp, + deleteToStartOfLineWithCmdBackspaceOnMac, + deleteWordUpstreamWithAltBackspaceOnMac, + deleteWordUpstreamWithControlBackspaceOnWindowsAndLinux, + deleteUpstreamContentWithBackspace, + deleteToEndOfLineWithCmdDeleteOnMac, + deleteWordDownstreamWithAltDeleteOnMac, + deleteWordDownstreamWithControlDeleteOnWindowsAndLinux, + deleteDownstreamContentWithDelete, + blockControlKeys, anyCharacterOrDestructiveKeyToDeleteSelection, anyCharacterToInsertInParagraph, anyCharacterToInsertInTextContent, @@ -741,26 +1542,123 @@ final defaultKeyboardActions = [ /// /// Using the IME on desktop involves partial input from the IME /// and partial input from non-content keys, like arrow keys. -final defaultImeKeyboardActions = [ - doNothingWhenThereIsNoSelection, +final defaultImeKeyboardActions = [ + toggleInteractionModeWhenCmdOrCtrlPressed, pasteWhenCmdVIsPressed, copyWhenCmdCIsPressed, cutWhenCmdXIsPressed, + undoWhenCmdZOrCtrlZIsPressed, + redoWhenCmdShiftZOrCtrlShiftZIsPressed, selectAllWhenCmdAIsPressed, - moveUpDownLeftAndRightWithArrowKeys, + cmdBToToggleBold, + cmdIToToggleItalics, + // All handlers that use backspace should be placed before `doNothingWithBackspaceOnWeb`, + // otherwise they will not run on web. + backspaceToUnIndentListItem, + backspaceToUnIndentParagraph, + backspaceToUnIndentTask, + backspaceToConvertTaskToParagraph, + backspaceToClearParagraphBlockType, + // We handled all shortcuts that care about backspace. Let the browser IME handle the + // backspace to perform text deletion. + doNothingWithBackspaceOnWeb, + doNothingWithCtrlOrCmdAndZOnWeb, + tabToIndentTask, + shiftTabToUnIndentTask, + tabToIndentParagraph, + shiftTabToUnIndentParagraph, + enterToUnIndentParagraph, + deleteDownstreamCharacterWithCtrlDeleteOnMac, + scrollOnCtrlOrCmdAndHomeKeyPress, + scrollOnCtrlOrCmdAndEndKeyPress, + shiftEnterToInsertNewlineInBlock, + deleteToEndOfLineWithCmdDeleteOnMac, + // WARNING: No keyboard handlers below this point will run on Mac. On Mac, most + // common shortcuts are recognized by the OS. This line short circuits Super Editor + // handlers, passing the key combo to the OS on Mac. Place all custom Mac key + // combos above this handler. + sendKeyEventToMacOs, + doNothingWhenThereIsNoSelection, + scrollOnPageUpKeyPress, + scrollOnPageDownKeyPress, + moveUpAndDownWithArrowKeys, + moveToStartOrEndOfLineWithArrowKeysOnWeb, + doNothingWithLeftRightArrowKeysAtMiddleOfTextOnWeb, + moveLeftAndRightWithArrowKeys, moveToLineStartWithHome, moveToLineEndWithEnd, + doNothingWithEnterOnWeb, + enterToInsertNewTask, + enterToInsertBlockNewline, tabToIndentListItem, shiftTabToUnIndentListItem, - backspaceToUnIndentListItem, - backspaceToClearParagraphBlockType, - cmdBToToggleBold, - cmdIToToggleItalics, - shiftEnterToInsertNewlineInBlock, - backspaceToRemoveUpstreamContent, - deleteToRemoveDownstreamContent, + deleteToStartOfLineWithCmdBackspaceOnMac, + deleteWordUpstreamWithAltBackspaceOnMac, + deleteWordUpstreamWithControlBackspaceOnWindowsAndLinux, + deleteUpstreamContentWithBackspace, + deleteWordDownstreamWithAltDeleteOnMac, + deleteWordDownstreamWithControlDeleteOnWindowsAndLinux, + doNothingWithDeleteOnWeb, + deleteDownstreamContentWithDelete, ]; +/// Map selector names to its handlers. +/// +/// Selectors are like system-level shortcuts for macOS. +/// +/// For more information, see [MacOsSelectors]. +const defaultEditorSelectorHandlers = { + // Caret movement. + MacOsSelectors.moveLeft: moveLeft, + MacOsSelectors.moveRight: moveRight, + MacOsSelectors.moveUp: moveUp, + MacOsSelectors.moveDown: moveDown, + MacOsSelectors.moveForward: moveRight, + MacOsSelectors.moveBackward: moveLeft, + MacOsSelectors.moveWordLeft: moveWordLeft, + MacOsSelectors.moveWordRight: moveWordRight, + MacOsSelectors.moveToLeftEndOfLine: moveToLeftEndOfLine, + MacOsSelectors.moveToRightEndOfLine: moveToRightEndOfLine, + MacOsSelectors.moveToBeginningOfParagraph: moveToBeginningOfParagraph, + MacOsSelectors.moveToEndOfParagraph: moveToEndOfParagraph, + MacOsSelectors.moveToBeginningOfDocument: moveToBeginningOfDocument, + MacOsSelectors.moveToEndOfDocument: moveToEndOfDocument, + + // Selection expanding. + MacOsSelectors.moveLeftAndModifySelection: moveLeftAndModifySelection, + MacOsSelectors.moveRightAndModifySelection: moveRightAndModifySelection, + MacOsSelectors.moveUpAndModifySelection: moveUpAndModifySelection, + MacOsSelectors.moveDownAndModifySelection: moveDownAndModifySelection, + MacOsSelectors.moveWordLeftAndModifySelection: moveWordLeftAndModifySelection, + MacOsSelectors.moveWordRightAndModifySelection: moveWordRightAndModifySelection, + MacOsSelectors.moveToLeftEndOfLineAndModifySelection: moveToLeftEndOfLineAndModifySelection, + MacOsSelectors.moveToRightEndOfLineAndModifySelection: moveToRightEndOfLineAndModifySelection, + MacOsSelectors.moveParagraphBackwardAndModifySelection: moveParagraphBackwardAndModifySelection, + MacOsSelectors.moveParagraphForwardAndModifySelection: moveParagraphForwardAndModifySelection, + MacOsSelectors.moveToBeginningOfDocumentAndModifySelection: moveToBeginningOfDocumentAndModifySelection, + MacOsSelectors.moveToEndOfDocumentAndModifySelection: moveToEndOfDocumentAndModifySelection, + + // Insertion. + MacOsSelectors.insertTab: indentListItem, + MacOsSelectors.insertBacktab: unIndentListItem, + MacOsSelectors.insertNewLine: insertNewLine, + + // Deletion. + MacOsSelectors.deleteBackward: deleteBackward, + MacOsSelectors.deleteForward: deleteForward, + MacOsSelectors.deleteWordBackward: deleteWordBackward, + MacOsSelectors.deleteWordForward: deleteWordForward, + MacOsSelectors.deleteToBeginningOfLine: deleteToBeginningOfLine, + MacOsSelectors.deleteToEndOfLine: deleteToEndOfLine, + MacOsSelectors.deleteBackwardByDecomposingPreviousCharacter: deleteBackward, + + // Scrolling. + MacOsSelectors.scrollToBeginningOfDocument: scrollToBeginningOfDocument, + MacOsSelectors.scrollToEndOfDocument: scrollToEndOfDocument, + MacOsSelectors.scrollPageUp: scrollPageUp, + MacOsSelectors.scrollPageDown: scrollPageDown, +}; + /// Stylesheet applied to all [SuperEditor]s by default. final defaultStylesheet = Stylesheet( rules: [ @@ -768,9 +1666,9 @@ final defaultStylesheet = Stylesheet( BlockSelector.all, (doc, docNode) { return { - "maxWidth": 640.0, - "padding": const CascadingPadding.symmetric(horizontal: 24), - "textStyle": const TextStyle( + Styles.maxWidth: 640.0, + Styles.padding: const CascadingPadding.symmetric(horizontal: 24), + Styles.textStyle: const TextStyle( color: Colors.black, fontSize: 18, height: 1.4, @@ -782,8 +1680,8 @@ final defaultStylesheet = Stylesheet( const BlockSelector("header1"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 40), - "textStyle": const TextStyle( + Styles.padding: const CascadingPadding.only(top: 40), + Styles.textStyle: const TextStyle( color: Color(0xFF333333), fontSize: 38, fontWeight: FontWeight.bold, @@ -795,8 +1693,8 @@ final defaultStylesheet = Stylesheet( const BlockSelector("header2"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 32), - "textStyle": const TextStyle( + Styles.padding: const CascadingPadding.only(top: 32), + Styles.textStyle: const TextStyle( color: Color(0xFF333333), fontSize: 26, fontWeight: FontWeight.bold, @@ -808,8 +1706,8 @@ final defaultStylesheet = Stylesheet( const BlockSelector("header3"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 28), - "textStyle": const TextStyle( + Styles.padding: const CascadingPadding.only(top: 28), + Styles.textStyle: const TextStyle( color: Color(0xFF333333), fontSize: 22, fontWeight: FontWeight.bold, @@ -821,7 +1719,7 @@ final defaultStylesheet = Stylesheet( const BlockSelector("paragraph"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 24), + Styles.padding: const CascadingPadding.only(top: 24), }; }, ), @@ -829,7 +1727,7 @@ final defaultStylesheet = Stylesheet( const BlockSelector("paragraph").after("header1"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 0), + Styles.padding: const CascadingPadding.only(top: 0), }; }, ), @@ -837,7 +1735,7 @@ final defaultStylesheet = Stylesheet( const BlockSelector("paragraph").after("header2"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 0), + Styles.padding: const CascadingPadding.only(top: 0), }; }, ), @@ -845,7 +1743,7 @@ final defaultStylesheet = Stylesheet( const BlockSelector("paragraph").after("header3"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 0), + Styles.padding: const CascadingPadding.only(top: 0), }; }, ), @@ -853,7 +1751,7 @@ final defaultStylesheet = Stylesheet( const BlockSelector("listItem"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 24), + Styles.padding: const CascadingPadding.only(top: 24), }; }, ), @@ -861,7 +1759,7 @@ final defaultStylesheet = Stylesheet( const BlockSelector("blockquote"), (doc, docNode) { return { - "textStyle": const TextStyle( + Styles.textStyle: const TextStyle( color: Colors.grey, fontSize: 20, fontWeight: FontWeight.bold, @@ -874,16 +1772,28 @@ final defaultStylesheet = Stylesheet( BlockSelector.all.last(), (doc, docNode) { return { - "padding": const CascadingPadding.only(bottom: 96), + Styles.padding: const CascadingPadding.only(bottom: 96), }; }, ), ], inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, ); TextStyle defaultInlineTextStyler(Set attributions, TextStyle existingStyle) { - return existingStyle.merge(defaultStyleBuilder(attributions)); + var newStyle = existingStyle.merge(defaultStyleBuilder(attributions)); + + // We apply opacity here instead of defaultStyleBuilder because opacity requires + // a color to be defined to apply itself. + final opacityAttribution = attributions.whereType().firstOrNull; + if (opacityAttribution != null) { + newStyle = newStyle.copyWith( + color: newStyle.color!.withValues(alpha: opacityAttribution.opacity), + ); + } + + return newStyle; } /// Creates [TextStyles] for the standard [SuperEditor]. @@ -911,6 +1821,30 @@ TextStyle defaultStyleBuilder(Set attributions) { ? TextDecoration.lineThrough : TextDecoration.combine([TextDecoration.lineThrough, newStyle.decoration!]), ); + } else if (attribution == superscriptAttribution) { + newStyle = newStyle.copyWith( + fontFeatures: [const FontFeature.superscripts()], + ); + } else if (attribution == subscriptAttribution) { + newStyle = newStyle.copyWith( + fontFeatures: [const FontFeature.subscripts()], + ); + } else if (attribution is ColorAttribution) { + newStyle = newStyle.copyWith( + color: attribution.color, + ); + } else if (attribution is BackgroundColorAttribution) { + newStyle = newStyle.copyWith( + backgroundColor: attribution.color, + ); + } else if (attribution is FontSizeAttribution) { + newStyle = newStyle.copyWith( + fontSize: attribution.fontSize, + ); + } else if (attribution is FontFamilyAttribution) { + newStyle = newStyle.copyWith( + fontFamily: attribution.fontFamily, + ); } else if (attribution is LinkAttribution) { newStyle = newStyle.copyWith( color: Colors.lightBlue, @@ -925,3 +1859,16 @@ TextStyle defaultStyleBuilder(Set attributions) { const defaultSelectionStyle = SelectionStyles( selectionColor: Color(0xFFACCEF7), ); + +/// A log that reports specific important errors and exceptional situations that +/// happen when running a [SuperEditor]. +abstract class SuperEditorLog { + TextDeltasDocumentEditorLog get imeDeltas; +} + +class SuperEditorPrintLog implements SuperEditorLog { + const SuperEditorPrintLog() : imeDeltas = const ConsolePrintTextDeltasDocumentEditorLog(); + + @override + final ConsolePrintTextDeltasDocumentEditorLog imeDeltas; +} diff --git a/super_editor/lib/src/default_editor/tables/table_block.dart b/super_editor/lib/src/default_editor/tables/table_block.dart new file mode 100644 index 0000000000..8afd46b698 --- /dev/null +++ b/super_editor/lib/src/default_editor/tables/table_block.dart @@ -0,0 +1,161 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/box_component.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/text.dart'; + +/// A [DocumentNode] that represents a read-only block table. +/// +/// Being a block node means that the table is either fully selected or not selected at all, +/// i.e., there is no selection of individual cells. +@immutable +class TableBlockNode extends BlockNode { + /// Creates a [TableBlockNode] with the given [cells]. + /// + /// The [cells] grid is indexed as `cells[row][column]`. + TableBlockNode({ + required this.id, + required List> cells, + super.metadata, + }) : _cells = List.from(cells.map((row) => List.from(row))) { + initAddToMetadata({NodeMetadata.blockType: tableBlockAttribution}); + } + + @override + final String id; + + final List> _cells; + + int get rowCount => _cells.length; + int get columnCount => _cells.isEmpty ? 0 : _cells[0].length; + + List getRow(int index) { + if (index < 0 || index >= _cells.length) { + throw RangeError.range(index, 0, _cells.length - 1, 'index'); + } + return UnmodifiableListView(_cells[index]); + } + + List getColumn(int index) { + if (_cells.isEmpty || index < 0 || index >= _cells[0].length) { + throw RangeError.range(index, 0, _cells[0].length - 1, 'index'); + } + return UnmodifiableListView(_cells.map((row) => row[index])); + } + + TextNode getCell({ + required int rowIndex, + required int columnIndex, + }) { + if (rowIndex < 0 || rowIndex >= _cells.length) { + throw RangeError.range(rowIndex, 0, _cells.length - 1, 'rowIndex'); + } + final row = _cells[rowIndex]; + if (columnIndex < 0 || columnIndex >= row.length) { + throw RangeError.range(columnIndex, 0, row.length - 1, 'cellIndex'); + } + return row[columnIndex]; + } + + @override + bool hasEquivalentContent(DocumentNode other) { + if (other is! TableBlockNode) { + return false; + } + + if (!super.hasEquivalentContent(other)) { + return false; + } + + if (rowCount != other.rowCount) { + return false; + } + + if (columnCount != other.columnCount) { + return false; + } + + for (int row = 0; row < rowCount; row += 1) { + for (int col = 0; col < columnCount; col += 1) { + final myCell = getCell(rowIndex: row, columnIndex: col); + final otherCell = other.getCell(rowIndex: row, columnIndex: col); + if (!myCell.hasEquivalentContent(otherCell)) { + return false; + } + } + } + + return true; + } + + @override + DocumentNode copyAndReplaceMetadata(Map newMetadata) { + return TableBlockNode( + id: id, + metadata: newMetadata, + cells: _cells, + ); + } + + @override + DocumentNode copyWithAddedMetadata(Map newProperties) { + return TableBlockNode( + id: id, + cells: _cells, + metadata: { + ...metadata, + ...newProperties, + }, + ); + } + + @override + String? copyContent(NodeSelection selection) { + if (selection is! UpstreamDownstreamNodeSelection) { + // We don't know how to handle this selection type. + return null; + } + if (selection.isCollapsed) { + // This selection doesn't include the table - it's a collapsed selection + // either on the upstream or downstream edge. + return null; + } + + final buffer = StringBuffer(); + for (int i = 0; i < _cells.length; i++) { + final row = _cells[i]; + if (i > 0) { + // Separate rows with a newline. + buffer.writeln(''); + } + + for (int j = 0; j < row.length; j++) { + final cell = row[j]; + if (j > 0) { + // Separate cells with a tab. + buffer.write('\t'); + } + + buffer.write(cell.text.toPlainText(includePlaceholders: false)); + } + } + return buffer.toString(); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TableBlockNode && + runtimeType == other.runtimeType && + id == other.id && + metadata == other.metadata && + const DeepCollectionEquality().equals(_cells, other._cells); + + @override + int get hashCode => id.hashCode ^ _cells.hashCode ^ metadata.hashCode; +} + +const tableBlockAttribution = NamedAttribution("tableBlock"); +const tableHeaderAttribution = NamedAttribution("tableHeader"); diff --git a/super_editor/lib/src/default_editor/tables/table_markdown.dart b/super_editor/lib/src/default_editor/tables/table_markdown.dart new file mode 100644 index 0000000000..bf6089b9d5 --- /dev/null +++ b/super_editor/lib/src/default_editor/tables/table_markdown.dart @@ -0,0 +1,570 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/styles.dart'; +import 'package:super_editor/src/default_editor/box_component.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/layout_single_column.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/selection_aware_viewmodel.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/tables/table_block.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/scrolling/desktop_mouse_wheel_and_trackpad_scrolling.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +/// Builds [MarkdownTableViewModel]s and [MarkdownTableComponent]s for every [TableBlockNode] +/// in a document. +/// +/// The [MarkdownTableComponent] uses block level selection, which means that the table is either +/// fully selected or not selected at all, i.e., there is no selection of individual cells. +/// +/// See [TableStyles] for the styles that can be applied to the table through a [Stylesheet]. +class MarkdownTableComponentBuilder implements ComponentBuilder { + const MarkdownTableComponentBuilder({ + this.columnWidth = const IntrinsicColumnWidth(), + this.fit = TableComponentFit.scale, + }); + + final TableColumnWidth columnWidth; + final TableComponentFit fit; + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + if (node is! TableBlockNode) { + return null; + } + + return MarkdownTableViewModel( + nodeId: node.id, + createdAt: node.metadata[NodeMetadata.createdAt], + padding: EdgeInsets.zero, + columnWidth: columnWidth, + fit: fit, + cells: [ + for (int i = 0; i < node.rowCount; i += 1) // + [ + for (final cell in node.getRow(i)) + MarkdownTableCellViewModel( + nodeId: cell.id, + createdAt: cell.metadata[NodeMetadata.createdAt], + text: cell.text, + textAlign: cell.getMetadataValue(TextNodeMetadata.textAlign) ?? TextAlign.left, + textStyleBuilder: noStyleBuilder, + padding: const EdgeInsets.all(8.0), + // ^ Default padding, can be overridden through the stylesheet. + metadata: cell.metadata, + ) + ], + ], + selectionColor: const Color(0x00000000), + caretColor: const Color(0x00000000), + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! MarkdownTableViewModel) { + return null; + } + + return MarkdownTableComponent( + componentKey: componentContext.componentKey, + viewModel: componentViewModel, + // selection: componentViewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection?, + // selectionColor: componentViewModel.selectionColor, + // showCaret: componentViewModel.selection != null, + // caretColor: componentViewModel.caretColor, + // opacity: componentViewModel.opacity, + ); + } +} + +/// View model that configures the appearance of a [MarkdownTableComponent]. +/// +/// View models move through various style phases, which fill out +/// various properties in the view model. For example, one phase applies +/// all [StyleRule]s, and another phase configures content selection +/// and caret appearance. +class MarkdownTableViewModel extends SingleColumnLayoutComponentViewModel with SelectionAwareViewModelMixin { + MarkdownTableViewModel({ + required super.nodeId, + required super.createdAt, + super.maxWidth, + required super.padding, + super.opacity, + required this.cells, + this.border, + this.columnWidth = const IntrinsicColumnWidth(), + this.fit = TableComponentFit.scale, + this.inlineWidgetBuilders = const [], + required this.caretColor, + DocumentNodeSelection? selection, + Color selectionColor = Colors.transparent, + }) { + super.selection = selection; + super.selectionColor = selectionColor; + } + + /// The cells of the table, indexed as `[rowIndex][columnIndex]`. + /// + /// The first row is considered the header row. + /// + /// The remaining rows are considered to be data rows. + final List> cells; + + /// The border to draw around the table and its cells. + /// + /// Configurable through [TableStyles.border]. + TableBorder? border; + + /// The policy that sizes the width of each column in the table. + TableColumnWidth columnWidth; + + /// How the table responds when it wants to be wider than the available width. + TableComponentFit fit; + + /// A chain of builders that create inline widgets that can be embedded + /// inside the table's cells. + InlineWidgetBuilderChain inlineWidgetBuilders; + + /// The color to use when painting the caret. + Color caretColor; + + @override + SingleColumnLayoutComponentViewModel copy() { + return MarkdownTableViewModel( + nodeId: nodeId, + createdAt: createdAt, + maxWidth: maxWidth, + padding: padding, + opacity: opacity, + cells: [ + for (final row in cells) // + row.map((e) => e.copy()).toList(), + ], + border: border, + columnWidth: columnWidth, + fit: fit, + inlineWidgetBuilders: inlineWidgetBuilders, + caretColor: caretColor, + selection: selection, + selectionColor: selectionColor, + ); + } + + @override + void applyStyles(Map styles) { + super.applyStyles(styles); + + if (cells.isEmpty) { + // There is no cell, so we're not rendering anything. Fizzle. + return; + } + + border = styles[TableStyles.border] as TableBorder? ?? border; + inlineWidgetBuilders = styles[Styles.inlineWidgetBuilders] ?? inlineWidgetBuilders; + final inlineTextStyler = styles[Styles.inlineTextStyler] as AttributionStyleAdjuster; + + final baseTextStyle = (styles[Styles.textStyle] ?? noStyleBuilder({})) as TextStyle; + final headerTextStyles = styles[TableStyles.headerTextStyle] as TextStyle?; + final cellDecorator = styles[TableStyles.cellDecorator] as TableCellDecorator?; + + EdgeInsets cellPadding = const EdgeInsets.all(0); + final cascadingPadding = styles[TableStyles.cellPadding] as CascadingPadding?; + if (cascadingPadding != null) { + cellPadding = cascadingPadding.toEdgeInsets(); + } + + // Apply the styles to the header. + final headerRow = cells[0]; + for (int i = 0; i < headerRow.length; i += 1) { + final headerCell = headerRow[i]; + // Applies the header text style on top of the base style. + headerCell.textStyleBuilder = (attributions) { + return inlineTextStyler( + attributions, + headerTextStyles != null // + ? baseTextStyle.merge(headerTextStyles) + : baseTextStyle, + ); + }; + headerCell.padding = cellPadding; + headerCell.decoration = cellDecorator?.call( + rowIndex: 0, + columnIndex: i, + cellText: headerCell.text, + cellMetadata: headerCell.metadata, + ) ?? + const BoxDecoration(); + } + + // Apply the styles to the data rows. + for (int i = 1; i < cells.length; i += 1) { + final dataRow = cells[i]; + for (int j = 0; j < dataRow.length; j += 1) { + final dataCell = dataRow[j]; + dataCell.textStyleBuilder = (attributions) { + return inlineTextStyler(attributions, baseTextStyle); + }; + dataCell.padding = cellPadding; + dataCell.decoration = cellDecorator?.call( + rowIndex: i, + columnIndex: j, + cellText: dataCell.text, + cellMetadata: dataCell.metadata, + ) ?? + const BoxDecoration(); + } + } + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MarkdownTableViewModel && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + createdAt == other.createdAt && + maxWidth == other.maxWidth && + padding == other.padding && + opacity == other.opacity && + caretColor == other.caretColor && + selection == other.selection && + selectionColor == other.selectionColor && + border == other.border && + columnWidth == other.columnWidth && + fit == other.fit && + const DeepCollectionEquality().equals(cells, other.cells); + + @override + int get hashCode => + nodeId.hashCode ^ + createdAt.hashCode ^ + maxWidth.hashCode ^ + padding.hashCode ^ + opacity.hashCode ^ + caretColor.hashCode ^ + selection.hashCode ^ + selectionColor.hashCode ^ + border.hashCode ^ + columnWidth.hashCode ^ + fit.hashCode ^ + cells.hashCode; +} + +enum TableComponentFit { + scroll, + scale; +} + +/// View model that configures the appearance of a [MarkdownTableComponent]'s cell. +class MarkdownTableCellViewModel extends SingleColumnLayoutComponentViewModel { + MarkdownTableCellViewModel({ + required super.nodeId, + required this.text, + this.textAlign = TextAlign.left, + this.textStyleBuilder = noStyleBuilder, + required super.padding, + this.decoration, + required this.metadata, + required super.createdAt, + }); + + final AttributedText text; + TextAlign textAlign; + AttributionStyleBuilder textStyleBuilder; + BoxDecoration? decoration; + Map metadata; + + @override + MarkdownTableCellViewModel copy() { + return MarkdownTableCellViewModel( + nodeId: nodeId, + createdAt: createdAt, + text: text, + textAlign: textAlign, + textStyleBuilder: textStyleBuilder, + padding: padding, + decoration: decoration, + metadata: Map.from(metadata), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MarkdownTableCellViewModel && + runtimeType == other.runtimeType && + super == other && + text == other.text && + textAlign == other.textAlign && + padding == other.padding && + decoration == other.decoration && + const DeepCollectionEquality().equals(metadata, other.metadata); + + @override + int get hashCode => + super.hashCode ^ // + text.hashCode ^ + textAlign.hashCode ^ + padding.hashCode ^ + decoration.hashCode ^ + metadata.hashCode; +} + +/// A component that displays a read-only table with block level selection. +/// +/// A block level selection means that the table is either fully selected or not selected at all, +/// i.e., there is no selection of individual cells. +/// +/// Table components support two sizing properties: +/// * [viewModel.columnWidth]: How to size every column, using the standard Flutter `Table` property. +/// * [viewModel.fit]: Whether to shrink the table to fit the width, or to scroll horizontally +/// +/// It is the responsibility of the user to ensure that `columnWidth` and `fit` do not conflict with +/// each other, such as a column width that takes up a percentage space, while setting the fit to scroll, +/// which would be a layout error. +class MarkdownTableComponent extends StatefulWidget { + const MarkdownTableComponent({ + super.key, + required this.componentKey, + required this.viewModel, + }); + + final GlobalKey componentKey; + final MarkdownTableViewModel viewModel; + + @override + State createState() => _MarkdownTableComponentState(); +} + +class _MarkdownTableComponentState extends State { + final _scrollController = ScrollController(); + + @override + dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return switch (widget.viewModel.fit) { + TableComponentFit.scroll => SingleAxisTrackpadAndWheelScroller( + axis: Axis.horizontal, + controller: _scrollController, + child: Center( + child: _ScrollbarWithoutGap( + scrollController: _scrollController, + scrollbarOrientation: ScrollbarOrientation.bottom, + child: SingleChildScrollView( + controller: _scrollController, + scrollDirection: Axis.horizontal, + child: _buildTableComponent( + table: _buildTable(context), + ), + ), + ), + ), + ), + TableComponentFit.scale => _buildTableComponent( + table: _buildTableToScaleDown( + table: _buildTable(context), + ), + ), + }; + } + + Widget _buildTableComponent({ + required Widget table, + }) { + return MouseRegion( + cursor: SystemMouseCursors.basic, + hitTestBehavior: HitTestBehavior.translucent, + // ^ Without `HitTestBehavior.translucent` the `MouseRegion` seems to be stealing + // the pointer events, making it impossible to place the caret. + child: IgnorePointer( + // ^ Without `IgnorePointer` gestures like taping to place the caret or double tapping + // to select the whole table don't work. The `SelectableBox` seems to be stealing + // the pointer events. + child: SelectableBox( + selection: widget.viewModel.selection?.nodeSelection is UpstreamDownstreamNodeSelection + ? widget.viewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection + : null, + selectionColor: widget.viewModel.selectionColor, + child: BoxComponent( + key: widget.componentKey, + opacity: widget.viewModel.opacity, + child: table, + ), + ), + ), + ); + } + + Widget _buildTableToScaleDown({ + required Widget table, + }) { + return LayoutBuilder( + builder: (context, constraints) { + return FittedBox( + fit: BoxFit.scaleDown, + // ^ Shrink to fit when the table is wider than the viewport. + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.maxWidth, + // ^ Expand to fill when the table is narrower than the viewport. + ), + child: _buildTable(context), + ), + ); + }, + ); + } + + Widget _buildTable(BuildContext context) { + return Table( + border: widget.viewModel.border ?? TableBorder.all(), + defaultColumnWidth: widget.viewModel.columnWidth, + children: [ + for (int i = 0; i < widget.viewModel.cells.length; i += 1) // + _buildRow(context, widget.viewModel.cells[i], i), + ], + ); + } + + TableRow _buildRow(BuildContext context, List row, int rowIndex) { + return TableRow( + children: [ + for (final cell in row) // + _buildCell(context, cell), + ], + ); + } + + Widget _buildCell( + BuildContext context, + MarkdownTableCellViewModel cell, + ) { + return DecoratedBox( + decoration: cell.decoration ?? const BoxDecoration(), + child: Padding( + padding: cell.padding, + child: SuperText( + richText: cell.text.computeInlineSpan( + context, + cell.textStyleBuilder, + widget.viewModel.inlineWidgetBuilders, + ), + textAlign: cell.textAlign, + ), + ), + ); + } +} + +/// Scrollbar that internally fixes a dumb Flutter gap bug. +/// +/// Some Flutter genius thought it was a good idea for all scrollbars in all locations to +/// inset themselves by the `MediaQuery` padding. This adds gaps between scrollbars and their +/// viewport in almost every location because most uses aren't full-screen. +/// +/// Issue ticket: https://github.com/flutter/flutter/issues/150544 +class _ScrollbarWithoutGap extends StatelessWidget { + const _ScrollbarWithoutGap({ + required this.scrollController, + required this.scrollbarOrientation, + required this.child, + }); + + final ScrollController scrollController; + final ScrollbarOrientation scrollbarOrientation; + final Widget child; + + @override + Widget build(BuildContext context) { + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + removeLeft: true, + removeRight: true, + child: Scrollbar( + controller: scrollController, + scrollbarOrientation: scrollbarOrientation, + child: MediaQuery( + data: MediaQuery.of(context), + child: child, + ), + ), + ); + } +} + +/// A function that decorates a table row. +/// +/// Can be used, for example, to apply alternating background colors to rows. +/// +/// The header row has [rowIndex] 0, the first data row has [rowIndex] 1, and so on. +/// +/// Returning `null` means that no decoration is applied to the row. +typedef TableRowDecorator = BoxDecoration? Function({ + required int rowIndex, +}); + +/// A function that decorates a table cell. +/// +/// The header row has [rowIndex] 0, the first data row has [rowIndex] 1, and so on. +/// +/// Returning `null` means that no decoration is applied to the cell, which means +/// the decoration of the row is applied, if any. +typedef TableCellDecorator = BoxDecoration? Function({ + required int rowIndex, + required int columnIndex, + required AttributedText cellText, + required Map cellMetadata, +}); + +/// The default styles that are applied to a table through a [Stylesheet]. +/// +/// Applies a border around the entire table and each cell, a bold text style to the header row, +/// and padding to each cell. +final markdownTableStyles = StyleRule( + BlockSelector(tableBlockAttribution.name), + (document, node) { + if (node is! TableBlockNode) { + return {}; + } + + return { + Styles.padding: const CascadingPadding.only(top: 24), + TableStyles.headerTextStyle: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.black, + ), + TableStyles.cellPadding: const CascadingPadding.all(4.0), + TableStyles.border: TableBorder.all(color: Colors.grey, width: 1), + }; + }, +); + +/// The keys to the style metadata used to style a table. +class TableStyles { + /// Applies a [TextStyle] to the cells of the header row. + static const String headerTextStyle = 'tableHeaderTextStyle'; + + /// Applies a [TableBorder] to the table. + static const String border = 'tableBorder'; + + /// Applies a [TableCellDecorator] to each cell in the table. + /// + /// A [TableCellDecorator] is applied after the [TableStyles.rowDecorator], + /// which means that the cell decorator can paint the cell with a different + /// background color than its parent row. + static const String cellDecorator = 'tableCellDecorator'; + + /// Applies padding to each cell in the table. + static const String cellPadding = 'tableCellPadding'; +} diff --git a/super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart b/super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart new file mode 100644 index 0000000000..b6e9a8010d --- /dev/null +++ b/super_editor/lib/src/default_editor/tap_handlers/tap_handlers.dart @@ -0,0 +1,176 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/rendering.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/links.dart'; + +typedef SuperEditorContentTapDelegateFactory = ContentTapDelegate Function(SuperEditorContext editContext); + +SuperEditorLaunchLinkTapHandler superEditorLaunchLinkTapHandlerFactory(SuperEditorContext editContext) => + SuperEditorLaunchLinkTapHandler(editContext.document, editContext.composer); + +/// A [ContentTapDelegate] that opens links when the user taps text with +/// a [LinkAttribution]. +/// +/// This delegate only opens links when [composer.isInInteractionMode] is +/// `true`. +class SuperEditorLaunchLinkTapHandler extends ContentTapDelegate { + SuperEditorLaunchLinkTapHandler(this.document, this.composer) { + composer.isInInteractionMode.addListener(notifyListeners); + } + + @override + void dispose() { + composer.isInInteractionMode.removeListener(notifyListeners); + super.dispose(); + } + + final Document document; + final DocumentComposer composer; + + @override + MouseCursor? mouseCursorForContentHover(DocumentPosition hoverPosition) { + if (!composer.isInInteractionMode.value) { + // The editor isn't in "interaction mode". We don't want a special cursor + return null; + } + + final link = _getLinkAtPosition(hoverPosition); + return link != null ? SystemMouseCursors.click : null; + } + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); + if (tapPosition == null) { + return TapHandlingInstruction.continueHandling; + } + + if (!composer.isInInteractionMode.value) { + // The editor isn't in "interaction mode". We don't want to allow + // users to open links by tapping on them. + return TapHandlingInstruction.continueHandling; + } + + final link = _getLinkAtPosition(tapPosition); + if (link != null) { + // The user tapped on a link. Launch it. + UrlLauncher.instance.launchUrl(link); + return TapHandlingInstruction.halt; + } else { + // The user didn't tap on a link. + return TapHandlingInstruction.continueHandling; + } + } + + Uri? _getLinkAtPosition(DocumentPosition position) { + final nodePosition = position.nodePosition; + if (nodePosition is! TextNodePosition) { + return null; + } + + final textNode = document.getNodeById(position.nodeId); + if (textNode is! TextNode) { + editorGesturesLog + .shout("Received a report of a tap on a TextNodePosition, but the node with that ID is a: $textNode"); + return null; + } + + final tappedAttributions = textNode.text.getAllAttributionsAt(nodePosition.offset); + for (final tappedAttribution in tappedAttributions) { + if (tappedAttribution is LinkAttribution) { + return tappedAttribution.launchableUri; + } + } + + return null; + } +} + +SuperEditorAddEmptyParagraphTapHandler superEditorAddEmptyParagraphTapHandlerFactory(SuperEditorContext editContext) => + SuperEditorAddEmptyParagraphTapHandler(editContext: editContext); + +/// A [ContentTapDelegate] that adds an empty paragraph at the end of the document +/// when the user taps below the last node in the document. +/// +/// Does nothing if the last node is a [TextNode]. +class SuperEditorAddEmptyParagraphTapHandler extends ContentTapDelegate { + SuperEditorAddEmptyParagraphTapHandler({ + required this.editContext, + }); + + final SuperEditorContext editContext; + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); + if (tapPosition == null) { + return TapHandlingInstruction.continueHandling; + } + + final editor = editContext.editor; + final document = editContext.document; + + final node = document.getNodeById(tapPosition.nodeId)!; + if (node is TextNode) { + return TapHandlingInstruction.continueHandling; + } + + if (!_isTapBelowLastNode( + nodeId: tapPosition.nodeId, + globalOffset: details.globalOffset, + )) { + return TapHandlingInstruction.continueHandling; + } + + // The user tapped below a non-text node. Add a new paragraph + // to the end of the document and place the caret there. + final newNodeId = Editor.createNodeId(); + editor.execute([ + InsertNodeAfterNodeRequest( + existingNodeId: node.id, + newNode: ParagraphNode( + id: newNodeId, + text: AttributedText(), + ), + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + + return TapHandlingInstruction.halt; + } + + bool _isTapBelowLastNode({ + required String nodeId, + required Offset globalOffset, + }) { + final documentLayout = editContext.documentLayout; + final document = editContext.document; + + final tappedComponent = documentLayout.getComponentByNodeId(nodeId)!; + final componentBox = tappedComponent.context.findRenderObject() as RenderBox; + final localPosition = componentBox.globalToLocal(globalOffset); + final node = document.getNodeById(nodeId); + + return (node == document.lastOrNull) && (localPosition.dy > componentBox.size.height); + } +} diff --git a/super_editor/lib/src/default_editor/tasks.dart b/super_editor/lib/src/default_editor/tasks.dart new file mode 100644 index 0000000000..75b047af9b --- /dev/null +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -0,0 +1,1164 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/core/styles.dart'; +import 'package:super_editor/src/default_editor/blocks/indentation.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/composable_text.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +import 'attributions.dart'; +import 'layout_single_column/layout_single_column.dart'; + +/// This file includes everything needed to add the concept of a task +/// to Super Editor. This includes: +/// +/// * [TaskNode], which represents a logical task. +/// * [TaskComponentViewModel], which configures the visual appearance +/// of a task in a document. +/// * [taskStyles], which applies desired styles to tasks in a document. +/// * [TaskComponentBuilder], which creates new [TaskComponentViewModel]s +/// and [TaskComponent]s, for every [TaskNode] in the document. +/// * [TaskComponent], which renders a task in a document. + +/// [DocumentNode] that represents a task to complete. +/// +/// A task can either be complete, or incomplete. +@immutable +class TaskNode extends TextNode { + TaskNode({ + required super.id, + required super.text, + super.metadata, + required this.isComplete, + this.indent = 0, + }) { + // Set a block type so that TaskNode's can be styled by + // StyleRule's. + initAddToMetadata({"blockType": const NamedAttribution("task")}); + } + + /// Whether this task is complete. + final bool isComplete; + + /// The indent level of this task - `0` is no indent. + /// + /// A task can only be indented one level beyond its parent task. + final int indent; + + @override + bool hasEquivalentContent(DocumentNode other) { + return other is TaskNode && isComplete == other.isComplete && text == other.text; + } + + TaskNode copyTaskWith({ + String? id, + AttributedText? text, + Map? metadata, + bool? isComplete, + int? indent, + }) { + return TaskNode( + id: id ?? this.id, + text: text ?? this.text, + metadata: metadata ?? this.metadata, + isComplete: isComplete ?? this.isComplete, + indent: indent ?? this.indent, + ); + } + + @override + TaskNode copyTextNodeWith({ + String? id, + AttributedText? text, + Map? metadata, + }) { + return copyTaskWith( + id: id, + text: text, + metadata: metadata, + ); + } + + @override + TaskNode copyAndReplaceMetadata(Map newMetadata) { + return copyTaskWith( + metadata: newMetadata, + ); + } + + @override + TaskNode copyWithAddedMetadata(Map newProperties) { + return copyTaskWith( + metadata: { + ...metadata, + ...newProperties, + }, + ); + } + + @override + TaskNode copy() { + return TaskNode( + id: id, + text: text.copyText(0), + metadata: Map.from(metadata), + isComplete: isComplete, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is TaskNode && + runtimeType == other.runtimeType && + isComplete == other.isComplete && + indent == other.indent; + + @override + int get hashCode => super.hashCode ^ isComplete.hashCode ^ indent.hashCode; +} + +extension TaskNodeType on DocumentNode { + TaskNode get asTask => this as TaskNode; +} + +/// Styles all task components to apply top padding +final taskStyles = StyleRule( + const BlockSelector("task"), + (document, node) { + if (node is! TaskNode) { + return {}; + } + + return { + Styles.padding: const CascadingPadding.only(top: 24), + }; + }, +); + +/// Builds [TaskComponentViewModel]s and [TaskComponent]s for every +/// [TaskNode] in a document. +class TaskComponentBuilder implements ComponentBuilder { + TaskComponentBuilder(this._editor); + + final Editor _editor; + + @override + TaskComponentViewModel? createViewModel(Document document, DocumentNode node) { + if (node is! TaskNode) { + return null; + } + + final textDirection = getParagraphDirection(node.text.toPlainText()); + + return TaskComponentViewModel( + nodeId: node.id, + createdAt: node.metadata[NodeMetadata.createdAt], + padding: EdgeInsets.zero, + indent: node.indent, + isComplete: node.isComplete, + setComplete: (bool isComplete) { + _editor.execute([ + ChangeTaskCompletionRequest( + nodeId: node.id, + isComplete: isComplete, + ), + ]); + }, + text: node.text, + textDirection: textDirection, + textAlignment: textDirection == TextDirection.ltr ? TextAlign.left : TextAlign.right, + textStyleBuilder: noStyleBuilder, + selectionColor: const Color(0x00000000), + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! TaskComponentViewModel) { + return null; + } + + return TaskComponent( + key: componentContext.componentKey, + viewModel: componentViewModel, + ); + } +} + +/// View model that configures the appearance of a [TaskComponent]. +/// +/// View models move through various style phases, which fill out +/// various properties in the view model. For example, one phase applies +/// all [StyleRule]s, and another phase configures content selection +/// and caret appearance. +class TaskComponentViewModel extends SingleColumnLayoutComponentViewModel with TextComponentViewModel { + TaskComponentViewModel({ + required super.nodeId, + super.createdAt, + super.maxWidth, + required super.padding, + super.opacity = 1.0, + this.indent = 0, + this.indentCalculator = defaultTaskIndentCalculator, + required this.isComplete, + required this.setComplete, + required this.text, + required this.textStyleBuilder, + this.inlineWidgetBuilders = const [], + this.textDirection = TextDirection.ltr, + this.textAlignment = TextAlign.left, + this.maxLines, + this.overflow = TextOverflow.clip, + this.selection, + required this.selectionColor, + this.highlightWhenEmpty = false, + TextRange? composingRegion, + bool showComposingRegionUnderline = false, + UnderlineStyle spellingErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.red), + List spellingErrors = const [], + UnderlineStyle grammarErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.blue), + List grammarErrors = const [], + }) { + this.composingRegion = composingRegion; + this.showComposingRegionUnderline = showComposingRegionUnderline; + + this.spellingErrorUnderlineStyle = spellingErrorUnderlineStyle; + this.spellingErrors = spellingErrors; + + this.grammarErrorUnderlineStyle = grammarErrorUnderlineStyle; + this.grammarErrors = grammarErrors; + } + + int indent; + TextBlockIndentCalculator indentCalculator; + + bool isComplete; + void Function(bool)? setComplete; + + @override + AttributedText text; + @override + AttributionStyleBuilder textStyleBuilder; + @override + InlineWidgetBuilderChain inlineWidgetBuilders; + @override + TextDirection textDirection; + @override + TextAlign textAlignment; + @override + int? maxLines; + @override + TextOverflow overflow; + @override + TextSelection? selection; + @override + Color selectionColor; + @override + bool highlightWhenEmpty; + + @override + TaskComponentViewModel copy() { + return internalCopy( + TaskComponentViewModel( + nodeId: nodeId, + createdAt: createdAt, + padding: padding, + text: text.copy(), + textStyleBuilder: textStyleBuilder, + opacity: opacity, + selectionColor: selectionColor, + indent: indent, + isComplete: isComplete, + setComplete: setComplete, + ), + ); + } + + @override + TaskComponentViewModel internalCopy(TaskComponentViewModel viewModel) { + final copy = super.internalCopy(viewModel) as TaskComponentViewModel; + + copy + ..indent = indent + ..isComplete = isComplete + ..setComplete = setComplete; + + return copy; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is TaskComponentViewModel && + runtimeType == other.runtimeType && + textViewModelEquals(other) && + indent == other.indent && + isComplete == other.isComplete; + + @override + int get hashCode => super.hashCode ^ textViewModelHashCode ^ indent.hashCode ^ isComplete.hashCode; +} + +/// The standard [TextBlockIndentCalculator] used by tasks in `SuperEditor`. +double defaultTaskIndentCalculator(TextStyle textStyle, int indent) { + return (textStyle.fontSize! * 0.60) * 4 * indent; +} + +/// A document component that displays a complete-able task. +/// +/// This is the widget that appears in the document layout for +/// an individual task. This widget includes a checkbox that the +/// user can tap to toggle the completeness of the task. +/// +/// The appearance of a [TaskComponent] is configured by the given +/// [viewModel]. +class TaskComponent extends StatefulWidget { + const TaskComponent({ + Key? key, + required this.viewModel, + this.showDebugPaint = false, + }) : super(key: key); + + final TaskComponentViewModel viewModel; + final bool showDebugPaint; + + @override + State createState() => _TaskComponentState(); +} + +class _TaskComponentState extends State with ProxyDocumentComponent, ProxyTextComposable { + final _textKey = GlobalKey(); + + @override + GlobalKey> get childDocumentComponentKey => _textKey; + + @override + TextComposable get childTextComposable => childDocumentComponentKey.currentState as TextComposable; + + /// Computes the [TextStyle] for this task's inner [TextComponent]. + TextStyle _computeStyles(Set attributions) { + // Show a strikethrough across the entire task if it's complete. + final style = widget.viewModel.textStyleBuilder(attributions); + return widget.viewModel.isComplete + ? style.copyWith( + decoration: style.decoration == null + ? TextDecoration.lineThrough + : TextDecoration.combine([TextDecoration.lineThrough, style.decoration!]), + ) + : style; + } + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: widget.viewModel.textDirection, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: widget.viewModel.indentCalculator( + widget.viewModel.textStyleBuilder({}), + widget.viewModel.indent, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16, right: 4), + child: Checkbox( + visualDensity: Theme.of(context).visualDensity, + value: widget.viewModel.isComplete, + onChanged: widget.viewModel.setComplete != null + ? (newValue) { + widget.viewModel.setComplete!(newValue!); + } + : null, + ), + ), + Expanded( + child: TextComponent( + key: _textKey, + text: widget.viewModel.text, + textDirection: widget.viewModel.textDirection, + textAlign: widget.viewModel.textAlignment, + maxLines: widget.viewModel.maxLines, + overflow: widget.viewModel.overflow, + textStyleBuilder: _computeStyles, + inlineWidgetBuilders: widget.viewModel.inlineWidgetBuilders, + textSelection: widget.viewModel.selection, + selectionColor: widget.viewModel.selectionColor, + highlightWhenEmpty: widget.viewModel.highlightWhenEmpty, + underlines: widget.viewModel.createUnderlines(), + showDebugPaint: widget.showDebugPaint, + ), + ), + ], + ), + ); + } +} + +ExecutionInstruction enterToInsertNewTask({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + // We only care about ENTER. + if (keyEvent.logicalKey != LogicalKeyboardKey.enter && keyEvent.logicalKey != LogicalKeyboardKey.numpadEnter) { + return ExecutionInstruction.continueExecution; + } + + // We only care when the selection is collapsed to a caret. + final selection = editContext.composer.selection; + if (selection == null || !selection.isCollapsed) { + return ExecutionInstruction.continueExecution; + } + + // We only care about TaskNodes. + final node = editContext.document.getNodeById(selection.extent.nodeId); + if (node is! TaskNode) { + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + InsertNewlineAtCaretRequest(Editor.createNodeId()), + ]); + + return ExecutionInstruction.haltExecution; +} + +ExecutionInstruction backspaceToConvertTaskToParagraph({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + if (!editContext.composer.selection!.isCollapsed) { + return ExecutionInstruction.continueExecution; + } + + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); + if (node is! TaskNode) { + return ExecutionInstruction.continueExecution; + } + + if ((editContext.composer.selection!.extent.nodePosition as TextPosition).offset > 0) { + // The selection isn't at the beginning. + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + DeleteUpstreamAtBeginningOfNodeRequest(node), + ]); + + return ExecutionInstruction.haltExecution; +} + +ExecutionInstruction tabToIndentTask({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.tab) { + return ExecutionInstruction.continueExecution; + } + + if (HardwareKeyboard.instance.isShiftPressed) { + // Don't indent if Shift is pressed - that's for un-indenting. + return ExecutionInstruction.continueExecution; + } + + final selection = editContext.composer.selection; + if (selection == null) { + return ExecutionInstruction.continueExecution; + } + + if (selection.base.nodeId != selection.extent.nodeId) { + // Selection spans nodes, so even if this selection includes a task, + // it includes other stuff, too. So we can't treat this as a task indentation. + return ExecutionInstruction.continueExecution; + } + + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); + if (node is! TaskNode) { + return ExecutionInstruction.continueExecution; + } + + final taskAbove = editContext.document.getNodeBefore(node); + if (taskAbove == null) { + // No task above us, so we can't indent. + return ExecutionInstruction.continueExecution; + } + if (taskAbove is! TaskNode) { + // The node above isn't a task. We can't indent. + return ExecutionInstruction.continueExecution; + } + + final maxIndent = taskAbove.indent + 1; + if (node.indent >= maxIndent) { + // Can't indent any further. + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + IndentTaskRequest(node.id), + ]); + + return ExecutionInstruction.haltExecution; +} + +ExecutionInstruction shiftTabToUnIndentTask({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.tab) { + return ExecutionInstruction.continueExecution; + } + if (!HardwareKeyboard.instance.isShiftPressed) { + return ExecutionInstruction.continueExecution; + } + + final selection = editContext.composer.selection; + if (selection == null) { + return ExecutionInstruction.continueExecution; + } + + if (selection.base.nodeId != selection.extent.nodeId) { + // Selection spans nodes, so even if this selection includes a task, + // it includes other stuff, too. So we can't treat this as a task indentation. + return ExecutionInstruction.continueExecution; + } + + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); + if (node is! TaskNode) { + return ExecutionInstruction.continueExecution; + } + + if (node.indent == 0) { + // Can't un-indent any further. + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + UnIndentTaskRequest(node.id), + ]); + + return ExecutionInstruction.haltExecution; +} + +ExecutionInstruction backspaceToUnIndentTask({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + + final selection = editContext.composer.selection; + if (selection == null) { + return ExecutionInstruction.continueExecution; + } + + if (selection.base.nodeId != selection.extent.nodeId) { + // Selection spans nodes, so even if this selection includes a task, + // it includes other stuff, too. So we can't treat this as a task indentation. + return ExecutionInstruction.continueExecution; + } + + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); + if (node is! TaskNode) { + return ExecutionInstruction.continueExecution; + } + if ((editContext.composer.selection!.extent.nodePosition as TextPosition).offset > 0) { + // Backspace should only un-indent if the caret is at the start of the text. + return ExecutionInstruction.continueExecution; + } + + if (node.indent == 0) { + // Can't un-indent any further. + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + UnIndentTaskRequest(node.id), + ]); + + return ExecutionInstruction.haltExecution; +} + +/// An [EditCommand] that inserts a newline when the caret sits within a [TaskNode]. +/// +/// This command adds the following behaviors beyond the usual: +/// * When the caret is in the middle of a task, splits the task into two tasks. +/// +/// * When the caret is at the end of a task, inserts a new empty task, instead of an +/// empty paragraph. +/// +/// * Inserting a newline into an empty task converts it into a paragraph instead of +/// inserting a new task. +class InsertNewlineInTaskAtCaretCommand extends BaseInsertNewlineAtCaretCommand { + const InsertNewlineInTaskAtCaretCommand(this.newNodeId); + + /// {@macro newNodeId} + final String newNodeId; + + @override + void doInsertNewline( + EditContext context, + CommandExecutor executor, + DocumentPosition caretPosition, + NodePosition caretNodePosition, + ) { + final node = context.document.getNodeById(caretPosition.nodeId); + if (caretNodePosition is! TextNodePosition || node is! TaskNode) { + // We don't know how to deal with this kind of node. + return; + } + + if (node.text.isEmpty) { + // The task is empty. Convert it to a paragraph. + executor.executeCommand( + ConvertTaskToParagraphCommand(nodeId: node.id), + ); + return; + } + + executor + ..executeCommand( + SplitExistingTaskCommand( + nodeId: node.id, + splitOffset: caretNodePosition.offset, + newNodeId: newNodeId, + ), + ) + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ); + } +} + +class ChangeTaskCompletionRequest implements EditRequest { + ChangeTaskCompletionRequest({required this.nodeId, required this.isComplete}); + + final String nodeId; + final bool isComplete; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ChangeTaskCompletionRequest && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + isComplete == other.isComplete; + + @override + int get hashCode => nodeId.hashCode ^ isComplete.hashCode; +} + +class ChangeTaskCompletionCommand extends EditCommand { + ChangeTaskCompletionCommand({required this.nodeId, required this.isComplete}); + + final String nodeId; + final bool isComplete; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final taskNode = context.document.getNodeById(nodeId); + if (taskNode is! TaskNode) { + return; + } + + context.document.replaceNodeById( + taskNode.id, + taskNode.copyTaskWith(isComplete: isComplete), + ); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(nodeId), + ), + ]); + } +} + +class ConvertParagraphToTaskRequest implements EditRequest { + const ConvertParagraphToTaskRequest({ + required this.nodeId, + this.isComplete = false, + }); + + final String nodeId; + final bool isComplete; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ConvertParagraphToTaskRequest && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + isComplete == other.isComplete; + + @override + int get hashCode => nodeId.hashCode ^ isComplete.hashCode; +} + +class ConvertParagraphToTaskCommand extends EditCommand { + const ConvertParagraphToTaskCommand({ + required this.nodeId, + this.isComplete = false, + }); + + final String nodeId; + final bool isComplete; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final existingNode = document.getNodeById(nodeId); + if (existingNode is! ParagraphNode) { + editorOpsLog.warning( + "Tried to convert ParagraphNode with ID '$nodeId' to TaskNode, but that node has the wrong type: ${existingNode.runtimeType}"); + return; + } + + final taskNode = TaskNode( + id: existingNode.id, + text: existingNode.text, + isComplete: isComplete, + ); + + executor.executeCommand( + ReplaceNodeCommand(existingNodeId: existingNode.id, newNode: taskNode), + ); + } +} + +class ConvertTaskToParagraphRequest implements EditRequest { + const ConvertTaskToParagraphRequest({ + required this.nodeId, + this.paragraphMetadata, + }); + + final String nodeId; + final Map? paragraphMetadata; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ConvertTaskToParagraphRequest && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + paragraphMetadata == other.paragraphMetadata; + + @override + int get hashCode => nodeId.hashCode ^ paragraphMetadata.hashCode; +} + +class ConvertTaskToParagraphCommand extends EditCommand { + const ConvertTaskToParagraphCommand({ + required this.nodeId, + this.paragraphMetadata, + }); + + final String nodeId; + final Map? paragraphMetadata; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final node = document.getNodeById(nodeId); + final taskNode = node as TaskNode; + final newMetadata = Map.from(paragraphMetadata ?? {}); + newMetadata["blockType"] = paragraphAttribution; + + final newParagraphNode = ParagraphNode( + id: taskNode.id, + text: taskNode.text, + metadata: newMetadata, + ); + document.replaceNodeById(taskNode.id, newParagraphNode); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(taskNode.id), + ) + ]); + } +} + +class SplitExistingTaskRequest implements EditRequest { + const SplitExistingTaskRequest({ + required this.existingNodeId, + required this.splitOffset, + this.newNodeId, + }); + + final String existingNodeId; + final int splitOffset; + final String? newNodeId; +} + +class SplitExistingTaskCommand extends EditCommand { + const SplitExistingTaskCommand({ + required this.nodeId, + required this.splitOffset, + this.newNodeId, + }); + + final String nodeId; + final int splitOffset; + final String? newNodeId; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext editContext, CommandExecutor executor) { + final document = editContext.document; + final composer = editContext.find(Editor.composerKey); + final selection = composer.selection; + + // We only care when the caret sits at the end of a TaskNode. + if (selection == null || !selection.isCollapsed) { + return; + } + + // We only care about TaskNodes. + final node = document.getNodeById(selection.extent.nodeId); + if (node is! TaskNode) { + return; + } + + // Ensure the split offset is valid. + if (splitOffset < 0 || splitOffset > node.text.length + 1) { + return; + } + + final newTaskNode = TaskNode( + id: newNodeId ?? Editor.createNodeId(), + text: node.text.copyText(splitOffset), + isComplete: false, + ); + + // Remove the text after the caret from the currently selected TaskNode. + final updatedNode = node.copyTextNodeWith( + text: node.text.removeRegion(startOffset: splitOffset, endOffset: node.text.length), + ); + document.replaceNodeById(node.id, updatedNode); + + // Insert a new TextNode after the currently selected TaskNode. + document.insertNodeAfter(existingNodeId: updatedNode.id, newNode: newTaskNode); + + // Move the caret to the beginning of the new TaskNode. + final oldSelection = composer.selection; + final oldComposingRegion = composer.composingRegion.value; + final newSelection = DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newTaskNode.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ); + + composer.setSelectionWithReason(newSelection, SelectionReason.userInteraction); + composer.setComposingRegion(null); + + executor.logChanges([ + SplitTaskIntention.start(), + DocumentEdit( + NodeChangeEvent(node.id), + ), + DocumentEdit( + NodeInsertedEvent(newTaskNode.id, document.getNodeIndexById(newTaskNode.id)), + ), + SelectionChangeEvent( + oldSelection: oldSelection, + newSelection: newSelection, + changeType: SelectionChangeType.pushCaret, + reason: SelectionReason.userInteraction, + ), + ComposingRegionChangeEvent( + oldComposingRegion: oldComposingRegion, + newComposingRegion: null, + ), + SplitTaskIntention.end(), + ]); + } +} + +class SplitTaskIntention extends Intention { + SplitTaskIntention.start() : super.start(); + + SplitTaskIntention.end() : super.end(); +} + +class IndentTaskRequest implements EditRequest { + const IndentTaskRequest(this.nodeId); + + final String nodeId; +} + +class IndentTaskCommand extends EditCommand { + const IndentTaskCommand(this.nodeId); + + final String nodeId; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + final task = document.getNodeById(nodeId); + if (task is! TaskNode) { + // The specified node isn't a task. Nothing for us to indent. + return; + } + + final taskAbove = document.getNodeBefore(task); + if (taskAbove is! TaskNode) { + // There's no task above this task, therefore it can't be indented. + return; + } + + final maxIndent = taskAbove.indent + 1; + if (task.indent >= maxIndent) { + // This task is already at max indentation. + return; + } + + // Increase the task indentation. + document.replaceNodeById( + task.id, + task.copyTaskWith(indent: task.indent + 1), + ); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(task.id), + ), + ]); + } +} + +class UnIndentTaskRequest implements EditRequest { + const UnIndentTaskRequest(this.nodeId); + + final String nodeId; +} + +class UnIndentTaskCommand extends EditCommand { + const UnIndentTaskCommand(this.nodeId); + + final String nodeId; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + final task = document.getNodeById(nodeId); + if (task is! TaskNode) { + // The specified node isn't a task. Nothing for us to indent. + return; + } + + if (task.indent == 0) { + // This task is already at minimum indent. Nothing to do. + return; + } + + final subTasks = []; + var nextNode = document.getNodeAfter(task); + while (nextNode != null) { + final subTask = nextNode; + if (subTask is! TaskNode) { + break; + } + if (subTask.indent <= task.indent) { + break; + } + + subTasks.add(subTask); + nextNode = document.getNodeAfter(nextNode); + } + + final changeLog = []; + + // Decrease the task indentation of the desired task. + document.replaceNodeById( + task.id, + task.copyTaskWith(indent: task.indent - 1), + ); + + changeLog.add( + DocumentEdit( + NodeChangeEvent(task.id), + ), + ); + + // Decrease the indentation of the sub-tasks. + for (final subTask in subTasks) { + document.replaceNodeById( + subTask.id, + subTask.copyTaskWith(indent: subTask.indent - 1), + ); + + changeLog.add( + DocumentEdit( + NodeChangeEvent(subTask.id), + ), + ); + } + + // Log all changes. + executor.logChanges(changeLog); + } +} + +/// Sets the indent of the task with ID [nodeId] to the given [indent]. +/// +/// This request doesn't verify any rules about allowed indentation +/// levels. It blindly applies the indent. Therefore, this request should +/// only be issued from places that have already validated the result. +class SetTaskIndentRequest implements EditRequest { + const SetTaskIndentRequest(this.nodeId, this.indent); + + final String nodeId; + final int indent; +} + +class SetTaskIndentCommand extends EditCommand { + const SetTaskIndentCommand(this.nodeId, this.indent); + + final String nodeId; + final int indent; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + final task = document.getNodeById(nodeId); + if (task is! TaskNode) { + // The specified node isn't a task. Nothing for us to indent. + return; + } + + document.replaceNodeById( + task.id, + task.copyTaskWith(indent: indent), + ); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(task.id), + ), + ]); + } +} + +class UpdateSubTaskIndentAfterTaskDeletionReaction extends EditReaction { + @override + void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { + final didDeleteTask = changeList + .whereType() + .where((edit) => edit.change is NodeRemovedEvent && (edit.change as NodeRemovedEvent).removedNode is TaskNode) + .isNotEmpty; + if (!didDeleteTask) { + // No tasks were deleted, so there are no task indentations to fix. + return; + } + + // At least one task was deleted. We're not sure where in the document the + // tasks were before being deleted. Therefore, we check and fix every task + // indentation in the document. + final document = editorContext.document; + final changeIndentationRequests = []; + int maxIndentation = 0; + for (final node in document) { + if (node is! TaskNode) { + // This node isn't a task. The first task in a list of tasks + // can't have an indent, so reset the max indent back to zero. + maxIndentation = 0; + continue; + } + + if (node.indent > maxIndentation) { + // This task has an indent that's too deep. Fix it by + // settings its indent to the max allowed. + changeIndentationRequests.add( + SetTaskIndentRequest(node.id, maxIndentation), + ); + + // A task that follows this one is allowed (up to) the previous + // max + 1. + maxIndentation += 1; + continue; + } + + // This is a task with a legitimate indent. Update the + // max indent tracker based on this task's level. + maxIndentation = node.indent + 1; + } + + if (changeIndentationRequests.isEmpty) { + // No changes needed. + return; + } + + // Adjust all tasks with illegal indentations. + requestDispatcher.execute(changeIndentationRequests); + } +} diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index 3c73271f51..7c7556cfd1 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -4,64 +4,66 @@ import 'dart:collection'; import 'dart:math'; import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide SelectableText; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; -import 'package:super_editor/src/core/document_editor.dart'; +import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/core/styles.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/text_ai.dart'; +import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/composable_text.dart'; +import 'package:super_editor/src/infrastructure/flutter/geometry.dart'; +import 'package:super_editor/src/infrastructure/key_event_extensions.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; -import 'package:super_editor/src/infrastructure/raw_key_event_extensions.dart'; import 'package:super_editor/src/infrastructure/strings.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import 'document_input_keyboard.dart'; import 'layout_single_column/layout_single_column.dart'; +import 'multi_node_editing.dart'; +import 'paragraph.dart'; +import 'selection_upstream_downstream.dart'; import 'text_tools.dart'; -class TextNode extends DocumentNode with ChangeNotifier { +@immutable +class TextNode extends DocumentNode { TextNode({ required this.id, - required AttributedText text, - Map? metadata, - }) : _text = text { - this.metadata = metadata; - _text.addListener(notifyListeners); - } - - @override - void dispose() { - _text.removeListener(notifyListeners); - super.dispose(); - } + required this.text, + super.metadata, + }); @override final String id; - AttributedText _text; - /// The content text within this [TextNode]. - AttributedText get text => _text; - set text(AttributedText newText) { - if (newText != _text) { - _text.removeListener(notifyListeners); - _text = newText; - _text.addListener(notifyListeners); - - notifyListeners(); - } - } + final AttributedText text; @override TextNodePosition get beginningPosition => const TextNodePosition(offset: 0); @override - TextNodePosition get endPosition => TextNodePosition(offset: text.text.length); + TextNodePosition get endPosition => TextNodePosition(offset: text.length); + + @override + bool containsPosition(Object position) { + if (position is! TextNodePosition) { + return false; + } + + if (position.offset < 0 || position.offset > text.length) { + return false; + } + + return true; + } @override NodePosition selectUpstreamPosition(NodePosition position1, NodePosition position2) { @@ -87,6 +89,50 @@ class TextNode extends DocumentNode with ChangeNotifier { return position1.offset > position2.offset ? position1 : position2; } + /// Returns a [DocumentSelection] within this [TextNode] from [startIndex] to [endIndex]. + DocumentSelection selectionBetween(int startIndex, int endIndex) { + return DocumentSelection( + base: DocumentPosition( + nodeId: id, + nodePosition: TextNodePosition(offset: startIndex), + ), + extent: DocumentPosition( + nodeId: id, + nodePosition: TextNodePosition(offset: endIndex), + ), + ); + } + + /// Returns a collapsed [DocumentSelection], positioned within this [TextNode] at the + /// given [collapsedIndex]. + DocumentSelection selectionAt(int collapsedIndex) { + return DocumentSelection.collapsed( + position: positionAt(collapsedIndex), + ); + } + + /// Returns a [DocumentPosition] within this [TextNode] at the given text [index]. + DocumentPosition positionAt(int index) { + return DocumentPosition( + nodeId: id, + nodePosition: TextNodePosition(offset: index), + ); + } + + /// Returns a [DocumentRange] within this [TextNode] between [startIndex] and [endIndex]. + DocumentRange rangeBetween(int startIndex, int endIndex) { + return DocumentRange( + start: DocumentPosition( + nodeId: id, + nodePosition: TextNodePosition(offset: startIndex), + ), + end: DocumentPosition( + nodeId: id, + nodePosition: TextNodePosition(offset: endIndex), + ), + ); + } + @override TextNodeSelection computeSelection({ required NodePosition base, @@ -106,7 +152,7 @@ class TextNode extends DocumentNode with ChangeNotifier { String copyContent(dynamic selection) { assert(selection is TextSelection); - return (selection as TextSelection).textInside(text.text); + return (selection as TextSelection).textInside(text.toPlainText()); } @override @@ -114,16 +160,50 @@ class TextNode extends DocumentNode with ChangeNotifier { return other is TextNode && text == other.text && super.hasEquivalentContent(other); } + TextNode copyTextNodeWith({ + String? id, + AttributedText? text, + Map? metadata, + }) { + return TextNode( + id: id ?? this.id, + text: text ?? this.text, + metadata: metadata ?? this.metadata, + ); + } + + @override + DocumentNode copyAndReplaceMetadata(Map newMetadata) { + return copyTextNodeWith( + metadata: newMetadata, + ); + } + + @override + DocumentNode copyWithAddedMetadata(Map newProperties) { + return copyTextNodeWith( + metadata: {...metadata, ...newProperties}, + ); + } + + TextNode copy() { + return TextNode(id: id, text: text.copyText(0), metadata: Map.from(metadata)); + } + @override String toString() => '[TextNode] - text: $text, metadata: ${copyMetadata()}'; @override bool operator ==(Object other) => identical(this, other) || - super == other && other is TextNode && runtimeType == other.runtimeType && id == other.id && _text == other._text; + super == other && other is TextNode && runtimeType == other.runtimeType && id == other.id && text == other.text; @override - int get hashCode => super.hashCode ^ id.hashCode ^ _text.hashCode; + int get hashCode => super.hashCode ^ id.hashCode ^ text.hashCode; +} + +extension TextNodeExtensions on DocumentNode { + TextNode get asTextNode => this as TextNode; } extension DocumentSelectionWithText on Document { @@ -162,7 +242,7 @@ extension DocumentSelectionWithText on Document { } else if (textNode == nodes.first) { // Handle partial node selection in first node. startOffset = (nodeRange.start.nodePosition as TextPosition).offset; - endOffset = max(textNode.text.text.length - 1, 0); + endOffset = max(textNode.text.length - 1, 0); } else if (textNode == nodes.last) { // Handle partial node selection in last node. startOffset = 0; @@ -173,10 +253,10 @@ extension DocumentSelectionWithText on Document { } else { // Handle full node selection. startOffset = 0; - endOffset = max(textNode.text.text.length - 1, 0); + endOffset = max(textNode.text.length - 1, 0); } - final selectionRange = SpanRange(start: startOffset, end: endOffset); + final selectionRange = SpanRange(startOffset, endOffset); if (textNode.text.hasAttributionsWithin( attributions: attributions, @@ -188,6 +268,125 @@ extension DocumentSelectionWithText on Document { return false; } + + /// Returns all attributions that appear throughout the entirety of the selected range. + Set getAllAttributions(DocumentSelection selection) { + final attributions = {}; + + final nodes = getNodesInside(selection.base, selection.extent); + if (nodes.isEmpty) { + return attributions; + } + + // Calculate a DocumentRange so we know which DocumentPosition + // belongs to the first node, and which belongs to the last node. + final nodeRange = getRangeBetween(selection.base, selection.extent); + + for (final textNode in nodes) { + if (textNode is! TextNode) { + continue; + } + + int startOffset = -1; + int endOffset = -1; + + if (textNode == nodes.first && textNode == nodes.last) { + // Handle selection within a single node + final baseOffset = (selection.base.nodePosition as TextPosition).offset; + final extentOffset = (selection.extent.nodePosition as TextPosition).offset; + startOffset = baseOffset < extentOffset ? baseOffset : extentOffset; + endOffset = baseOffset < extentOffset ? extentOffset : baseOffset; + + // -1 because TextPosition's offset indexes the character after the + // selection, not the final character in the selection. + endOffset -= 1; + } else if (textNode == nodes.first) { + // Handle partial node selection in first node. + startOffset = (nodeRange.start.nodePosition as TextPosition).offset; + endOffset = max(textNode.text.length - 1, 0); + } else if (textNode == nodes.last) { + // Handle partial node selection in last node. + startOffset = 0; + + // -1 because TextPosition's offset indexes the character after the + // selection, not the final character in the selection. + endOffset = (nodeRange.end.nodePosition as TextPosition).offset - 1; + } else { + // Handle full node selection. + startOffset = 0; + endOffset = max(textNode.text.length - 1, 0); + } + + final selectionRange = SpanRange(startOffset, endOffset); + + final attributionsInRange = textNode // + .text + .getAllAttributionsThroughout(selectionRange); + + attributions.addAll(attributionsInRange); + } + return attributions; + } + + /// Returns all attributions of type [T] that appear throughout the entirety of the selected range. + Set getAttributionsByType(DocumentSelection selection) { + final attributions = {}; + + final nodes = getNodesInside(selection.base, selection.extent); + if (nodes.isEmpty) { + return attributions; + } + + // Calculate a DocumentRange so we know which DocumentPosition + // belongs to the first node, and which belongs to the last node. + final nodeRange = getRangeBetween(selection.base, selection.extent); + + for (final textNode in nodes) { + if (textNode is! TextNode) { + continue; + } + + int startOffset = -1; + int endOffset = -1; + + if (textNode == nodes.first && textNode == nodes.last) { + // Handle selection within a single node + final baseOffset = (selection.base.nodePosition as TextPosition).offset; + final extentOffset = (selection.extent.nodePosition as TextPosition).offset; + startOffset = baseOffset < extentOffset ? baseOffset : extentOffset; + endOffset = baseOffset < extentOffset ? extentOffset : baseOffset; + + // -1 because TextPosition's offset indexes the character after the + // selection, not the final character in the selection. + endOffset -= 1; + } else if (textNode == nodes.first) { + // Handle partial node selection in first node. + startOffset = (nodeRange.start.nodePosition as TextPosition).offset; + endOffset = max(textNode.text.length - 1, 0); + } else if (textNode == nodes.last) { + // Handle partial node selection in last node. + startOffset = 0; + + // -1 because TextPosition's offset indexes the character after the + // selection, not the final character in the selection. + endOffset = (nodeRange.end.nodePosition as TextPosition).offset - 1; + } else { + // Handle full node selection. + startOffset = 0; + endOffset = max(textNode.text.length - 1, 0); + } + + final selectionRange = SpanRange(startOffset, endOffset); + + final attributionsInRange = textNode // + .text + .getAllAttributionsThroughout(selectionRange) + .whereType(); + + attributions.addAll(attributionsInRange); + } + return attributions; + } } extension Words on String { @@ -263,6 +462,18 @@ class TextNodePosition extends TextPosition implements NodePosition { TextAffinity affinity = TextAffinity.downstream, }) : super(offset: offset, affinity: affinity); + @override + bool isEquivalentTo(NodePosition other) { + if (other is! TextNodePosition) { + return false; + } + + // Equivalency is determined by text offset. Affinity is ignored, because + // affinity doesn't alter the actual location in the text that a + // TextNodePosition refers to. + return offset == other.offset; + } + TextNodePosition copyWith({ int? offset, TextAffinity? affinity, @@ -291,15 +502,27 @@ class TextNodePosition extends TextPosition implements NodePosition { /// provides consistent application of text-based styling for all /// view models that add this mixin. mixin TextComponentViewModel on SingleColumnLayoutComponentViewModel { + AttributedText get text; + set text(AttributedText text); + AttributionStyleBuilder get textStyleBuilder; set textStyleBuilder(AttributionStyleBuilder styleBuilder); + InlineWidgetBuilderChain get inlineWidgetBuilders; + set inlineWidgetBuilders(InlineWidgetBuilderChain inlineWidgetBuildChain); + TextDirection get textDirection; set textDirection(TextDirection direction); TextAlign get textAlignment; set textAlignment(TextAlign alignment); + int? get maxLines; + set maxLines(int? maxLines); + + TextOverflow get overflow; + set overflow(TextOverflow overflow); + TextSelection? get selection; set selection(TextSelection? selection); @@ -309,19 +532,170 @@ mixin TextComponentViewModel on SingleColumnLayoutComponentViewModel { bool get highlightWhenEmpty; set highlightWhenEmpty(bool highlight); + /// The span of text that's currently sitting in the IME's composing region, + /// which is underlined by this component. + TextRange? composingRegion; + UnderlineStyle composingRegionUnderlineStyle = const StraightUnderlineStyle(); + + Set customUnderlines = {}; + CustomUnderlineStyles? customUnderlineStyles; + + /// Whether to underline the [composingRegion]. + /// + /// Showing the underline is optional because the behavior differs between + /// platforms, e.g., Mac shows an underline but Windows and Linux don't. + bool showComposingRegionUnderline = true; + + List spellingErrors = []; + UnderlineStyle spellingErrorUnderlineStyle = const SquiggleUnderlineStyle(); + + List grammarErrors = []; + UnderlineStyle grammarErrorUnderlineStyle = const SquiggleUnderlineStyle(color: Colors.blue); + + /// Given a [subclassInstance] of [TextComponentViewModel], copies all base-level text + /// properties from this [TextComponentViewModel] into the given [subclassInstance]. + /// + /// Every view model must implement the ability to copy. Without this method, every subclass + /// would have to repeat the same mapping of properties between the original view model to + /// the copied view model. Originally, that's what Super Editor did, but it became very + /// tedious, and it was error prone because it was easy to accidentally miss a property. + /// + /// From a copy perspective, mutability of view models is important because [TextComponentViewModel] + /// doesn't have a constructor, and because every subclass has different constructors. Therefore, + /// the one approach to consistently support copy is to mutate the parts of a view model + /// that a given class knows about, such as what you see in the implementation of this method. + @protected + TextComponentViewModel internalCopy(covariant TextComponentViewModel subclassInstance) { + subclassInstance + ..createdAt = createdAt + ..maxWidth = maxWidth + ..padding = padding + ..text = text.copy() + ..maxLines = maxLines + ..overflow = overflow + ..textStyleBuilder = textStyleBuilder + ..inlineWidgetBuilders = inlineWidgetBuilders + ..textDirection = textDirection + ..textAlignment = textAlignment + ..selection = selection + ..selectionColor = selectionColor + ..highlightWhenEmpty = highlightWhenEmpty + ..customUnderlines = Set.from(customUnderlines) + ..customUnderlineStyles = customUnderlineStyles?.copy() + ..spellingErrorUnderlineStyle = spellingErrorUnderlineStyle + ..spellingErrors = List.from(spellingErrors) + ..grammarErrorUnderlineStyle = grammarErrorUnderlineStyle + ..grammarErrors = List.from(grammarErrors) + ..composingRegion = composingRegion + ..showComposingRegionUnderline = showComposingRegionUnderline; + + return subclassInstance; + } + @override void applyStyles(Map styles) { super.applyStyles(styles); - textAlignment = styles["textAlign"] ?? textAlignment; + textAlignment = styles[Styles.textAlign] ?? textAlignment; + + maxLines = styles[Styles.maxLines]; + overflow = styles[Styles.overflow] ?? TextOverflow.clip; textStyleBuilder = (attributions) { - final baseStyle = styles["textStyle"] ?? noStyleBuilder({}); - final inlineTextStyler = styles["inlineTextStyler"] as AttributionStyleAdjuster; + final baseStyle = styles[Styles.textStyle] ?? noStyleBuilder({}); + final inlineTextStyler = styles[Styles.inlineTextStyler] as AttributionStyleAdjuster; return inlineTextStyler(attributions, baseStyle); }; + + inlineWidgetBuilders = styles[Styles.inlineWidgetBuilders] ?? []; + + customUnderlineStyles = styles[Styles.customUnderlineStyles]; + + composingRegionUnderlineStyle = styles[Styles.composingRegionUnderlineStyle] ?? composingRegionUnderlineStyle; + showComposingRegionUnderline = styles[Styles.showComposingRegionUnderline] ?? showComposingRegionUnderline; + + spellingErrorUnderlineStyle = styles[Styles.spellingErrorUnderlineStyle] ?? spellingErrorUnderlineStyle; + grammarErrorUnderlineStyle = styles[Styles.grammarErrorUnderlineStyle] ?? grammarErrorUnderlineStyle; + } + + List createUnderlines() { + return [ + for (final underline in customUnderlines) + Underlines( + style: customUnderlineStyles?.stylesByType[underline.type] ?? const StraightUnderlineStyle(), + underlines: [underline.textRange], + ), + if (composingRegion != null && showComposingRegionUnderline) + Underlines( + style: composingRegionUnderlineStyle, + underlines: [composingRegion!], + ), + if (spellingErrors.isNotEmpty) // + Underlines( + style: spellingErrorUnderlineStyle, + underlines: spellingErrors, + ), + if (grammarErrors.isNotEmpty) // + Underlines( + style: grammarErrorUnderlineStyle, + underlines: grammarErrors, + ), + ]; } + + bool textViewModelEquals(Object other) => + identical(this, other) || + super == other && + other is TextComponentViewModel && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + maxWidth == other.maxWidth && + padding == other.padding && + text == other.text && + textDirection == other.textDirection && + textAlignment == other.textAlignment && + maxLines == other.maxLines && + overflow == other.overflow && + selection == other.selection && + selectionColor == other.selectionColor && + highlightWhenEmpty == other.highlightWhenEmpty && + customUnderlineStyles == other.customUnderlineStyles && + spellingErrorUnderlineStyle == other.spellingErrorUnderlineStyle && + grammarErrorUnderlineStyle == other.grammarErrorUnderlineStyle && + composingRegion == other.composingRegion && + showComposingRegionUnderline == other.showComposingRegionUnderline && + const DeepCollectionEquality().equals(customUnderlines, other.customUnderlines) && + const DeepCollectionEquality().equals(spellingErrors, other.spellingErrors) && + const DeepCollectionEquality().equals(grammarErrors, other.grammarErrors); + + int get textViewModelHashCode => + super.hashCode ^ + nodeId.hashCode ^ + maxWidth.hashCode ^ + padding.hashCode ^ + text.hashCode ^ + textDirection.hashCode ^ + textAlignment.hashCode ^ + maxLines.hashCode ^ + overflow.hashCode ^ + selection.hashCode ^ + selectionColor.hashCode ^ + highlightWhenEmpty.hashCode ^ + customUnderlines.hashCode ^ + customUnderlineStyles.hashCode ^ + spellingErrorUnderlineStyle.hashCode ^ + spellingErrors.hashCode ^ + grammarErrorUnderlineStyle.hashCode ^ + grammarErrors.hashCode ^ + composingRegion.hashCode ^ + showComposingRegionUnderline.hashCode; +} + +/// Keys to access metadata that are specific to a [TextNode]. +class TextNodeMetadata { + /// The [TextAlign] of the [TextNode]. + static const String textAlign = 'textAlign'; } /// Document component that displays hint text when its content text @@ -332,28 +706,45 @@ class TextWithHintComponent extends StatefulWidget { const TextWithHintComponent({ Key? key, required this.text, + this.inlineWidgetBuilders = const [], this.hintText, + this.hintMaxLines, + this.hintOverflow = TextOverflow.ellipsis, this.hintStyleBuilder, this.textAlign, this.textDirection, + this.maxLines, + this.overflow = TextOverflow.clip, required this.textStyleBuilder, this.metadata = const {}, this.textSelection, this.selectionColor = Colors.lightBlueAccent, this.highlightWhenEmpty = false, + this.underlines = const [], this.showDebugPaint = false, }) : super(key: key); final AttributedText text; + + /// {@macro text_component_inline_widget_builders} + final InlineWidgetBuilderChain inlineWidgetBuilders; + final AttributedText? hintText; + final int? hintMaxLines; + final TextOverflow hintOverflow; final AttributionStyleBuilder? hintStyleBuilder; + final TextAlign? textAlign; final TextDirection? textDirection; + final int? maxLines; + final TextOverflow overflow; final AttributionStyleBuilder textStyleBuilder; final Map metadata; final TextSelection? textSelection; final Color selectionColor; final bool highlightWhenEmpty; + final List underlines; + final bool showDebugPaint; @override @@ -386,22 +777,28 @@ class _TextWithHintComponentState extends State Widget build(BuildContext context) { return Stack( children: [ - if (widget.text.text.isEmpty) + if (widget.text.isEmpty) IgnorePointer( child: Text.rich( - widget.hintText?.computeTextSpan(_styleBuilder) ?? const TextSpan(text: ''), + widget.hintText?.computeInlineSpan(context, _styleBuilder, []) ?? const TextSpan(text: ''), + maxLines: widget.hintMaxLines, + overflow: widget.hintOverflow, ), ), TextComponent( key: _childTextComponentKey, text: widget.text, + inlineWidgetBuilders: widget.inlineWidgetBuilders, textAlign: widget.textAlign, textDirection: widget.textDirection, + maxLines: widget.maxLines, + overflow: widget.overflow, textStyleBuilder: widget.textStyleBuilder, metadata: widget.metadata, textSelection: widget.textSelection, selectionColor: widget.selectionColor, highlightWhenEmpty: widget.highlightWhenEmpty, + underlines: widget.underlines, showDebugPaint: widget.showDebugPaint, ), ], @@ -418,29 +815,65 @@ class TextComponent extends StatefulWidget { required this.text, this.textAlign, this.textDirection, + this.textScaler, + this.maxLines, + this.overflow = TextOverflow.clip, required this.textStyleBuilder, + this.inlineWidgetBuilders = const [], this.metadata = const {}, this.textSelection, this.selectionColor = Colors.lightBlueAccent, this.highlightWhenEmpty = false, + this.underlines = const [], this.showDebugPaint = false, }) : super(key: key); final AttributedText text; + final TextAlign? textAlign; + final TextDirection? textDirection; + + /// The text scaling policy. + /// + /// Defaults to `MediaQuery.textScalerOf()`. + final TextScaler? textScaler; + + final int? maxLines; + + final TextOverflow overflow; + final AttributionStyleBuilder textStyleBuilder; + + /// {@template text_component_inline_widget_builders} + /// A Chain of Responsibility that's used to build inline widgets. + /// + /// The first builder in the chain to return a non-null `Widget` will be + /// used for a given inline placeholder. + /// {@endtemplate} + final InlineWidgetBuilderChain inlineWidgetBuilders; + final Map metadata; + final TextSelection? textSelection; + final Color selectionColor; + final bool highlightWhenEmpty; + + /// Groups of underlines. + /// + /// Each [Underlines] group contains some number of underlines, along with a style that + /// applies to those underlines. Multiple styles of underlines are displayed by providing + /// multiple [Underlines]. + final List underlines; + final bool showDebugPaint; @override TextComponentState createState() => TextComponentState(); } -@visibleForTesting class TextComponentState extends State with DocumentComponent implements TextComposable { final _textKey = GlobalKey(); @@ -456,9 +889,6 @@ class TextComponentState extends State with DocumentComponent imp // API for nearest position and then let clients pick the one that's // right for them. final textPosition = textLayout.getPositionNearestToOffset(localOffset); - // if (textPosition == null) { - // return null; - // } return TextNodePosition.fromTextPosition(textPosition); } @@ -471,6 +901,18 @@ class TextComponentState extends State with DocumentComponent imp return textLayout.getOffsetAtPosition(nodePosition); } + @override + Rect getEdgeForPosition(NodePosition nodePosition) { + if (nodePosition is! TextPosition) { + throw Exception('Expected nodePosition of type TextPosition but received: $nodePosition'); + } + + final textNodePosition = nodePosition as TextPosition; + final characterBox = getRectForPosition(textNodePosition); + + return textNodePosition.affinity == TextAffinity.upstream ? characterBox.leftEdge : characterBox.rightEdge; + } + @override Rect getRectForPosition(dynamic nodePosition) { if (nodePosition is! TextPosition) { @@ -495,8 +937,45 @@ class TextComponentState extends State with DocumentComponent imp baseOffset: baseNodePosition.offset, extentOffset: extentNodePosition.offset, ); - final boxes = textLayout.getBoxesForSelection(selection); + if (selection.isCollapsed) { + // A collapsed selection reports no boxes, but we want to return a rect at the + // selection's x-offset with a height that matches the text. Try to calculate + // a selection rectangle based on the character that's either after, or before, the + // collapsed selection position. + final rectForPosition = getRectForPosition(extentNodePosition); + if (rectForPosition.height > 0) { + return rectForPosition; + } + + TextBox? characterBox = textLayout.getCharacterBox(extentNodePosition); + if (characterBox != null) { + final rect = characterBox.toRect(); + return Rect.fromLTWH(rect.left, rect.top, 0, rect.height); + } + + // We didn't find a character at the given offset. That offset might be at the end + // of the text. Try looking one character upstream. + characterBox = extentNodePosition.offset > 0 + ? textLayout.getCharacterBox(TextPosition(offset: extentNodePosition.offset - 1)) + : null; + if (characterBox != null) { + final rect = characterBox.toRect(); + // Use the right side of the character because this is the character that appears + // BEFORE the position we want, which means the position we want is just after + // this character box. + return Rect.fromLTWH(rect.right, rect.top, 0, rect.height); + } + + // We couldn't find a character box, which means the text is empty. Return + // the caret height, or the estimated line height. + final caretHeight = textLayout.getHeightForCaret(selection.extent); + return caretHeight != null + ? Rect.fromLTWH(0, 0, 0, caretHeight) + : Rect.fromLTWH(0, 0, 0, textLayout.estimatedLineHeight); + } + + final boxes = textLayout.getBoxesForSelection(selection); Rect boundingBox = boxes.isNotEmpty ? boxes.first.toRect() : Rect.zero; for (int i = 1; i < boxes.length; ++i) { boundingBox = boundingBox.expandToInclude(boxes[i].toRect()); @@ -524,7 +1003,7 @@ class TextComponentState extends State with DocumentComponent imp return null; } - if (textPosition.offset > widget.text.text.length) { + if (textPosition.offset > widget.text.length) { // This text position does not represent a position within our text. return null; } @@ -545,6 +1024,8 @@ class TextComponentState extends State with DocumentComponent imp } return TextNodePosition(offset: newOffset); + } else if (movementModifier == MovementModifier.paragraph) { + return const TextNodePosition(offset: 0); } final newOffset = getAllText().moveOffsetUpstreamByCharacter(textPosition.offset); @@ -558,7 +1039,7 @@ class TextComponentState extends State with DocumentComponent imp return null; } - if (textPosition.offset >= widget.text.text.length) { + if (textPosition.offset >= widget.text.length) { // Can't move further right. return null; } @@ -571,7 +1052,8 @@ class TextComponentState extends State with DocumentComponent imp final TextPosition endPosition = getEndPosition(); // Note: we compare offset values because we don't care if the affinitys are equal - final isAutoWrapLine = endOfLine.offset != endPosition.offset && (widget.text.text[endOfLine.offset] != '\n'); + final isAutoWrapLine = + endOfLine.offset != endPosition.offset && (widget.text.toPlainText()[endOfLine.offset] != '\n'); // Note: For lines that auto-wrap, moving the cursor to `offset` causes the // cursor to jump to the next line because the cursor is placed after @@ -589,13 +1071,15 @@ class TextComponentState extends State with DocumentComponent imp ? TextNodePosition(offset: endOfLine.offset - 1) : TextNodePosition.fromTextPosition(endOfLine); } - if (movementModifier != null && movementModifier == MovementModifier.word) { + if (movementModifier == MovementModifier.word) { final newOffset = getAllText().moveOffsetDownstreamByWord(textPosition.offset); if (newOffset == null) { return textPosition; } return TextNodePosition(offset: newOffset); + } else if (movementModifier == MovementModifier.paragraph) { + return TextNodePosition(offset: getAllText().length); } final newOffset = getAllText().moveOffsetDownstreamByCharacter(textPosition.offset); @@ -609,7 +1093,7 @@ class TextComponentState extends State with DocumentComponent imp return null; } - if (textNodePosition.offset < 0 || textNodePosition.offset > widget.text.text.length) { + if (textNodePosition.offset < 0 || textNodePosition.offset > widget.text.length) { // This text position does not represent a position within our text. return null; } @@ -628,7 +1112,7 @@ class TextComponentState extends State with DocumentComponent imp return null; } - if (textNodePosition.offset < 0 || textNodePosition.offset > widget.text.text.length) { + if (textNodePosition.offset < 0 || textNodePosition.offset > widget.text.length) { // This text position does not represent a position within our text. return null; } @@ -642,7 +1126,7 @@ class TextComponentState extends State with DocumentComponent imp @override TextNodePosition getEndPosition() { - return TextNodePosition(offset: widget.text.text.length); + return TextNodePosition(offset: widget.text.length); } @override @@ -686,7 +1170,7 @@ class TextComponentState extends State with DocumentComponent imp TextNodeSelection getSelectionOfEverything() { return TextNodeSelection( baseOffset: 0, - extentOffset: widget.text.text.length, + extentOffset: widget.text.length, ); } @@ -697,12 +1181,12 @@ class TextComponentState extends State with DocumentComponent imp @override String getAllText() { - return widget.text.text; + return widget.text.toPlainText(); } @override String getContiguousTextAt(TextNodePosition textPosition) { - return getContiguousTextSelectionAt(textPosition).textInside(widget.text.text); + return getContiguousTextSelectionAt(textPosition).textInside(widget.text.toPlainText()); } @override @@ -714,7 +1198,7 @@ class TextComponentState extends State with DocumentComponent imp @override TextNodeSelection getContiguousTextSelectionAt(TextNodePosition textPosition) { - final text = widget.text.text; + final text = widget.text.toPlainText(); if (text.isEmpty) { return const TextNodeSelection.collapsed(offset: -1); } @@ -773,24 +1257,76 @@ class TextComponentState extends State with DocumentComponent imp ); } + /// Return the [TextStyle] for the character at [offset]. + /// + /// If the caret sits at the beginning of the text, the style + /// of the first character is returned. + /// + /// If the caret sits at the end of the text, the style + /// of the last character is returned. + /// + /// If the text is empty, the style computed by the widget's `textStyleBuilder` + /// without any attributions is returned. + TextStyle getTextStyleAt(int offset) { + final attributions = widget.text.getAllAttributionsAt(offset < widget.text.length // + ? offset + : widget.text.length - 1); + + return _textStyleWithBlockType(attributions); + } + + TextAlign? get textAlign => widget.textAlign; + + TextDirection? get textDirection => widget.textDirection; + @override Widget build(BuildContext context) { editorLayoutLog.finer('Building a TextComponent with key: ${widget.key}'); return IgnorePointer( - child: SuperTextWithSelection.single( + child: SuperText( key: _textKey, - richText: widget.text.computeTextSpan(_textStyleWithBlockType), + richText: widget.text.computeInlineSpan( + context, + _textStyleWithBlockType, + widget.inlineWidgetBuilders, + ), textAlign: widget.textAlign ?? TextAlign.left, textDirection: widget.textDirection ?? TextDirection.ltr, - userSelection: UserSelection( - highlightStyle: SelectionHighlightStyle( - color: widget.selectionColor, - ), - selection: widget.textSelection ?? const TextSelection.collapsed(offset: -1), - highlightWhenEmpty: widget.highlightWhenEmpty, - hasCaret: false, - ), + textScaler: widget.textScaler ?? MediaQuery.textScalerOf(context), + maxLines: widget.maxLines, + overflow: widget.overflow, + layerBeneathBuilder: (context, textLayout) { + return Stack( + children: [ + // Selection highlight beneath the text. + if (widget.text.length > 0) + TextLayoutSelectionHighlight( + textLayout: textLayout, + style: SelectionHighlightStyle( + color: widget.selectionColor, + ), + selection: widget.textSelection ?? const TextSelection.collapsed(offset: -1), + ) + else if (widget.highlightWhenEmpty) + TextLayoutEmptyHighlight( + textLayout: textLayout, + style: SelectionHighlightStyle( + color: widget.selectionColor, + ), + ), + for (final underlines in widget.underlines) + TextUnderlineLayer( + textLayout: textLayout, + style: underlines.style, + underlines: [ + for (final range in underlines.underlines) // + TextLayoutUnderline(range: range), + ], + ), + ], + ); + }, ), ); } @@ -808,34 +1344,240 @@ class TextComponentState extends State with DocumentComponent imp } } -// TODO: the add/remove/toggle commands are almost identical except for what they -// do to ranges of text. Pull out the common range calculation behavior. -/// Applies the given `attributions` to the given `documentSelection`. -class AddTextAttributionsCommand implements EditorCommand { - AddTextAttributionsCommand({ - required this.documentSelection, - required this.attributions, +/// The default priority list of inline widget builders, which map [AttributedText] +/// placeholders to widgets. +const defaultInlineWidgetBuilderChain = [ + inlineNetworkImageBuilder, + inlineAssetImageBuilder, +]; + +/// An inline widget builder that displays an image from the network. +Widget? inlineNetworkImageBuilder(BuildContext context, TextStyle textStyle, Object placeholder) { + if (placeholder is! InlineNetworkImagePlaceholder) { + return null; + } + + return LineHeight( + style: textStyle, + child: Image.network(placeholder.url), + ); +} + +/// An inline widget builder that displays an image from local assets. +Widget? inlineAssetImageBuilder(BuildContext context, TextStyle textStyle, Object placeholder) { + if (placeholder is! InlineAssetImagePlaceholder) { + return null; + } + + return LineHeight( + style: textStyle, + child: Image.asset(placeholder.assetPath), + ); +} + +/// A widget that sets its [child]'s height to the line-height of a given text [style]. +class LineHeight extends StatefulWidget { + const LineHeight({ + super.key, + required this.style, + required this.child, }); - final DocumentSelection documentSelection; - final Set attributions; + final TextStyle style; + final Widget child; @override - void execute(Document document, DocumentEditorTransaction transaction) { - editorDocLog.info('Executing AddTextAttributionsCommand'); - final nodes = document.getNodesInside(documentSelection.base, documentSelection.extent); - if (nodes.isEmpty) { - editorDocLog.shout(' - Bad DocumentSelection. Could not get range of nodes. Selection: $documentSelection'); - return; - } + State createState() => _LineHeightState(); +} - // Calculate a DocumentRange so we know which DocumentPosition - // belongs to the first node, and which belongs to the last node. - final nodeRange = document.getRangeBetween(documentSelection.base, documentSelection.extent); - editorDocLog.info(' - node range: $nodeRange'); +class _LineHeightState extends State { + late double _lineHeight; - // ignore: prefer_collection_literals - final nodesAndSelections = LinkedHashMap(); + @override + void initState() { + super.initState(); + _calculateLineHeight(); + } + + @override + void didUpdateWidget(LineHeight oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.style != oldWidget.style) { + _calculateLineHeight(); + } + } + + void _calculateLineHeight() { + final textPainter = TextPainter( + text: TextSpan(text: "a", style: widget.style), + textDirection: TextDirection.ltr, + )..layout(); + + _lineHeight = textPainter.height; + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _lineHeight, + child: widget.child, + ); + } +} + +/// A widget that sets its [child]'s width and height to the line-height of a +/// given text [style]. +class LineHeightSquare extends StatefulWidget { + const LineHeightSquare({ + super.key, + required this.style, + required this.child, + }); + + final TextStyle style; + final Widget child; + + @override + State createState() => _LineHeightSquareState(); +} + +class _LineHeightSquareState extends State { + late double _lineHeight; + + @override + void initState() { + super.initState(); + _calculateLineHeight(); + } + + @override + void didUpdateWidget(LineHeightSquare oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.style != oldWidget.style) { + _calculateLineHeight(); + } + } + + void _calculateLineHeight() { + final textPainter = TextPainter( + text: TextSpan(text: "a", style: widget.style), + textDirection: TextDirection.ltr, + )..layout(); + + _lineHeight = textPainter.height; + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _lineHeight, + height: _lineHeight, + child: widget.child, + ); + } +} + +/// A [ProxyDocumentComponent] that adds [TextComposable] capabilities so +/// that simple text-based proxy components can meet their expected contract +/// without going through the work of defining a stateful widget that mixes in +/// the [ProxyDocumentComponent] methods. +/// +/// Using a [ProxyTextDocumentComponent] is never technically necessary. +/// Custom [DocumentComponent]s can achieve a similar result by mixing in +/// [ProxyDocumentComponent] within a `State` object. This widget is provided +/// as a convenience so that some components can be defined as stateless +/// widgets while still providing access to component behaviors and text layout queries. +class ProxyTextDocumentComponent extends StatefulWidget { + const ProxyTextDocumentComponent({ + super.key, + required this.textComponentKey, + required this.child, + }); + + final GlobalKey textComponentKey; + + /// The widget subtree, which must include a widget that implements `TextComposable`, + /// and that `TextComposable` must be bound to the given [textComponentKey]. + final Widget child; + + @override + State createState() => _ProxyTextDocumentComponentState(); +} + +class _ProxyTextDocumentComponentState extends State + with ProxyDocumentComponent, ProxyTextComposable { + @override + GlobalKey> get childDocumentComponentKey => widget.textComponentKey; + + @override + TextComposable get childTextComposable => childDocumentComponentKey.currentState as TextComposable; + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// A group of text ranges that should be displayed with underlines, along with the [style] +/// of those underlines. +class Underlines { + const Underlines({ + required this.style, + required this.underlines, + }); + + final UnderlineStyle style; + final List underlines; +} + +class AddTextAttributionsRequest implements EditRequest { + AddTextAttributionsRequest({ + required this.documentRange, + required this.attributions, + this.autoMerge = true, + }); + + final DocumentRange documentRange; + final Set attributions; + final bool autoMerge; +} + +// TODO: the add/remove/toggle commands are almost identical except for what they +// do to ranges of text. Pull out the common range calculation behavior. +/// Applies the given `attributions` to the given `documentSelection`. +class AddTextAttributionsCommand extends EditCommand { + AddTextAttributionsCommand({ + required this.documentRange, + required this.attributions, + this.autoMerge = true, + }); + + final DocumentRange documentRange; + final Set attributions; + final bool autoMerge; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + editorDocLog.info('Executing AddTextAttributionsCommand'); + final document = context.document; + final nodes = document.getNodesInside(documentRange.start, documentRange.end); + if (nodes.isEmpty) { + editorDocLog.shout(' - Bad DocumentSelection. Could not get range of nodes. Selection: $documentRange'); + return; + } + + // Calculate a normalized DocumentRange so we know which DocumentPosition + // belongs to the first node, and which belongs to the last node. + final normalRange = documentRange.normalize(document); + editorDocLog.info(' - node range: $normalRange'); + + // ignore: prefer_collection_literals + final nodesAndSelections = LinkedHashMap(); for (final textNode in nodes) { if (textNode is! TextNode) { @@ -848,19 +1590,17 @@ class AddTextAttributionsCommand implements EditorCommand { if (textNode == nodes.first && textNode == nodes.last) { // Handle selection within a single node editorDocLog.info(' - the selection is within a single node: ${textNode.id}'); - final baseOffset = (documentSelection.base.nodePosition as TextPosition).offset; - final extentOffset = (documentSelection.extent.nodePosition as TextPosition).offset; - startOffset = baseOffset < extentOffset ? baseOffset : extentOffset; - endOffset = baseOffset < extentOffset ? extentOffset : baseOffset; + + startOffset = (normalRange.start.nodePosition as TextPosition).offset; // -1 because TextPosition's offset indexes the character after the // selection, not the final character in the selection. - endOffset -= 1; + endOffset = (normalRange.end.nodePosition as TextPosition).offset - 1; } else if (textNode == nodes.first) { // Handle partial node selection in first node. editorDocLog.info(' - selecting part of the first node: ${textNode.id}'); - startOffset = (nodeRange.start.nodePosition as TextPosition).offset; - endOffset = max(textNode.text.text.length - 1, 0); + startOffset = (normalRange.start.nodePosition as TextPosition).offset; + endOffset = max(textNode.text.length - 1, 0); } else if (textNode == nodes.last) { // Handle partial node selection in last node. editorDocLog.info(' - adding part of the last node: ${textNode.id}'); @@ -868,12 +1608,12 @@ class AddTextAttributionsCommand implements EditorCommand { // -1 because TextPosition's offset indexes the character after the // selection, not the final character in the selection. - endOffset = (nodeRange.end.nodePosition as TextPosition).offset - 1; + endOffset = (normalRange.end.nodePosition as TextPosition).offset - 1; } else { // Handle full node selection. editorDocLog.info(' - adding full node: ${textNode.id}'); startOffset = 0; - endOffset = max(textNode.text.text.length - 1, 0); + endOffset = max(textNode.text.length - 1, 0); } final selectionRange = TextRange(start: startOffset, end: endOffset); @@ -890,15 +1630,37 @@ class AddTextAttributionsCommand implements EditorCommand { // Create a new AttributedText with updated attribution spans, so that the presentation system can // see that we made a change, and re-renders the text in the document. - node.text = AttributedText( - text: node.text.text, - spans: node.text.spans.copy() - ..addAttribution( - newAttribution: attribution, - start: range.start, - end: range.end, + document.replaceNodeById( + node.id, + node.copyTextNodeWith( + text: AttributedText( + node.text.toPlainText( + // Don't include placeholder characters, because we're providing + // actual placeholders down below. + includePlaceholders: false, + ), + node.text.spans.copy() + ..addAttribution( + newAttribution: attribution, + start: range.start, + end: range.end, + autoMerge: autoMerge, + ), + Map.from(node.text.placeholders), ), + ), ); + + executor.logChanges([ + DocumentEdit( + AttributionChangeEvent( + nodeId: node.id, + change: AttributionChange.added, + range: range, + attributions: attributions, + ), + ), + ]); } } @@ -906,29 +1668,43 @@ class AddTextAttributionsCommand implements EditorCommand { } } +class RemoveTextAttributionsRequest implements EditRequest { + RemoveTextAttributionsRequest({ + required this.documentRange, + required this.attributions, + }); + + final DocumentRange documentRange; + final Set attributions; +} + /// Removes the given `attributions` from the given `documentSelection`. -class RemoveTextAttributionsCommand implements EditorCommand { +class RemoveTextAttributionsCommand extends EditCommand { RemoveTextAttributionsCommand({ - required this.documentSelection, + required this.documentRange, required this.attributions, }); - final DocumentSelection documentSelection; + final DocumentRange documentRange; final Set attributions; @override - void execute(Document document, DocumentEditorTransaction transaction) { + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { editorDocLog.info('Executing RemoveTextAttributionsCommand'); - final nodes = document.getNodesInside(documentSelection.base, documentSelection.extent); + final document = context.document; + final nodes = document.getNodesInside(documentRange.start, documentRange.end); if (nodes.isEmpty) { - editorDocLog.shout(' - Bad DocumentSelection. Could not get range of nodes. Selection: $documentSelection'); + editorDocLog.shout(' - Bad DocumentSelection. Could not get range of nodes. Selection: $documentRange'); return; } - // Calculate a DocumentRange so we know which DocumentPosition + // Normalize the DocumentRange so we know which DocumentPosition // belongs to the first node, and which belongs to the last node. - final nodeRange = document.getRangeBetween(documentSelection.base, documentSelection.extent); - editorDocLog.info(' - node range: $nodeRange'); + final normalizedRange = documentRange.normalize(document); + editorDocLog.info(' - node range: $normalizedRange'); // ignore: prefer_collection_literals final nodesAndSelections = LinkedHashMap(); @@ -944,19 +1720,20 @@ class RemoveTextAttributionsCommand implements EditorCommand { if (textNode == nodes.first && textNode == nodes.last) { // Handle selection within a single node editorDocLog.info(' - the selection is within a single node: ${textNode.id}'); - final baseOffset = (documentSelection.base.nodePosition as TextPosition).offset; - final extentOffset = (documentSelection.extent.nodePosition as TextPosition).offset; - startOffset = baseOffset < extentOffset ? baseOffset : extentOffset; - endOffset = baseOffset < extentOffset ? extentOffset : baseOffset; - // -1 because TextPosition's offset indexes the character after the - // selection, not the final character in the selection. - endOffset -= 1; + startOffset = (normalizedRange.start.nodePosition as TextPosition).offset; + + endOffset = normalizedRange.start != normalizedRange.end + // -1 because TextPosition's offset indexes the character after the + // selection, not the final character in the selection. + ? (normalizedRange.end.nodePosition as TextPosition).offset - 1 + // The selection is collapsed. Don't decrement the offset. + : startOffset; } else if (textNode == nodes.first) { // Handle partial node selection in first node. editorDocLog.info(' - selecting part of the first node: ${textNode.id}'); - startOffset = (nodeRange.start.nodePosition as TextPosition).offset; - endOffset = max(textNode.text.text.length - 1, 0); + startOffset = (normalizedRange.start.nodePosition as TextPosition).offset; + endOffset = max(textNode.text.length - 1, 0); } else if (textNode == nodes.last) { // Handle partial node selection in last node. editorDocLog.info(' - adding part of the last node: ${textNode.id}'); @@ -964,12 +1741,12 @@ class RemoveTextAttributionsCommand implements EditorCommand { // -1 because TextPosition's offset indexes the character after the // selection, not the final character in the selection. - endOffset = (nodeRange.end.nodePosition as TextPosition).offset - 1; + endOffset = (normalizedRange.end.nodePosition as TextPosition).offset - 1; } else { // Handle full node selection. editorDocLog.info(' - adding full node: ${textNode.id}'); startOffset = 0; - endOffset = max(textNode.text.text.length - 1, 0); + endOffset = max(textNode.text.length - 1, 0); } final selectionRange = TextRange(start: startOffset, end: endOffset); @@ -977,61 +1754,103 @@ class RemoveTextAttributionsCommand implements EditorCommand { nodesAndSelections.putIfAbsent(textNode, () => selectionRange); } - // Add attributions. + // Remove attributions. for (final entry in nodesAndSelections.entries) { + var node = entry.key; + final range = entry.value.toSpanRange(); + for (Attribution attribution in attributions) { - final node = entry.key; - final range = entry.value.toSpanRange(); editorDocLog.info(' - removing attribution: $attribution. Range: $range'); // Create a new AttributedText with updated attribution spans, so that the presentation system can // see that we made a change, and re-renders the text in the document. - node.text = AttributedText( - text: node.text.text, - spans: node.text.spans.copy() - ..removeAttribution( - attributionToRemove: attribution, - start: range.start, - end: range.end, + node = node.copyTextNodeWith( + text: AttributedText( + node.text.toPlainText( + // Don't include placeholder characters, because we're providing + // actual placeholders down below. + includePlaceholders: false, ), + node.text.spans.copy() + ..removeAttribution( + attributionToRemove: attribution, + start: range.start, + end: range.end, + ), + Map.from(node.text.placeholders), + ), ); + + executor.logChanges([ + DocumentEdit( + AttributionChangeEvent( + nodeId: node.id, + change: AttributionChange.removed, + range: range, + attributions: attributions, + ), + ), + ]); } + + // Now that attribution changes are done for the given node, replace + // the existing document node with the updated node. + document.replaceNodeById(node.id, node); } editorDocLog.info(' - done adding attributions'); } } +class ToggleTextAttributionsRequest implements EditRequest { + ToggleTextAttributionsRequest({ + required this.documentRange, + required this.attributions, + }); + + final DocumentRange documentRange; + final Set attributions; +} + /// Applies the given `attributions` to the given `documentSelection`, /// if none of the content in the selection contains any of the /// given `attributions`. Otherwise, all the given `attributions` /// are removed from the content within the `documentSelection`. -class ToggleTextAttributionsCommand implements EditorCommand { +class ToggleTextAttributionsCommand extends EditCommand { ToggleTextAttributionsCommand({ - required this.documentSelection, + required this.documentRange, required this.attributions, }); - final DocumentSelection documentSelection; + final DocumentRange documentRange; final Set attributions; @override - void execute(Document document, DocumentEditorTransaction transaction) { + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + // TODO: The structure of this command looks nearly identical to the two other attribution + // commands above. We collect nodes and then we loop through them to apply an operation. + // Try to de-dup this code. Maybe use a private base class called ChangeTextAttributionsCommand + // and provide a hook for the specific operation: add, remove, toggle. + @override + void execute(EditContext context, CommandExecutor executor) { editorDocLog.info('Executing ToggleTextAttributionsCommand'); - final nodes = document.getNodesInside(documentSelection.base, documentSelection.extent); + final document = context.document; + final nodes = document.getNodesInside(documentRange.start, documentRange.end); if (nodes.isEmpty) { - editorDocLog.shout(' - Bad DocumentSelection. Could not get range of nodes. Selection: $documentSelection'); + editorDocLog.shout(' - Bad DocumentSelection. Could not get range of nodes. Selection: $documentRange'); return; } - // Calculate a DocumentRange so we know which DocumentPosition + // Normalize DocumentRange so we know which DocumentPosition // belongs to the first node, and which belongs to the last node. - final nodeRange = document.getRangeBetween(documentSelection.base, documentSelection.extent); - editorDocLog.info(' - node range: $nodeRange'); + final normalizedRange = documentRange.normalize(document); + editorDocLog.info(' - node range: $normalizedRange'); // ignore: prefer_collection_literals final nodesAndSelections = LinkedHashMap(); - bool alreadyHasAttributions = false; + + bool alreadyHasAttributions = true; for (final textNode in nodes) { if (textNode is! TextNode) { @@ -1044,19 +1863,25 @@ class ToggleTextAttributionsCommand implements EditorCommand { if (textNode == nodes.first && textNode == nodes.last) { // Handle selection within a single node editorDocLog.info(' - the selection is within a single node: ${textNode.id}'); - final baseOffset = (documentSelection.base.nodePosition as TextPosition).offset; - final extentOffset = (documentSelection.extent.nodePosition as TextPosition).offset; - startOffset = baseOffset < extentOffset ? baseOffset : extentOffset; - endOffset = baseOffset < extentOffset ? extentOffset : baseOffset; + + startOffset = (normalizedRange.start.nodePosition as TextPosition).offset; // -1 because TextPosition's offset indexes the character after the // selection, not the final character in the selection. - endOffset -= 1; + endOffset = (normalizedRange.end.nodePosition as TextPosition).offset - 1; } else if (textNode == nodes.first) { // Handle partial node selection in first node. editorDocLog.info(' - selecting part of the first node: ${textNode.id}'); - startOffset = (nodeRange.start.nodePosition as TextPosition).offset; - endOffset = max(textNode.text.text.length - 1, 0); + startOffset = (normalizedRange.start.nodePosition as TextPosition).offset; + endOffset = max(textNode.text.length - 1, 0); + + if (startOffset >= textNode.text.length) { + // The range spans multiple nodes, starting at the end of the first node of the + // range. From the first node's perspective, this is equivalent to a collapsed + // selection at the end of the node. There's no text to toggle any attributions. + // Skip this node. + continue; + } } else if (textNode == nodes.last) { // Handle partial node selection in last node. editorDocLog.info(' - toggling part of the last node: ${textNode.id}'); @@ -1064,18 +1889,26 @@ class ToggleTextAttributionsCommand implements EditorCommand { // -1 because TextPosition's offset indexes the character after the // selection, not the final character in the selection. - endOffset = (nodeRange.end.nodePosition as TextPosition).offset - 1; + endOffset = (normalizedRange.end.nodePosition as TextPosition).offset - 1; + + if (endOffset <= 0) { + // The range spans multiple nodes, ending at the beginning of the last node of the + // range. From the last node's perspective, this is equivalent to a collapsed + // selection at the beginning of the node. There's no text to toggle any attributions. + // Skip this node. + continue; + } } else { // Handle full node selection. editorDocLog.info(' - toggling full node: ${textNode.id}'); startOffset = 0; - endOffset = max(textNode.text.text.length - 1, 0); + endOffset = max(textNode.text.length - 1, 0); } - final selectionRange = SpanRange(start: startOffset, end: endOffset); + final selectionRange = SpanRange(startOffset, endOffset); - alreadyHasAttributions = alreadyHasAttributions || - textNode.text.hasAttributionsWithin( + alreadyHasAttributions = alreadyHasAttributions && + textNode.text.hasAttributionsThroughout( attributions: attributions, range: selectionRange, ); @@ -1083,103 +1916,1123 @@ class ToggleTextAttributionsCommand implements EditorCommand { nodesAndSelections.putIfAbsent(textNode, () => selectionRange); } - // Toggle attributions. for (final entry in nodesAndSelections.entries) { + var node = entry.key; + final range = entry.value; + for (Attribution attribution in attributions) { - final node = entry.key; - final range = entry.value; editorDocLog.info(' - toggling attribution: $attribution. Range: $range'); - // Create a new AttributedText with updated attribution spans, so that the presentation system can - // see that we made a change, and re-renders the text in the document. - node.text = AttributedText( - text: node.text.text, - spans: node.text.spans.copy() - ..toggleAttribution( - attribution: attribution, - start: range.start, - end: range.end, + if (alreadyHasAttributions) { + // Attribution is present throughout the user selection. Remove attribution. + editorDocLog.info(' - Removing attribution: $attribution. Range: $range'); + + // Create a new AttributedText with updated attribution spans, so that the presentation system can + // see that we made a change, and re-renders the text in the document. + node = node.copyTextNodeWith( + text: node.text.copy() // + ..removeAttribution( + attribution, + range, + ), + ); + } else { + // Attribution isn't present throughout the user selection. Apply attribution. + editorDocLog.info(' - Adding attribution: $attribution. Range: $range'); + + // Create a new AttributedText with updated attribution spans, so that the presentation system can + // see that we made a change, and re-renders the text in the document. + node = node.copyTextNodeWith( + text: node.text.copy() // + ..addAttribution( + attribution, + range, + autoMerge: true, + // FIXME: I noticed that the default value for overwriteConflictingSpans on + // AttributedText.addAttribution is `false`, but the default on AttributedSpans.addAttribution() + // is `true`. This seems like a likely bug. Should they actually be different? If not, + // update one of them. If so, add a comment to both places mentioning why. + overwriteConflictingSpans: true, + ), + ); + } + + final wasAttributionAdded = node.text.hasAttributionAt(range.start, attribution: attribution); + executor.logChanges([ + DocumentEdit( + AttributionChangeEvent( + nodeId: node.id, + change: wasAttributionAdded ? AttributionChange.added : AttributionChange.removed, + range: range, + attributions: attributions, ), - ); + ), + ]); } + + // Now that all attributions have been applied to the node, replace the + // old node in the Document with the updated node. + document.replaceNodeById(node.id, node); } editorDocLog.info(' - done toggling attributions'); } } -class InsertTextCommand implements EditorCommand { - InsertTextCommand({ - required this.documentPosition, - required this.textToInsert, +/// A [NodeChangeEvent] for the addition or removal of a set of attributions. +class AttributionChangeEvent extends NodeChangeEvent { + AttributionChangeEvent({ + required String nodeId, + required this.change, + required this.range, required this.attributions, - }) : assert(documentPosition.nodePosition is TextPosition); + }) : super(nodeId); - final DocumentPosition documentPosition; - final String textToInsert; + final AttributionChange change; + final SpanRange range; final Set attributions; @override - void execute(Document document, DocumentEditorTransaction transaction) { - final textNode = document.getNodeById(documentPosition.nodeId); - if (textNode is! TextNode) { - editorDocLog.shout('ERROR: can\'t insert text in a node that isn\'t a TextNode: $textNode'); + String describe() => + "${change == AttributionChange.added ? "Added" : "Removed"} attributions ($nodeId) - ${range.start} -> ${range.end}: $attributions"; + + @override + String toString() => "AttributionChangeEvent ('$nodeId' - ${range.start} -> ${range.end} ($change): '$attributions')"; + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is AttributionChangeEvent && + runtimeType == other.runtimeType && + change == other.change && + range == other.range && + const DeepCollectionEquality().equals(attributions, other.attributions); + + @override + int get hashCode => super.hashCode ^ change.hashCode ^ range.hashCode ^ attributions.hashCode; +} + +enum AttributionChange { + added, + removed; +} + +/// Changes layout styles, like padding and width, of a component within a [SingleColumnDocumentLayout]. +class ChangeSingleColumnLayoutComponentStylesRequest implements EditRequest { + const ChangeSingleColumnLayoutComponentStylesRequest({ + required this.nodeId, + required this.styles, + }); + + final String nodeId; + final SingleColumnLayoutComponentStyles styles; +} + +class ChangeSingleColumnLayoutComponentStylesCommand extends EditCommand { + ChangeSingleColumnLayoutComponentStylesCommand({ + required this.nodeId, + required this.styles, + }); + + final String nodeId; + final SingleColumnLayoutComponentStyles styles; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final node = document.getNodeById(nodeId)!; + + document.replaceNodeById( + nodeId, + node.copyWithAddedMetadata(styles.toMetadata()), + ); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(node.id), + ), + ]); + } +} + +/// A request to insert the given [plainText] at the current caret position. +/// +/// If the base of the selection isn't a [TextNode], this request does nothing. +/// +/// If the selection is expanded, the selected content is deleted. +/// +/// If the [plainText] contains any newlines, those newlines will be inserted +/// as characters. This request doesn't insert any new nodes. +class InsertPlainTextAtCaretRequest implements EditRequest { + const InsertPlainTextAtCaretRequest( + this.plainText, { + this.createdAt, + }); + + final String plainText; + + /// An (optional) timestamp that describes when this text was inserted. + final DateTime? createdAt; +} + +class InsertPlainTextAtCaretCommand extends EditCommand { + const InsertPlainTextAtCaretCommand( + this.plainText, { + this.attributions = const {}, + this.createdAt, + }); + + final String plainText; + final Set attributions; + + /// An (optional) timestamp that describes when this text was inserted. + final DateTime? createdAt; + + @override + void execute(EditContext context, CommandExecutor executor) { + final selection = context.composer.selection; + if (selection == null) { + // Can't insert at caret if there is no caret. return; } - final textOffset = (documentPosition.nodePosition as TextPosition).offset; + final range = selection.normalize(context.document); + if (range.start.nodeId == range.end.nodeId && range.start.nodePosition is! TextNodePosition) { + // Selection is in a single node, and it's not a text node. We can't insert text here. + return; + } + + late final DocumentPosition insertionPosition; + if (range.isCollapsed) { + // Insertion position is at caret. + insertionPosition = selection.extent; + } else { + // Inserting text with an expanded selection should delete the currently + // selected content. Do that now. + executor.executeCommand( + DeleteSelectionCommand(affinity: TextAffinity.upstream), + ); + + final caret = context.composer.selection!.extent; + if (caret.nodePosition is! TextNodePosition) { + // After deleting an expanded selection, we ended up with a caret + // sitting in a non-text node. Insert a text node to accept the new + // text. + final newTextNodeId = Editor.createNodeId(); + executor.executeCommand( + InsertNodeAfterNodeCommand( + existingNodeId: caret.nodeId, + newNode: ParagraphNode(id: newTextNodeId, text: AttributedText()), + ), + ); + + insertionPosition = DocumentPosition(nodeId: newTextNodeId, nodePosition: const TextNodePosition(offset: 0)); + } else { + insertionPosition = caret; + } + } - textNode.text = textNode.text.insertString( - textToInsert: textToInsert, - startOffset: textOffset, - applyAttributions: attributions, + executor.executeCommand( + InsertTextCommand( + documentPosition: insertionPosition, + textToInsert: plainText, + createdAt: createdAt, + attributions: attributions, + ), ); } } -class InsertAttributedTextCommand implements EditorCommand { - InsertAttributedTextCommand({ +class InsertTextRequest implements EditRequest { + InsertTextRequest({ required this.documentPosition, required this.textToInsert, + required this.attributions, + this.createdAt, }) : assert(documentPosition.nodePosition is TextPosition); final DocumentPosition documentPosition; - final AttributedText textToInsert; + final String textToInsert; + final Set attributions; + + /// An (optional) timestamp that describes when this text was inserted. + final DateTime? createdAt; +} + +class InsertTextCommand extends EditCommand { + InsertTextCommand({ + required this.documentPosition, + required this.textToInsert, + required this.attributions, + this.createdAt, + }) : assert(documentPosition.nodePosition is TextPosition); + + final DocumentPosition documentPosition; + final String textToInsert; + final Set attributions; + final DateTime? createdAt; @override - void execute(Document document, DocumentEditorTransaction transaction) { - final textNode = document.getNodeById(documentPosition.nodeId); + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + String describe() => + "Insert text - ${documentPosition.nodeId} @ ${(documentPosition.nodePosition as TextNodePosition).offset} - '$textToInsert'"; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + var textNode = document.getNodeById(documentPosition.nodeId); if (textNode is! TextNode) { editorDocLog.shout('ERROR: can\'t insert text in a node that isn\'t a TextNode: $textNode'); return; } - final textOffset = (documentPosition.nodePosition as TextPosition).offset; + final textPosition = documentPosition.nodePosition as TextPosition; + final textOffset = textPosition.offset; + + textNode = textNode.copyTextNodeWith( + text: textNode.text.insertString( + textToInsert: textToInsert, + startOffset: textOffset, + applyAttributions: { + ...attributions, + if (createdAt != null) // + CreatedAtAttribution(start: createdAt!), + }, + ), + ); + document.replaceNodeById( + textNode.id, + textNode, + ); - textNode.text = textNode.text.insert( - textToInsert: textToInsert, - startOffset: textOffset, + executor.logChanges([ + DocumentEdit( + TextInsertionEvent( + nodeId: textNode.id, + offset: textOffset, + text: AttributedText(textToInsert), + ), + ), + ]); + + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: textNode.id, + nodePosition: TextNodePosition( + offset: textOffset + textToInsert.length, + affinity: textPosition.affinity, + ), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + notifyListeners: false, + ), ); } } -ExecutionInstruction anyCharacterToInsertInTextContent({ - required EditContext editContext, - required RawKeyEvent keyEvent, -}) { - // Do nothing if CMD or CTRL are pressed because this signifies an attempted - // shortcut. - if (keyEvent.isControlPressed || keyEvent.isMetaPressed) { - return ExecutionInstruction.continueExecution; - } - if (editContext.composer.selection == null) { +class TextInsertionEvent extends NodeChangeEvent { + TextInsertionEvent({ + required String nodeId, + required this.offset, + required this.text, + }) : super(nodeId); + + final int offset; + final AttributedText text; + + @override + String describe() => "Inserted text ($nodeId) @ $offset: '${text.toPlainText()}'"; + + @override + String toString() => "TextInsertionEvent ('$nodeId' - $offset -> '${text.toPlainText()}')"; + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is TextInsertionEvent && + runtimeType == other.runtimeType && + offset == other.offset && + text == other.text; + + @override + int get hashCode => super.hashCode ^ offset.hashCode ^ text.hashCode; +} + +class TextDeletedEvent extends NodeChangeEvent { + const TextDeletedEvent( + String nodeId, { + required this.offset, + required this.deletedText, + }) : super(nodeId); + + final int offset; + final AttributedText deletedText; + + @override + String describe() => "Deleted text ($nodeId) @ $offset: ${deletedText.toPlainText()}"; + + @override + String toString() => "TextDeletedEvent ('$nodeId' - $offset -> '${deletedText.toPlainText()}')"; + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is TextDeletedEvent && + runtimeType == other.runtimeType && + offset == other.offset && + deletedText == other.deletedText; + + @override + int get hashCode => super.hashCode ^ offset.hashCode ^ deletedText.hashCode; +} + +/// A request to insert a newline at the current caret position. +/// +/// The specific action taken depends on the type of content where the caret sits. +/// This request might be routed to different [EditCommand]s based on that position. +/// +/// Regardless of how the newline is handled, if the selection is expanded, that +/// selection is deleted before inserting the newline. +class InsertNewlineAtCaretRequest implements EditRequest { + InsertNewlineAtCaretRequest([String? newNodeId]) { + // We let callers avoid giving us a `newNodeId`, if desired, because + // callers may not understand that this ID is for undo/redo. Also, + // callers may not be sure what value they're supposed to provide. + // So if we don't get one, we create one. + this.newNodeId = newNodeId ?? Editor.createNodeId(); + } + + /// {@template newNodeId} + /// The ID to use for a new node, if a new node is created. + /// + /// This information is required so that undo/redo works. When requests + /// are re-run, they need to use the same node IDs, so that following + /// requests can repeat edits on those same nodes. + /// {@endtemplate} + late final String newNodeId; +} + +/// An [EditCommand] that inserts a newline when the caret sits within a code block. +/// +/// This command adds the following behaviors beyond the usual: +/// * When the caret is in the middle of a code block, a soft newline is inserted within +/// the code block instead of splitting the node. +/// +/// * When the caret is at the end of a code block without a soft newline, inserts +/// a soft newline, so that users can keep writing more code in a code block. +/// +/// * When the caret sits after an existing soft newline, deletes the soft newline +/// and inserts a new empty paragraph below the code block. +class InsertNewlineInCodeBlockAtCaretCommand extends BaseInsertNewlineAtCaretCommand { + const InsertNewlineInCodeBlockAtCaretCommand(this.newNodeId); + + /// {@macro newNodeId} + final String newNodeId; + + @override + void doInsertNewline( + EditContext context, + CommandExecutor executor, + DocumentPosition caretPosition, + NodePosition caretNodePosition, + ) { + final node = context.document.getNodeById(caretPosition.nodeId); + if (node is! TextNode || caretNodePosition is! TextNodePosition) { + return; + } + if (node.metadata[NodeMetadata.blockType] != codeAttribution) { + return; + } + + // When inserting a newline in the middle of a code block, the + // newline should be inserted within the code block, without + // breaking the node into two. + // + // When inserting a newline at the end of a code block, immediately + // after some content, the newline should appear within the code block. + // + // When inserting a newline after another newline, the existing + // newline should be removed from the code block, and a new paragraph + // should be inserted below the code block. + if (caretNodePosition.offset == node.text.length && node.text.last == "\n") { + // The caret is at the end of a code block, following another newline. + // Remove the existing newline. + executor + ..executeCommand( + ReplaceNodeCommand( + existingNodeId: node.id, + newNode: node.copyTextNodeWith( + text: node.text.removeRegion( + startOffset: node.text.length - 1, + endOffset: node.text.length, + ), + ), + ), + ) + // Insert a new empty paragraph after the code block. + ..executeCommand( + InsertNodeAfterNodeCommand( + existingNodeId: node.id, + newNode: ParagraphNode( + id: newNodeId, + text: AttributedText(), + ), + ), + ) + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ); + } else { + // Insert a newline within the code block. + executor.executeCommand( + InsertTextCommand( + documentPosition: DocumentPosition( + nodeId: node.id, + nodePosition: node.endPosition, + ), + textToInsert: "\n", + attributions: {}, + ), + ); + } + } +} + +/// An [EditCommand] that handles a typical newline insertion. +/// +/// If [documentSelection] is expanded, the selected content is first deleted. +/// The remaining behavior is then guaranteed to apply to a caret offset. +/// +/// Newline insertion operates as follows: +/// +/// * Caret in the middle of a paragraph, the paragraph is split in two, with +/// the same metadata applied to both paragraphs. +/// +/// * Caret at the end of a paragraph, a new paragraph is inserted after the +/// current paragraph, using a standard "paragraph" block type. +/// +/// * Caret on the leading edge of a block node, an empty paragraph is inserted +/// before the block node. +/// +/// * Caret on the trailing edge of a block node, an empty paragraph is inserted +/// after the block node. +class DefaultInsertNewlineAtCaretCommand extends BaseInsertNewlineAtCaretCommand { + const DefaultInsertNewlineAtCaretCommand(this.newNodeId); + + /// {@macro newNodeId} + final String newNodeId; + + @override + void doInsertNewline( + EditContext context, + CommandExecutor executor, + DocumentPosition caretPosition, + NodePosition caretNodePosition, + ) { + if (caretNodePosition is! UpstreamDownstreamNodePosition && caretNodePosition is! TextNodePosition) { + // We don't know how to deal with this kind of node. + return; + } + + if (caretNodePosition is UpstreamDownstreamNodePosition) { + // The caret is sitting at the edge of an upstream/downstream node. + _insertNewlineInBinaryNode(context, executor, caretPosition, caretNodePosition); + return; + } + + final node = context.document.getNodeById(caretPosition.nodeId); + if (caretNodePosition is TextNodePosition && node is TextNode) { + _insertNewlineInTextNode(context, executor, node, caretPosition, caretNodePosition); + return; + } + } + + void _insertNewlineInBinaryNode( + EditContext context, + CommandExecutor executor, + DocumentPosition caretPosition, + UpstreamDownstreamNodePosition caretNodePosition, + ) { + if (caretNodePosition.affinity == TextAffinity.upstream) { + // Insert an empty paragraph before the block node. + executor + ..executeCommand( + InsertNodeBeforeNodeCommand( + existingNodeId: caretPosition.nodeId, + newNode: ParagraphNode( + id: newNodeId, + text: AttributedText(), + ), + ), + ) + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ); + } else { + // Insert an empty paragraph after the block node. + executor + ..executeCommand( + InsertNodeAfterNodeCommand( + existingNodeId: caretPosition.nodeId, + newNode: ParagraphNode( + id: newNodeId, + text: AttributedText(), + ), + ), + ) + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ); + } + } + + void _insertNewlineInTextNode( + EditContext context, + CommandExecutor executor, + TextNode textNode, + DocumentPosition caretPosition, + TextNodePosition caretTextPosition, + ) { + // Split the paragraph into two. This includes headers, blockquotes, and + // any other block-level paragraph. + final endOfParagraph = textNode.endPosition; + + editorOpsLog.finer("Splitting paragraph in two."); + executor + ..executeCommand( + SplitParagraphCommand( + nodeId: caretPosition.nodeId, + splitPosition: caretTextPosition, + newNodeId: newNodeId, + replicateExistingMetadata: caretTextPosition.offset != endOfParagraph.offset, + ), + ) + ..executeCommand( + // Place the caret at the beginning of the new node. + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ); + } +} + +/// An abstract [EditCommand] that does some common accounting that's useful for various +/// implementations of commands that insert newlines. +/// +/// Before delegating execution to subclasses, this base command fizzles if the selection +/// is `null`. It also deletes selected content, if the selection is expanded. After that, +/// subclasses receive the `non-null` caret position for easier processing. +abstract class BaseInsertNewlineAtCaretCommand extends EditCommand { + const BaseInsertNewlineAtCaretCommand(); + + @override + void execute(EditContext context, CommandExecutor executor) { + final documentSelection = context.composer.selection; + if (documentSelection == null) { + return; + } + + // Ensure selection doesn't include any non-deletable nodes. + final selectedNodes = context.document.getNodesInside(documentSelection.base, documentSelection.extent); + for (final node in selectedNodes) { + if (!node.isDeletable) { + // There's at least one non-deletable node. Fizzle. + return; + } + } + + if (!documentSelection.isCollapsed) { + // The selection is expanded. Delete the selected content. + executor.executeCommand(DeleteSelectionCommand(affinity: TextAffinity.downstream)); + } + assert(context.composer.selection!.isCollapsed); + + final caretPosition = context.composer.selection!.extent; + final caretNodePosition = caretPosition.nodePosition; + doInsertNewline(context, executor, caretPosition, caretNodePosition); + } + + void doInsertNewline( + EditContext context, + CommandExecutor executor, + DocumentPosition caretPosition, + NodePosition caretNodePosition, + ); +} + +/// Inserts a newline character "\n" at the current caret position, within +/// the current selected text node (doesn't insert a new node). +/// +/// If a non-text node has the caret, nothing happens. +/// +/// If the selection is expanded, the selected content is deleted before +/// the insertion. +class InsertSoftNewlineAtCaretRequest implements EditRequest { + const InsertSoftNewlineAtCaretRequest({ + this.createdAt, + }); + + final DateTime? createdAt; +} + +class InsertSoftNewlineCommand extends EditCommand { + const InsertSoftNewlineCommand({ + this.createdAt, + }); + + final DateTime? createdAt; + + @override + void execute(EditContext context, CommandExecutor executor) { + final documentSelection = context.composer.selection; + if (documentSelection == null) { + return; + } + if (documentSelection.base.nodePosition is! TextNodePosition) { + // The effective insertion position isn't within a text node. Fizzle. + return; + } + if (!documentSelection.isCollapsed) { + // The selection is expanded. Delete the selected content. + executor.executeCommand(DeleteSelectionCommand(affinity: TextAffinity.downstream)); + } + assert(context.composer.selection!.isCollapsed); + + final caretPosition = context.composer.selection!.extent; + executor.executeCommand( + InsertTextCommand( + documentPosition: caretPosition, + textToInsert: "\n", + attributions: { + if (createdAt != null) // + CreatedAtAttribution(start: createdAt!), + }, + ), + ); + } +} + +class ConvertTextNodeToParagraphRequest implements EditRequest { + const ConvertTextNodeToParagraphRequest({ + required this.nodeId, + this.newMetadata, + }); + + final String nodeId; + final Map? newMetadata; +} + +class ConvertTextNodeToParagraphCommand extends EditCommand { + ConvertTextNodeToParagraphCommand({ + required this.nodeId, + this.newMetadata, + }); + + final String nodeId; + final Map? newMetadata; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + final extentNode = document.getNodeById(nodeId) as TextNode; + late ParagraphNode newParagraphNode; + if (extentNode is ParagraphNode) { + newParagraphNode = extentNode.copyWithAddedMetadata({ + NodeMetadata.blockType: paragraphAttribution, + }); + } else { + newParagraphNode = ParagraphNode( + id: extentNode.id, + text: extentNode.text, + metadata: newMetadata, + ); + } + document.replaceNodeById( + extentNode.id, + newParagraphNode, + ); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(extentNode.id), + ), + ]); + } +} + +class InsertAttributedTextRequest implements EditRequest { + const InsertAttributedTextRequest( + this.documentPosition, + this.textToInsert, { + this.createdAt, + }); + + final DocumentPosition documentPosition; + final AttributedText textToInsert; + + /// An (optional) timestamp that describes when this text was inserted. + final DateTime? createdAt; +} + +class InsertAttributedTextCommand extends EditCommand { + InsertAttributedTextCommand({ + required this.documentPosition, + required this.textToInsert, + this.createdAt, + }) : assert(documentPosition.nodePosition is TextPosition); + + final DocumentPosition documentPosition; + final AttributedText textToInsert; + final DateTime? createdAt; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final textNode = document.getNodeById(documentPosition.nodeId); + if (textNode is! TextNode) { + editorDocLog.shout('ERROR: can\'t insert text in a node that isn\'t a TextNode: $textNode'); + return; + } + + final textOffset = (documentPosition.nodePosition as TextPosition).offset; + + late final AttributedText finalTextToInsert; + if (createdAt != null) { + finalTextToInsert = textToInsert.copy() + ..addAttribution( + CreatedAtAttribution(start: createdAt!), + SpanRange(0, textToInsert.length - 1), + ); + } else { + finalTextToInsert = textToInsert; + } + + document.replaceNodeById( + textNode.id, + textNode.copyTextNodeWith( + text: textNode.text.insert( + textToInsert: finalTextToInsert, + startOffset: textOffset, + ), + ), + ); + + executor.logChanges([ + DocumentEdit( + TextInsertionEvent( + nodeId: textNode.id, + offset: textOffset, + text: textToInsert, + ), + ), + ]); + } +} + +class InsertStyledTextAtCaretRequest implements EditRequest { + const InsertStyledTextAtCaretRequest( + this.text, { + this.createdAt, + }); + + final AttributedText text; + + /// An (optional) timestamp that describes when this text was inserted. + final DateTime? createdAt; +} + +class InsertStyledTextAtCaretCommand extends EditCommand { + const InsertStyledTextAtCaretCommand( + this.text, { + this.createdAt, + }); + + final AttributedText text; + + /// An (optional) timestamp that describes when this text was inserted. + final DateTime? createdAt; + + @override + void execute(EditContext context, CommandExecutor executor) { + final selection = context.composer.selection; + if (selection == null) { + // Can't insert at caret if there is no caret. + return; + } + if (!selection.isCollapsed) { + // The selection is expanded. There's no caret. Fizzle. + // Maybe we want these commands to actually be "at selection" instead of + // "at caret" and then delete the selected content. + return; + } + + late final AttributedText textToInsert; + if (createdAt != null) { + textToInsert = text.copy() + ..addAttribution( + CreatedAtAttribution(start: createdAt!), + SpanRange(0, text.length - 1), + ); + } else { + textToInsert = text; + } + + executor + ..executeCommand( + InsertAttributedTextCommand( + documentPosition: selection.extent, + textToInsert: textToInsert, + ), + ) + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: selection.extent.copyWith( + nodePosition: TextNodePosition( + offset: (selection.extent.nodePosition as TextNodePosition).offset + text.length, + ), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ); + } +} + +class InsertInlinePlaceholderAtCaretRequest implements EditRequest { + const InsertInlinePlaceholderAtCaretRequest( + this.placeholder, { + this.createdAt, + }); + + final Object placeholder; + + /// An (optional) timestamp that describes when this text was inserted. + final DateTime? createdAt; +} + +class InsertInlinePlaceholderAtCaretCommand extends EditCommand { + const InsertInlinePlaceholderAtCaretCommand( + this.placeholder, { + this.createdAt, + }); + + final Object placeholder; + + final DateTime? createdAt; + + @override + void execute(EditContext context, CommandExecutor executor) { + final createdAtAttribution = createdAt != null ? CreatedAtAttribution(start: createdAt!) : null; + + executor.executeCommand( + InsertStyledTextAtCaretCommand( + AttributedText( + "", + createdAt != null + ? AttributedSpans(attributions: [ + SpanMarker(attribution: createdAtAttribution!, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: createdAtAttribution, offset: 0, markerType: SpanMarkerType.end), + ]) + : null, + { + 0: placeholder, + }, + ), + ), + ); + } +} + +/// Inserts the given plain [text] at the end of the document. +/// +/// If the document is empty, or ends with a non-text node, a [ParagraphNode] +/// is inserted at the end of the document, and then [text] is inserted into +/// that node. +class InsertPlainTextAtEndOfDocumentRequest implements EditRequest { + InsertPlainTextAtEndOfDocumentRequest( + this.text, { + String? newNodeId, + this.createdAt, + }) { + // We let callers avoid giving us a `newNodeId`, if desired, because + // callers may not understand that this ID is for undo/redo. Also, + // callers may not be sure what value they're supposed to provide. + // So if we don't get one, we create one. + this.newNodeId = newNodeId ?? Editor.createNodeId(); + } + + final String text; + + /// {@macro newNodeId} + late final String newNodeId; + + /// An (optional) timestamp that describes when this text was inserted. + final DateTime? createdAt; +} + +/// Inserts the given styled [text] at the end of the document. +/// +/// If the document is empty, or ends with a non-text node, a [ParagraphNode] +/// is inserted at the end of the document, and then [text] is inserted into +/// that node. +class InsertStyledTextAtEndOfDocumentRequest implements EditRequest { + InsertStyledTextAtEndOfDocumentRequest( + this.text, { + String? newNodeId, + this.createdAt, + }) { + // We let callers avoid giving us a `newNodeId`, if desired, because + // callers may not understand that this ID is for undo/redo. Also, + // callers may not be sure what value they're supposed to provide. + // So if we don't get one, we create one. + this.newNodeId = newNodeId ?? Editor.createNodeId(); + } + + final AttributedText text; + + /// {@macro newNodeId} + late final String newNodeId; + + /// An (optional) timestamp that describes when this text was inserted. + final DateTime? createdAt; +} + +class InsertStyledTextAtEndOfDocumentCommand extends EditCommand { + const InsertStyledTextAtEndOfDocumentCommand( + this.text, { + required this.newNodeId, + this.createdAt, + }); + + final AttributedText text; + + final String newNodeId; + + /// An (optional) timestamp that describes when this text was inserted. + final DateTime? createdAt; + + @override + void execute(EditContext context, CommandExecutor executor) { + late final AttributedText textToInsert; + if (createdAt != null) { + textToInsert = text.copy() + ..addAttribution( + CreatedAtAttribution(start: createdAt!), + SpanRange(0, text.length - 1), + ); + } else { + textToInsert = text; + } + + late final DocumentPosition endOfDocument; + final lastNode = context.document.lastOrNull; + if (lastNode == null || lastNode is! TextNode) { + // There's no text node at the end of the document. We need to insert + // one so we can insert the text. + executor.executeCommand( + InsertNodeAtIndexCommand( + nodeIndex: context.document.length, + newNode: ParagraphNode( + id: newNodeId, + text: AttributedText(), + ), + ), + ); + + endOfDocument = DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ); + } else { + endOfDocument = DocumentPosition( + nodeId: lastNode.id, + nodePosition: lastNode.endPosition, + ); + } + + executor.executeCommand( + InsertAttributedTextCommand( + documentPosition: endOfDocument, + textToInsert: textToInsert, + ), + ); + } +} + +ExecutionInstruction anyCharacterToInsertInTextContent({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + // Do nothing if CMD or CTRL are pressed because this signifies an attempted + // shortcut. + if (HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { return ExecutionInstruction.continueExecution; } if (!editContext.composer.selection!.isCollapsed) { return ExecutionInstruction.continueExecution; } if (!_isTextEntryNode( - document: editContext.editor.document, + document: editContext.document, selection: editContext.composer.selection!, )) { return ExecutionInstruction.continueExecution; @@ -1210,9 +3063,234 @@ ExecutionInstruction anyCharacterToInsertInTextContent({ return didInsertCharacter ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; } +/// Inserts the given [character] at the current caret position. +/// +/// If [ignoreComposerAttributions] is `false`, the current composer styles are applied +/// to the inserted character. +/// +/// If the selection is expanded, the selection is deleted. +/// +/// If the caret sits in a non-text node, a new paragraph is inserted below +/// that node. +class InsertCharacterAtCaretRequest implements EditRequest { + InsertCharacterAtCaretRequest({ + required this.character, + this.ignoreComposerAttributions = false, + }) { + // We generate a node ID just in case the caret sits in a binary + // node, and we need to insert a new paragraph. + // FIXME: Rework all uses of this request so that the caller ensures + // that the caret is in a text node. Or, fizzle in the command + // if we're not. It's probably not a good idea to hide the + // paragraph insertion in this request/command pair. + newNodeId = Editor.createNodeId(); + } + + final String character; + // FIXME: Document why we made this configurable, given that we're inserting + // at the caret. Maybe this was for undo/redo? If so, we probably need + // the composer styles to also activate/deactivate with history. It's + // not clear that users will always be in a position to toggle this property + // at the right times. + // + // Another option is to require users to look up the styles from the composer + // when they create the request. + final bool ignoreComposerAttributions; + + late final String newNodeId; +} + +class InsertCharacterAtCaretCommand extends EditCommand { + InsertCharacterAtCaretCommand({ + required this.character, + required this.newNodeId, + this.ignoreComposerAttributions = false, + }); + + final String character; + final bool ignoreComposerAttributions; + + /// {@macro newNodeId} + final String newNodeId; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.find(Editor.composerKey); + final selection = composer.selection; + + if (selection == null) { + return; + } + + if (!selection.isCollapsed) { + _deleteExpandedSelection( + context: context, + executor: executor, + document: document, + composer: composer, + ); + } + + final extentNodePosition = composer.selection!.extent.nodePosition; + if (extentNodePosition is UpstreamDownstreamNodePosition) { + editorOpsLog.fine("The selected position is an UpstreamDownstreamPosition. Inserting new paragraph first."); + executor.executeCommand( + DefaultInsertNewlineAtCaretCommand(newNodeId), + ); + } + + final extentNode = document.getNodeById(composer.selection!.extent.nodeId)!; + if (extentNode is! TextNode) { + editorOpsLog.fine( + "Couldn't insert character because Super Editor doesn't know how to handle a node of type: $extentNode"); + return; + } + + // Insert the character. + if (!_isTextEntryNode(document: document, selection: selection)) { + return; + } + + executor.executeCommand( + InsertTextCommand( + documentPosition: selection.extent, + textToInsert: character, + attributions: ignoreComposerAttributions ? {} : composer.preferences.currentAttributions, + ), + ); + } +} + +void _deleteExpandedSelection({ + required EditContext context, + required CommandExecutor executor, + required Document document, + required DocumentComposer composer, +}) { + final newSelectionPosition = _getDocumentPositionAfterExpandedDeletion( + document: document, + selection: composer.selection!, + ); + + // Delete the selected content. + executor.executeCommand( + DeleteContentCommand( + documentRange: composer.selection!, + ), + ); + + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed(position: newSelectionPosition), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ); +} + +// FIXME: This method appears to be the same as CommonEditorOperations.getDocumentPositionAfterExpandedDeletion +// De-dup this behavior in an appropriate place +DocumentPosition _getDocumentPositionAfterExpandedDeletion({ + required Document document, + required DocumentSelection selection, +}) { + // Figure out where the caret should appear after the + // deletion. + // TODO: This calculation depends upon the first + // selected node still existing after the deletion. This + // is a fragile expectation and should be revisited. + final basePosition = selection.base; + final baseNode = document.getNode(basePosition); + if (baseNode == null) { + throw Exception('Failed to _getDocumentPositionAfterDeletion because the base node no longer exists.'); + } + + final extentPosition = selection.extent; + final extentNode = document.getNode(extentPosition); + if (extentNode == null) { + throw Exception('Failed to _getDocumentPositionAfterDeletion because the extent node no longer exists.'); + } + + final selectionAffinity = document.getAffinityForSelection(selection); + final topPosition = selectionAffinity == TextAffinity.downstream // + ? selection.base + : selection.extent; + final topNodePosition = topPosition.nodePosition; + final topNode = document.getNodeById(topPosition.nodeId)!; + + final bottomPosition = selectionAffinity == TextAffinity.downstream // + ? selection.extent + : selection.base; + final bottomNodePosition = bottomPosition.nodePosition; + final bottomNode = document.getNodeById(bottomPosition.nodeId)!; + + DocumentPosition newSelectionPosition; + + if (topPosition.nodeId != bottomPosition.nodeId) { + if (topNodePosition == topNode.beginningPosition && bottomNodePosition == bottomNode.endPosition) { + // All nodes in the selection will be deleted. Assume that the base + // node will be retained and converted into a paragraph, if it's not + // already a paragraph. + newSelectionPosition = DocumentPosition( + nodeId: baseNode.id, + nodePosition: const TextNodePosition(offset: 0), + ); + } else if (topNodePosition == topNode.beginningPosition) { + // The top node will be deleted, but only part of the bottom node + // will be deleted. + newSelectionPosition = DocumentPosition( + nodeId: bottomNode.id, + nodePosition: bottomNode.beginningPosition, + ); + } else if (bottomNodePosition == bottomNode.endPosition) { + // The bottom node will be deleted, but only part of the top node + // will be deleted. + newSelectionPosition = DocumentPosition( + nodeId: topNode.id, + nodePosition: topNodePosition, + ); + } else { + // Part of the top and bottom nodes will be deleted, but both of + // those nodes will remain. + + // The caret should end up at the base position + newSelectionPosition = selectionAffinity == TextAffinity.downstream ? selection.base : selection.extent; + } + } else { + // Selection is within a single node. + // + // If it's an upstream/downstream selection node, then the whole node + // is selected, and it will be replaced by a Paragraph Node. + // + // Otherwise, it must be a TextNode, in which case we need to figure + // out which DocumentPosition contains the earlier TextNodePosition. + if (basePosition.nodePosition is UpstreamDownstreamNodePosition) { + // Assume that the node was replace with an empty paragraph. + newSelectionPosition = DocumentPosition( + nodeId: baseNode.id, + nodePosition: const TextNodePosition(offset: 0), + ); + } else if (basePosition.nodePosition is TextNodePosition) { + final baseOffset = (basePosition.nodePosition as TextNodePosition).offset; + final extentOffset = (extentPosition.nodePosition as TextNodePosition).offset; + + newSelectionPosition = DocumentPosition( + nodeId: baseNode.id, + nodePosition: TextNodePosition(offset: min(baseOffset, extentOffset)), + ); + } else { + throw Exception( + 'Unknown selection position type: $basePosition, for node: $baseNode, within document selection: $selection'); + } + } + + return newSelectionPosition; +} + ExecutionInstruction deleteCharacterWhenBackspaceIsPressed({ - required EditContext editContext, - required RawKeyEvent keyEvent, + required SuperEditorContext editContext, + required KeyEvent keyEvent, }) { if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { return ExecutionInstruction.continueExecution; @@ -1221,7 +3299,7 @@ ExecutionInstruction deleteCharacterWhenBackspaceIsPressed({ return ExecutionInstruction.continueExecution; } if (!_isTextEntryNode( - document: editContext.editor.document, + document: editContext.document, selection: editContext.composer.selection!, )) { return ExecutionInstruction.continueExecution; @@ -1238,10 +3316,14 @@ ExecutionInstruction deleteCharacterWhenBackspaceIsPressed({ return didDelete ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; } -ExecutionInstruction deleteToRemoveDownstreamContent({ - required EditContext editContext, - required RawKeyEvent keyEvent, +ExecutionInstruction deleteDownstreamContentWithDelete({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.delete) { return ExecutionInstruction.continueExecution; } @@ -1252,19 +3334,25 @@ ExecutionInstruction deleteToRemoveDownstreamContent({ } ExecutionInstruction shiftEnterToInsertNewlineInBlock({ - required EditContext editContext, - required RawKeyEvent keyEvent, + required SuperEditorContext editContext, + required KeyEvent keyEvent, }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.enter && keyEvent.logicalKey != LogicalKeyboardKey.numpadEnter) { return ExecutionInstruction.continueExecution; } - if (!keyEvent.isShiftPressed) { + if (!HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; } - final didInsertNewline = editContext.commonOps.insertPlainText('\n'); + editContext.editor.execute([ + const InsertSoftNewlineAtCaretRequest(), + ]); - return didInsertNewline ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; + return ExecutionInstruction.haltExecution; } bool _isTextEntryNode({ diff --git a/super_editor/lib/src/default_editor/text/custom_underlines.dart b/super_editor/lib/src/default_editor/text/custom_underlines.dart new file mode 100644 index 0000000000..a50cad385f --- /dev/null +++ b/super_editor/lib/src/default_editor/text/custom_underlines.dart @@ -0,0 +1,102 @@ +import 'dart:ui'; + +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/styles.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +/// A style phase that inspects [TextComponentViewModel]s, finds text with +/// [CustomUnderlineAttribution]s and adds underline configurations to that +/// view model for each such attribution span. +/// +/// The [TextComponentViewModel]s then configure some kind of `TextComponent`, +/// which finally paints the desired underline. +/// +/// To associate an underline type with a visual style, see [CustomUnderlineStyles]. +class CustomUnderlineStyler extends SingleColumnLayoutStylePhase { + @override + SingleColumnLayoutViewModel style(Document document, SingleColumnLayoutViewModel viewModel) { + final updatedViewModel = SingleColumnLayoutViewModel( + padding: viewModel.padding, + componentViewModels: [ + for (final previousViewModel in viewModel.componentViewModels) // + _applyUnderlines(previousViewModel.copy()), + ], + ); + + return updatedViewModel; + } + + SingleColumnLayoutComponentViewModel _applyUnderlines(SingleColumnLayoutComponentViewModel viewModel) { + if (viewModel is! TextComponentViewModel) { + return viewModel; + } + + final underlineSpans = viewModel.text.getAttributionSpansByFilter((a) => a is CustomUnderlineAttribution); + if (underlineSpans.isEmpty) { + return viewModel; + } + + // Add each attributed underline to the text view model. + viewModel.customUnderlines.clear(); + for (final span in underlineSpans) { + final underlineAttribution = span.attribution as CustomUnderlineAttribution; + + viewModel.customUnderlines.add( + CustomUnderline( + underlineAttribution.type, + TextRange(start: span.start, end: span.end + 1), + // ^ +1 because SpanRange is inclusive and TextRange is exclusive. + ), + ); + } + + return viewModel; + } +} + +/// A data structure that describes how various custom underline styles should +/// be painted. +/// +/// This data structure is a glorified map, which maps from underline names, +/// such as "squiggle", to an underline style, such as `SquiggleUnderlineStyle`. +/// +/// A [CustomUnderlineStyles] can be placed in a document stylesheet in a style +/// rule with a key of [Styles.customUnderlineStyles]. +class CustomUnderlineStyles { + const CustomUnderlineStyles(this.stylesByType); + + /// Map from a custom underline type to its painter. + final Map stylesByType; + + CustomUnderlineStyles copy() { + return CustomUnderlineStyles(Map.from(stylesByType)); + } + + CustomUnderlineStyles addStyles(Map newStyles) { + return CustomUnderlineStyles({ + ...stylesByType, + ...newStyles, + }); + } +} + +/// Data structure, which describes a [type] of underline, which should be painted +/// across the given [textRange]. +/// +/// A [CustomUnderline] applies to a given piece of text - it does not encode any +/// particular document node/position. +class CustomUnderline { + const CustomUnderline(this.type, this.textRange); + + /// A name that represents the type of underline, which maps to some painting + /// style, e.g., "straight", "squiggle". + /// + /// The [type] can be anything - it's meaning is determined by the style system. + final String type; + + /// The range of text within some text block to which this underline applies. + final TextRange textRange; +} diff --git a/super_editor/lib/src/default_editor/text_ai.dart b/super_editor/lib/src/default_editor/text_ai.dart new file mode 100644 index 0000000000..e0ee18b424 --- /dev/null +++ b/super_editor/lib/src/default_editor/text_ai.dart @@ -0,0 +1,31 @@ +import 'package:attributed_text/attributed_text.dart'; + +/// An [Attribution] that logs the timestamp when a piece of content was created, +/// such as typing text, or inserting an image. +class CreatedAtAttribution implements Attribution { + const CreatedAtAttribution({ + required this.start, + }); + + final DateTime start; + + @override + String get id => 'created-at'; + + @override + bool canMergeWith(Attribution other) { + if (other is! CreatedAtAttribution) { + return false; + } + + return start == other.start; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CreatedAtAttribution && runtimeType == other.runtimeType && start == other.start; + + @override + int get hashCode => start.hashCode; +} diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart new file mode 100644 index 0000000000..a3bffeff39 --- /dev/null +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -0,0 +1,618 @@ +import 'dart:math'; + +import 'package:attributed_text/attributed_text.dart'; +import 'package:characters/characters.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_tokenizing/tags.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; + +/// A plugin that adds support for action tags, which are tags that represent +/// a user's desire for an action, and then disappear after entry. +/// +/// Examples: +/// +/// A user types "/task" to convert the current paragraph node to a task node, and +/// then the "/task" text disappears. +/// +/// A user types "@john" to assign a task to the user "john", and then the "@john" +/// text disappears. +/// +/// Typically, when the user initiates an action tag, the app displays a popover +/// with available actions. Then, the user selects an action from the popover. +/// This plugin doesn't include any popover behavior - that's left for each app +/// to handle as desired. +/// +/// When an action tag is submitted, either by the user selecting a desired +/// action from the app's popover, or by some other app-specific means, the +/// tag text is deleted. This is because an action tag is a textual representation +/// of a user's desire to take an action. It's not a persistent reference, like +/// a user tag, or a hash tag. +class ActionTagsPlugin extends SuperEditorPlugin { + static const defaultActionTagId = "composingActionTag"; + + ActionTagsPlugin({ + TagRule? tagRule, + this.actionTagId = defaultActionTagId, + }) : this.tagRule = tagRule ?? defaultActionTagRule { + _requestHandlers = [ + (editor, request) => request is SubmitComposingActionTagRequest // + ? SubmitComposingActionTagCommand() + : null, + (editor, request) => request is CancelComposingActionTagRequest // + ? CancelComposingActionTagCommand(request.tagRule) + : null, + ]; + + _reactions = [ + ActionTagComposingReaction( + tagRule: this.tagRule, + onUpdateComposingActionTag: (composingTag) { + composingActionTag.value = composingTag; + }, + ), + ]; + } + + void dispose() { + composingActionTag.dispose(); + } + + /// The tag rule, which is used to identify when a user's input qualifies as an + /// action tag. + final TagRule tagRule; + + /// The ID which is used to register the [composingActionTag] with an [Editor]'s + /// context, allowing anyone with the [Editor] to query the action tag. + final String actionTagId; + + /// The action tag that the user is currently composing. + final composingActionTag = ComposingActionTag(); + + @override + void attach(Editor editor) { + editor + ..context.put(actionTagId, composingActionTag) + ..requestHandlers.insertAll(0, _requestHandlers) + ..reactionPipeline.insertAll(0, _reactions); + } + + @override + void detach(Editor editor) { + editor + ..context.remove(actionTagId, composingActionTag) + ..requestHandlers.removeWhere((item) => _requestHandlers.contains(item)) + ..reactionPipeline.removeWhere((item) => _reactions.contains(item)); + } + + late final List _requestHandlers; + + late final List _reactions; + + @override + List get keyboardActions => [_cancelOnEscape]; + ExecutionInstruction _cancelOnEscape({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, + }) { + if (keyEvent is KeyDownEvent || keyEvent is KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.escape) { + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + CancelComposingActionTagRequest(tagRule), + ]); + + return ExecutionInstruction.haltExecution; + } +} + +final defaultActionTagRule = TagRule(trigger: "/", excludedCharacters: {" "}); + +class SubmitComposingActionTagRequest implements EditRequest { + const SubmitComposingActionTagRequest(); +} + +class SubmitComposingActionTagCommand extends EditCommand { + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.find(Editor.composerKey); + final selection = composer.selection; + + if (selection == null) { + return; + } + + if (!selection.isCollapsed) { + // Action tags are composed while the user is typing. Since the + // selection is expanded, the user is not typing. + return; + } + + final extent = selection.extent; + final extentPosition = extent.nodePosition; + if (extentPosition is! TextNodePosition) { + return; + } + + final textNode = document.getNodeById(extent.nodeId) as TextNode; + + final tagAroundPosition = _findTagUpstream( + // TODO: deal with these tag rules in requests and commands, should the user really pass them? + tagRule: defaultActionTagRule, + nodeId: composer.selection!.extent.nodeId, + text: textNode.text, + caretPosition: extentPosition, + isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), + ); + + if (tagAroundPosition == null) { + return; + } + + context.composingActionTag.value = null; + + executor.executeCommand( + DeleteContentCommand( + documentRange: DocumentSelection( + base: tagAroundPosition.indexedTag.start, + extent: tagAroundPosition.indexedTag.end, + ), + ), + ); + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed(position: tagAroundPosition.indexedTag.start), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ); + } +} + +/// An [EditRequest] that cancels an on-going action tag composition near the user's selection. +/// +/// When a user is in the process of composing an action tag, that tag is given an attribution +/// to identify it. After this request is processed, that attribution will be removed from +/// the text, which will also remove any related UI, such as a suggested user popover. +/// +/// This request doesn't change the user's selection. +class CancelComposingActionTagRequest implements EditRequest { + const CancelComposingActionTagRequest(this.tagRule); + + final TagRule tagRule; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CancelComposingActionTagRequest && runtimeType == other.runtimeType && tagRule == other.tagRule; + + @override + int get hashCode => tagRule.hashCode; +} + +class CancelComposingActionTagCommand extends EditCommand { + const CancelComposingActionTagCommand(this._tagRule); + + final TagRule _tagRule; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.find(Editor.composerKey); + + final selection = composer.selection; + if (selection == null) { + // There shouldn't be a composing action tag without a selection. Either way, + // we can't find the desired composing action tag without a selection position + // to guide us. Fizzle. + editorActionTagsLog.warning("Tried to cancel a composing action tag, but there's no user selection."); + return; + } + + if (!selection.isCollapsed) { + // Action tags are composed while the user is typing. Since the + // selection is expanded, the user is not typing. + return; + } + + // Look for a composing tag at the extent. + final base = selection.base; + final extent = selection.extent; + TagAroundPosition? composingToken; + TextNode? textNode; + + if (extent.nodePosition is TextNodePosition) { + textNode = document.getNodeById(selection.extent.nodeId) as TextNode; + composingToken = _findTagUpstream( + tagRule: _tagRule, + nodeId: textNode.id, + text: textNode.text, + caretPosition: base.nodePosition as TextNodePosition, + isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(actionTagComposingAttribution), + ); + } + + if (composingToken == null) { + // There's no composing tag near either side of the user's selection. Fizzle. + editorActionTagsLog.warning( + "Tried to cancel a composing action tag, but there's no composing action tag near the user's selection."); + return; + } + + // Remove the composing attribution. + executor.executeCommand( + RemoveTextAttributionsCommand( + documentRange: textNode!.selectionBetween( + composingToken.indexedTag.startOffset, + composingToken.indexedTag.endOffset, + ), + attributions: {actionTagComposingAttribution}, + ), + ); + executor.executeCommand( + AddTextAttributionsCommand( + documentRange: textNode.selectionBetween( + composingToken.indexedTag.startOffset, + composingToken.indexedTag.startOffset + 1, + ), + attributions: {actionTagCancelledAttribution}, + ), + ); + } +} + +class ActionTagComposingReaction extends EditReaction { + ActionTagComposingReaction({ + required TagRule tagRule, + required OnUpdateComposingActionTag onUpdateComposingActionTag, + }) : _tagRule = tagRule, + _onUpdateComposingActionTag = onUpdateComposingActionTag; + + final TagRule _tagRule; + final OnUpdateComposingActionTag _onUpdateComposingActionTag; + + IndexedTag? _composingTag; + + @override + void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { + final document = editorContext.document; + final composer = editorContext.find(Editor.composerKey); + + _composingTag = editorContext.composingActionTag.value; + + _healCancelledTags(requestDispatcher, document, changeList); + + if (composer.selection?.isCollapsed != true) { + // Action tags are composed while the user is typing. Since the + // selection is either null or expanded, the user is not typing. + _cancelComposingTag(requestDispatcher); + editorContext.composingActionTag.value = null; + _onUpdateComposingActionTag(null); + return; + } + + final selection = composer.selection!; + + // Look for a composing tag at the extent. + final extent = selection.extent; + TagAroundPosition? tagAroundPosition; + TextNode? textNode; + + if (extent.nodePosition is TextNodePosition) { + textNode = document.getNodeById(selection.extent.nodeId) as TextNode; + tagAroundPosition = _findTagUpstream( + tagRule: _tagRule, + nodeId: textNode.id, + text: textNode.text, + caretPosition: extent.nodePosition as TextNodePosition, + isTokenCandidate: (attributions) => !attributions.contains(actionTagCancelledAttribution), + ); + } + + if (tagAroundPosition == null) { + _cancelComposingTag(requestDispatcher); + editorContext.composingActionTag.value = null; + _onUpdateComposingActionTag(null); + return; + } + + final hasComposingTagAttribution = textNode!.text + .getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: SpanRange(tagAroundPosition.indexedTag.startOffset, tagAroundPosition.indexedTag.endOffset), + ) + .isNotEmpty; + if (changeList.none((event) => event is DocumentEdit) && !hasComposingTagAttribution) { + // The user is neither typing nor moving the caret within an existing composing tag. + return; + } + + _updateComposingTag(requestDispatcher, tagAroundPosition.indexedTag); + editorContext.composingActionTag.value = tagAroundPosition.indexedTag; + _onUpdateComposingActionTag(tagAroundPosition.indexedTag); + } + + /// Finds all cancelled action tags across all changed text nodes in [changeList] and corrects + /// any invalid attribution bounds that may have been introduced by edits. + void _healCancelledTags(RequestDispatcher requestDispatcher, MutableDocument document, List changeList) { + final healChangeRequests = []; + + for (final event in changeList) { + if (event is! DocumentEdit) { + continue; + } + + final change = event.change; + if (change is! NodeChangeEvent) { + continue; + } + + final node = document.getNodeById(change.nodeId); + if (node is! TextNode) { + continue; + } + + // The content in a TextNode changed. Check for the existence of any + // out-of-sync cancelled tags and fix them. + healChangeRequests.addAll( + _healCancelledTagsInTextNode(requestDispatcher, node), + ); + } + + // Run all the requests to heal the various cancelled tags. + requestDispatcher.execute(healChangeRequests); + } + + List _healCancelledTagsInTextNode(RequestDispatcher requestDispatcher, TextNode node) { + final cancelledTagRanges = node.text.getAttributionSpansInRange( + attributionFilter: (a) => a == actionTagCancelledAttribution, + range: SpanRange(0, node.text.length - 1), + ); + + final changeRequests = []; + + for (final range in cancelledTagRanges) { + final cancelledText = node.text.substring(range.start, range.end + 1); // +1 because substring is exclusive + if (_tagRule.isTrigger(cancelledText)) { + // This is a legitimate cancellation attribution. + continue; + } + + DocumentSelection? addedRange; + for (final trigger in _tagRule.triggers) { + if (cancelledText.contains(trigger)) { + // This cancelled range includes more than just a trigger. Reduce it back + // down to the trigger. + final triggerIndex = cancelledText.indexOf(trigger); + addedRange = node.selectionBetween(triggerIndex, triggerIndex); + } + } + + changeRequests.addAll([ + RemoveTextAttributionsRequest( + documentRange: node.selectionBetween(range.start, range.end), + attributions: {actionTagCancelledAttribution}, + ), + if (addedRange != null) // + AddTextAttributionsRequest( + documentRange: addedRange, + attributions: {actionTagCancelledAttribution}, + ), + ]); + } + + return changeRequests; + } + + void _updateComposingTag(RequestDispatcher requestDispatcher, IndexedTag newTag) { + final oldComposingTag = _composingTag; + _composingTag = newTag; + + requestDispatcher.execute([ + if (oldComposingTag != null) + RemoveTextAttributionsRequest( + documentRange: DocumentSelection( + base: oldComposingTag.start, + extent: oldComposingTag.end, + ), + attributions: {actionTagComposingAttribution}, + ), + AddTextAttributionsRequest( + documentRange: DocumentSelection( + base: newTag.start, + extent: newTag.end, + ), + attributions: {actionTagComposingAttribution}, + ), + ]); + } + + void _cancelComposingTag(RequestDispatcher requestDispatcher) { + if (_composingTag == null) { + return; + } + + final composingTag = _composingTag!; + _composingTag = null; + + requestDispatcher.execute([ + RemoveTextAttributionsRequest( + documentRange: DocumentSelection( + base: composingTag.start, + extent: composingTag.end, + ), + attributions: {actionTagComposingAttribution}, + ), + AddTextAttributionsRequest( + documentRange: DocumentSelection( + base: composingTag.start, + extent: composingTag.start.copyWith( + nodePosition: TextNodePosition(offset: composingTag.startOffset + 1), + ), + ), + attributions: {actionTagCancelledAttribution}, + ), + ]); + } +} + +/// Finds a tag that starts upstream to the [caretPosition] and ends +/// at the [caretPosition]. +/// +/// For example, considering the following text, where '|' represents the caret: +/// +/// "hello/wo|rld" +/// +/// This method will extract "/wo" as the tag. +TagAroundPosition? _findTagUpstream({ + required TagRule tagRule, + required String nodeId, + required AttributedText text, + required TextNodePosition caretPosition, + required bool Function(Set tokenAttributions) isTokenCandidate, +}) { + final rawText = text.toPlainText(); + if (rawText.isEmpty) { + return null; + } + + int splitIndex = min(caretPosition.offset, rawText.length); + splitIndex = max(splitIndex, 0); + + // Extract the text upstream to the caret. + // For example: "hello/wor|ld" + // -> extracts the text "hello/wor" + final charactersBefore = rawText.substring(0, splitIndex).characters; + final iteratorUpstream = charactersBefore.iteratorAtEnd; + + if (charactersBefore.isNotEmpty && tagRule.excludedCharacters.contains(charactersBefore.last)) { + // The character where we're supposed to begin our expansion is a + // character that's not allowed in a tag. Therefore, no tag exists + // around the search offset. + return null; + } + + // Move upstream until we find the trigger character or an excluded character. + while (iteratorUpstream.moveBack()) { + final currentCharacter = iteratorUpstream.current; + if (tagRule.excludedCharacters.contains(currentCharacter)) { + // The upstream character isn't allowed to appear in a tag. end the search. + return null; + } + + if (tagRule.isTrigger(currentCharacter)) { + // The character we are reading is the trigger. + // We move the iteratorUpstream one last time to include the trigger in the tokenRange and stop looking any further upstream + iteratorUpstream.moveBack(); + break; + } + } + + final tokenStartOffset = splitIndex - iteratorUpstream.stringAfterLength; + final tokenRange = SpanRange(tokenStartOffset, splitIndex); + + final tagText = text.substringInRange(tokenRange); + if (!tagRule.doesTextStartWithTrigger(tagText)) { + return null; + } + + final tokenAttributions = text.getAttributionSpansInRange(attributionFilter: (a) => true, range: tokenRange); + if (!isTokenCandidate(tokenAttributions.map((span) => span.attribution).toSet())) { + return null; + } + + return TagAroundPosition( + indexedTag: IndexedTag( + Tag(tagRule.extractTriggerFrom(tagText)!, tagText.substring(1)), + nodeId, + tokenStartOffset, + ), + searchOffset: caretPosition.offset, + ); +} + +extension ActionTagPluginDefaults on EditContext { + ComposingActionTag get composingActionTag => find(ActionTagsPlugin.defaultActionTagId); + + ComposingActionTag? get maybeComposingActionTag => findMaybe(ActionTagsPlugin.defaultActionTagId); +} + +class ComposingActionTag with ChangeNotifier implements Editable { + IndexedTag? get value => _value; + IndexedTag? _value; + set value(IndexedTag? newValue) { + if (newValue == value) { + return; + } + + _value = newValue; + + _onChange(); + } + + bool _isInATransaction = false; + bool _didChange = false; + + @override + void onTransactionStart() { + _isInATransaction = true; + _didChange = false; + } + + void _onChange() { + if (!_isInATransaction) { + notifyListeners(); + return; + } + + _didChange = true; + } + + @override + void onTransactionEnd(List edits) { + _isInATransaction = false; + if (_didChange) { + _didChange = false; + notifyListeners(); + } + } + + @override + void reset() { + _value = null; + } +} + +typedef OnUpdateComposingActionTag = void Function(IndexedTag? composingActionTag); + +/// An attribution for an action tag that's currently being composed. +const actionTagComposingAttribution = NamedAttribution("action-tag-composing"); + +/// An attribution for an action tag that was being composed and then was cancelled. +/// +/// This attribution is used to prevent automatically converting a cancelled composition +/// back to a composing tag. +const actionTagCancelledAttribution = NamedAttribution("action-tag-cancelled"); diff --git a/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart new file mode 100644 index 0000000000..6e10a0180e --- /dev/null +++ b/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart @@ -0,0 +1,643 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_tokenizing/tags.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// A [SuperEditorPlugin] that finds and attributes tags, based on patterns, in a document. +/// +/// A pattern tag is a text token that begins with a trigger character, such as "#", and +/// is followed by characters that fit a given pattern. That pattern might be as simple as +/// "any character that isn't a space". +/// +/// A [PatternTagPlugin] finds and attributes tags as the user types them into an [Editor]. +/// Clients that wish to react to changes to pattern tags can use the [tagIndex] to query +/// existing tags. +/// +/// To add pattern tag behaviors to a [SuperEditor] widget, provide a [PatternTagPlugin] in +/// the `plugins` property. +/// +/// SuperEditor( +/// //... +/// plugins: { +/// patternTagPlugin, +/// }, +/// ); +/// +/// To add pattern tag behaviors directly to an [Editor], without involving a [SuperEditor] +/// widget, call [attach] with the given [Editor]. When that [Editor] is no longer needed, +/// call [detach] to clean up all plugin references. +/// +/// patternTagPlugin.attach(editor); +/// +/// +class PatternTagPlugin extends SuperEditorPlugin { + /// The key used to access the [PatternTagIndex] in an attached [Editor]. + static const patternTagIndexKey = "patternTagIndex"; + + PatternTagPlugin({ + TagRule? tagRule, + }) : _tagRule = tagRule ?? hashTagRule, + tagIndex = PatternTagIndex() { + _patternTagReaction = PatternTagReaction( + tagRule: _tagRule, + ); + } + + void dispose() { + tagIndex.dispose(); + } + + /// The rule for what this plugin considers to be a tag. + late final TagRule _tagRule; + + /// Index of all pattern tags in the document. + final PatternTagIndex tagIndex; + + /// An [EditReaction] that finds and attributes all pattern tags. + late EditReaction _patternTagReaction; + + @override + void attach(Editor editor) { + editor + ..context.put(patternTagIndexKey, tagIndex) + ..reactionPipeline.insert(0, _patternTagReaction); + + _initializePatternTagIndex(editor); + } + + void _initializePatternTagIndex(Editor editor) { + final document = editor.context.document; + + for (final node in document) { + if (node is! TextNode) { + continue; + } + + final tagSpans = node.text.getAttributionSpansInRange( + attributionFilter: (a) => a is PatternTagAttribution, + range: SpanRange(0, node.text.length - 1), + ); + + final tags = {}; + for (final tagSpan in tagSpans) { + IndexedTag( + Tag.fromRaw(node.text.substring(tagSpan.start, tagSpan.end + 1)), + node.id, + tagSpan.start, + ); + } + tagIndex._setTagsInNode(node.id, tags); + } + } + + @override + void detach(Editor editor) { + editor + ..context.remove(patternTagIndexKey, tagIndex) + ..reactionPipeline.remove(_patternTagReaction); + } +} + +/// Default [TagRule] for hash tags. +/// +/// Any rule can be used for pattern tags. This rule is provided as a convenience +/// due to the popularity of hash tags. +final hashTagRule = TagRule(trigger: "#", excludedCharacters: {" ", "."}); + +extension PatternTagIndexEditable on EditContext { + /// Returns the [PatternTagIndex] that the [PatternTagPlugin] added to the attached [Editor]. + /// + /// This accessor is provided as a convenience so that clients don't need to call `find()` + /// on the [EditContext]. + PatternTagIndex get patternTagIndex => find(PatternTagPlugin.patternTagIndexKey); +} + +/// Collects references to all pattern tags in a document for easy querying. +class PatternTagIndex with ChangeNotifier implements Editable { + final _tags = >{}; + + Set getTagsInTextNode(String nodeId) => _tags[nodeId] ?? {}; + + Set getAllTags() { + final tags = {}; + for (final value in _tags.values) { + tags.addAll(value); + } + return tags; + } + + void _setTagsInNode(String nodeId, Set tags) { + if (const DeepCollectionEquality().equals(_tags[nodeId], tags)) { + return; + } + + _tags[nodeId] ??= {}; + _tags[nodeId]!.addAll(tags); + _onChange(); + } + + void _clearNode(String nodeId) { + if (_tags[nodeId] == null || _tags[nodeId]!.isEmpty) { + return; + } + + _tags[nodeId]?.clear(); + _onChange(); + } + + bool _isInATransaction = false; + bool _didChange = false; + + @override + void onTransactionStart() { + _isInATransaction = true; + _didChange = false; + } + + void _onChange() { + if (!_isInATransaction) { + return; + } + + _didChange = true; + } + + @override + void onTransactionEnd(List edits) { + _isInATransaction = false; + if (_didChange) { + _didChange = false; + notifyListeners(); + } + } + + @override + void reset() { + _tags.clear(); + } +} + +/// An [EditReaction] that creates, updates, and removes pattern tags. +/// +/// A pattern tag is a token that begins with a trigger, such as "#", and is +/// followed by one or more characters. A pattern tag is terminated by a violating +/// character given the tag rule, the end of a text block, or another trigger ("#"). +/// +/// Examples of pattern tags, using the hash tag rule: +/// +/// #flutter +/// #flutter #dart (2 tags) +/// #flutter#dart (2 tags) +/// I love #flutter. (the period is excluded from the hash tag) +/// +/// Examples of strings that aren't pattern tags, using the hash tag rule: +/// +/// # +/// #. +/// ## +/// +class PatternTagReaction extends EditReaction { + PatternTagReaction({ + TagRule? tagRule, + }) : _tagRule = tagRule ?? hashTagRule; + + late final TagRule _tagRule; + + @override + void react(EditContext editContext, RequestDispatcher requestDispatcher, List changeList) { + if (changeList.whereType().isEmpty) { + // If there are no document edits then there can't possibly be a change to + // hash tags. This is a quick escape to avoid unnecessary logging and inspections. + return; + } + + editorPatternTagsLog.info("Reacting to possible hash tagging"); + editorPatternTagsLog.info("Incoming change list:"); + editorPatternTagsLog.info(changeList.map((event) => event.runtimeType).toList()); + editorPatternTagsLog.info( + "Caret position: ${editContext.find(Editor.composerKey).selection?.extent.nodePosition}"); + + _adjustTagAttributionsAroundAlteredTags(editContext, requestDispatcher, changeList); + + _findAndCreateNewTags(editContext, requestDispatcher, changeList); + + _splitBackToBackTags(editContext, requestDispatcher, changeList); + + _removeInvalidTags(editContext, requestDispatcher, changeList); + + _updateTagIndex(editContext, changeList); + + final tags = editContext.patternTagIndex.getAllTags(); + editorPatternTagsLog.finer("At end of reaction, all pattern tags:"); + for (final tag in tags) { + editorPatternTagsLog.finer(" - '${tag.tag.trigger}' -> '${tag.tag.token}'"); + } + } + + /// Finds a pattern tag near the caret and adjusts the attribution bounds so that the + /// tag content remains attributed. + /// + /// Examples: + /// + /// - |#das|h -> |#dash| + /// - |#dash and| -> |#dash| and + /// + void _adjustTagAttributionsAroundAlteredTags( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ) { + final document = editContext.document; + + final tag = _findTagAtCaret(editContext, (attributions) => attributions.contains(const PatternTagAttribution())); + if (tag == null) { + return; + } + + final tagRange = SpanRange(tag.indexedTag.startOffset, tag.indexedTag.endOffset); + final hasTagAttributionThroughout = + tag.indexedTag.computeLeadingSpanForAttribution(document, const PatternTagAttribution()) == tagRange; + if (hasTagAttributionThroughout) { + // The tag is already fully attributed. No need to do anything. + return; + } + + // The token is only partially attributed. Expand the attribution around the token. + requestDispatcher.execute([ + AddTextAttributionsRequest( + documentRange: DocumentSelection( + base: tag.indexedTag.start, + extent: tag.indexedTag.end, + ), + attributions: {const PatternTagAttribution()}, + ), + ]); + } + + TagAroundPosition? _findTagAtCaret( + EditContext editContext, + bool Function(Set attributions) tagSelector, + ) { + final composer = editContext.find(Editor.composerKey); + if (composer.selection == null || !composer.selection!.isCollapsed) { + // We only tag when the selection is collapsed. Our selection is null or expanded. Return. + return null; + } + final selectionPosition = composer.selection!.extent; + final caretPosition = selectionPosition.nodePosition; + if (caretPosition is! TextNodePosition) { + // Tagging only happens in the middle of text. The selected content isn't text. Return. + return null; + } + + final document = editContext.document; + final selectedNode = document.getNodeById(selectionPosition.nodeId); + if (selectedNode is! TextNode) { + // Tagging only happens in the middle of text. The selected content isn't text. Return. + return null; + } + + return TagFinder.findTagAroundPosition( + tagRule: _tagRule, + nodeId: selectedNode.id, + text: selectedNode.text, + expansionPosition: caretPosition, + isTokenCandidate: tagSelector, + ); + } + + /// Find any text near the caret that fits the tag pattern, and surround + /// it with a hash tag attribution. + void _findAndCreateNewTags( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ) { + editorPatternTagsLog.fine("Looking for a pattern tag around the caret."); + + final composer = editContext.find(Editor.composerKey); + if (composer.selection == null || !composer.selection!.isCollapsed) { + // We only tag when the selection is collapsed. Our selection is null or expanded. Return. + return; + } + + final selectionPosition = composer.selection!.extent; + final caretPosition = selectionPosition.nodePosition; + if (caretPosition is! TextNodePosition) { + // Tagging only happens in the middle of text. The selected content isn't text. Return. + return; + } + + final document = editContext.document; + final selectedNode = document.getNodeById(selectionPosition.nodeId); + if (selectedNode is! TextNode) { + // Tagging only happens in the middle of text. The selected content isn't text. Return. + return; + } + + final tagAroundCaret = TagFinder.findTagAroundPosition( + tagRule: _tagRule, + nodeId: selectedNode.id, + text: selectedNode.text, + expansionPosition: caretPosition, + isTokenCandidate: (tokenAttributions) => + !tokenAttributions.any((attribution) => attribution is PatternTagAttribution), + ); + if (tagAroundCaret == null) { + // There's no tag around the caret. + editorPatternTagsLog.fine("There's no tag around the caret, fizzling"); + return; + } + if (!_tagRule.doesTextStartWithTrigger(tagAroundCaret.indexedTag.tag.raw)) { + // Tags must start with the trigger, e.g., "#", but the preceding word doesn't. Return. + editorPatternTagsLog.fine("Token doesn't start with triggers (${_tagRule.triggers}), fizzling"); + return; + } + if (tagAroundCaret.indexedTag.tag.raw.length <= 1) { + // The token only contains the trigger, e.g., "#". We require at least one valid character after + // the trigger to consider it a hash tag. + editorPatternTagsLog.fine("Token has no content after '${tagAroundCaret.indexedTag.tag.raw[0]}', fizzling"); + return; + } + + editorPatternTagsLog.fine( + "Found a pattern tag around caret: '${tagAroundCaret.indexedTag.tag}' - surrounding it with an attribution: ${tagAroundCaret.indexedTag.startOffset} -> ${tagAroundCaret.indexedTag.endOffset}"); + + requestDispatcher.execute([ + // Remove the old pattern tag attribution(s). + RemoveTextAttributionsRequest( + documentRange: selectedNode.selectionBetween( + tagAroundCaret.indexedTag.startOffset, + tagAroundCaret.indexedTag.endOffset, + ), + attributions: { + ...selectedNode.text + .getAllAttributionsAt(tagAroundCaret.indexedTag.startOffset) + .whereType(), + }, + ), + // Add the new/updated pattern tag attribution. + AddTextAttributionsRequest( + documentRange: selectedNode.selectionBetween( + tagAroundCaret.indexedTag.startOffset, + tagAroundCaret.indexedTag.endOffset, + ), + attributions: { + const PatternTagAttribution(), + }, + ), + ]); + } + + /// Finds any attributed pattern tag that spans multiple pattern tags, and breaks them up. + /// + /// For example, it's possible that we've gotten into a situation where two back-to-back + /// pattern tags are currently attributed as one: + /// + /// [#flutter#dart] + /// + /// This method breaks that one attribution into two: + /// + /// [#flutter][#dart] + /// + void _splitBackToBackTags(EditContext editContext, RequestDispatcher requestDispatcher, List changeList) { + final document = editContext.document; + + final textEdits = changeList + .whereType() + .where((docEdit) => docEdit.change is NodeChangeEvent) + .map((docEdit) => docEdit.change as NodeChangeEvent) + .where((nodeChange) => document.getNodeById(nodeChange.nodeId) != null) + .toList(growable: false); + if (textEdits.isEmpty) { + return; + } + + editorPatternTagsLog.info("Checking edited text nodes for back-to-back pattern tags that need to be split apart"); + for (final textEdit in textEdits) { + final node = document.getNodeById(textEdit.nodeId) as TextNode; + _splitBackToBackTagsInTextNode(requestDispatcher, node); + } + } + + void _splitBackToBackTagsInTextNode(RequestDispatcher requestDispatcher, TextNode node) { + final patternTags = node.text.getAttributionSpansByFilter( + (attribution) => attribution is PatternTagAttribution, + ); + if (patternTags.isEmpty) { + return; + } + + final spanRemovals = {}; + final spanCreations = {}; + + editorPatternTagsLog.finer("Found ${patternTags.length} pattern tag attributions in text node '${node.id}'"); + for (final patternTag in patternTags) { + final tagContent = node.text.substring(patternTag.start, patternTag.end + 1); + editorPatternTagsLog.finer("Inspecting '$tagContent' at ${patternTag.start} -> ${patternTag.end}"); + + final tagTriggers = _tagRule.findAllTriggers(tagContent); + if (tagTriggers.length == 1) { + // There's only one trigger ("#") in this tag, and it's at the beginning. No need + // to split the tag. + editorPatternTagsLog.finer("No need to split this tag. Moving to next one."); + continue; + } + + // This tag either has no trigger, or has multiple triggers ("#") in it. We need to + // remove this tag, or split this tag into multiple pieces. + editorPatternTagsLog.finer("There are zero triggers, or multiple triggers in this tag. Removing or splitting."); + + // Remove the existing attribution, which covers multiple pattern tags. + spanRemovals.add(patternTag.range); + editorPatternTagsLog.finer( + "Removing multi-tag span: ${patternTag.start} -> ${patternTag.end}, '${node.text.substring(patternTag.start, patternTag.end + 1)}'"); + + // Add a new attribution for each individual pattern tag. + final allTriggers = _tagRule.findAllTriggers(tagContent); + for (int i = 0; i < allTriggers.length; i += 1) { + final tagStart = allTriggers[i].$1; + final tagEnd = i < allTriggers.length - 1 ? allTriggers[i + 1].$1 - 1 : tagContent.length - 1; + + if (tagEnd - tagStart > 0) { + // There's a trigger, followed by at least one non-trigger character. Therefore, this + // is a legitimate pattern tag. Give it an attribution. + editorPatternTagsLog.finer( + "Adding a split tag span: ${patternTag.start + tagStart} -> ${patternTag.start + tagEnd}, '${node.text.substring(patternTag.start + tagStart, patternTag.start + tagEnd + 1)}'"); + spanCreations.add(SpanRange( + patternTag.start + tagStart, + patternTag.start + tagEnd, + )); + } + } + } + + if (spanRemovals.isEmpty) { + // We didn't find any tags to break up. No need to submit change requests. + return; + } + + // Execute the attribution removals and additions. + requestDispatcher.execute([ + // Remove the original multi-tag attribution spans. + for (final removal in spanRemovals) + RemoveTextAttributionsRequest( + documentRange: node.selectionBetween( + removal.start, + removal.end + 1, + ), + attributions: {const PatternTagAttribution()}, + ), + + // Add the new, narrowed attribution spans. + for (final creation in spanCreations) + AddTextAttributionsRequest( + documentRange: node.selectionBetween( + creation.start, + creation.end + 1, + ), + attributions: {const PatternTagAttribution()}, + autoMerge: false, + ), + ]); + } + + /// Removes pattern tags that have become invalid, e.g., a hash tag that had content but + /// the content was deleted, and now it's just a dangling "#". + void _removeInvalidTags( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ) { + editorPatternTagsLog.fine("Removing invalid tags."); + final nodesToInspect = {}; + for (final edit in changeList) { + // We only care about deleted text, in case the deletion made an existing tag invalid. + if (edit is! DocumentEdit) { + continue; + } + final change = edit.change; + if (change is! TextDeletedEvent) { + continue; + } + + // We only care about deleted text when the deleted text contains at least one tag. + final tagsInDeletedText = change.deletedText.getAttributionSpansByFilter( + (attribution) => attribution is PatternTagAttribution, + ); + if (tagsInDeletedText.isEmpty) { + continue; + } + + nodesToInspect.add(change.nodeId); + } + editorPatternTagsLog.fine("Found ${nodesToInspect.length} impacted nodes with tags that might be invalid"); + + // Inspect every TextNode where a text deletion impacted a tag. If a tag no longer contains + // a trigger, or only contains a trigger, remove the attribution. + final document = editContext.document; + final removeTagRequests = {}; + for (final nodeId in nodesToInspect) { + final textNode = document.getNodeById(nodeId) as TextNode; + final allTags = textNode.text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution is PatternTagAttribution, + range: SpanRange(0, textNode.text.length - 1), + ); + + for (final tag in allTags) { + final tagText = textNode.text.substring(tag.start, tag.end + 1); + if (!_tagRule.doesTextStartWithTrigger(tagText) || _tagRule.isTrigger(tagText)) { + // Either this text has no trigger, or this text is only a trigger with no value, + // neither of which is a tag. Remove the tag attribution. + editorPatternTagsLog.info("Removing tag with value: '$tagText'"); + removeTagRequests.add( + RemoveTextAttributionsRequest( + documentRange: textNode.selectionBetween( + tag.start, + tag.end + 1, + ), + attributions: {const PatternTagAttribution()}, + ), + ); + } + } + } + + // Run all the tag attribution removal requests that we queued up. + for (final request in removeTagRequests) { + requestDispatcher.execute([request]); + } + } + + void _updateTagIndex(EditContext editContext, List changeList) { + final document = editContext.document; + final index = editContext.patternTagIndex; + for (final event in changeList) { + if (event is! DocumentEdit) { + continue; + } + + final change = event.change; + if (change is! NodeDocumentChange) { + return; + } + if (document.getNodeById(change.nodeId) is! TextNode) { + return; + } + + if (change is NodeRemovedEvent) { + index._clearNode(change.nodeId); + } else if (change is NodeInsertedEvent) { + index._setTagsInNode( + change.nodeId, + _findAllTagsInNode(document, change.nodeId), + ); + } else if (change is NodeChangeEvent) { + index._clearNode(change.nodeId); + index._setTagsInNode( + change.nodeId, + _findAllTagsInNode(document, change.nodeId), + ); + } + } + } + + Set _findAllTagsInNode(Document document, String nodeId) { + final textNode = document.getNodeById(nodeId) as TextNode; + final allTags = textNode.text + .getAttributionSpansInRange( + attributionFilter: (attribution) => attribution is PatternTagAttribution, + range: SpanRange(0, textNode.text.length - 1), + ) + .map( + (span) => IndexedTag( + Tag.fromRaw(textNode.text.substring(span.start, span.end + 1)), + textNode.id, + span.start, + ), + ) + .toSet(); + + return allTags; + } +} + +/// An attribution for a pattern tag. +class PatternTagAttribution extends NamedAttribution { + const PatternTagAttribution() : super("patternTag"); + + @override + bool canMergeWith(Attribution other) => other is PatternTagAttribution; + + @override + String toString() { + return '[PatternTagAttribution]'; + } +} diff --git a/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart new file mode 100644 index 0000000000..ca3bb85106 --- /dev/null +++ b/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart @@ -0,0 +1,1560 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/document_hardware_keyboard/document_input_keyboard.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_tokenizing/tags.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; + +/// A [SuperEditor] plugin that adds the ability to create stable tags, such as +/// persistent user references, e.g., "@dash". +/// +/// Stable tagging includes three modes: +/// * Composing: a stable tag is being assembled, i.e., typed. +/// * Committed: a stable tag is done being assembled - it's now uneditable. +/// * Cancelled: a stable tag was being composed, but the composition was cancelled. +/// +/// ## Composing Tags +/// The user initiates tag composition by typing the trigger symbol, e.g., "@". A +/// [stableTagComposingAttribution] is applied to the trigger symbol. As the user types, +/// the attribution expands with the new text content, surrounding the entire tag. +/// +/// Eventually, the composing tag will either be committed, or cancelled. Those modes +/// are discussed below. +/// +/// "@da|" -> "@das|" - still composing +/// "@ds|" -> "@d|s" -> "@da|s" - still composing +/// "@dash|" -> "@dash |" - committed +/// "@|dash" -> "|@dash" - committed +/// "@da|ash" -> "@dash" - committed +/// +/// ## Committed Tags +/// Once a stable tag is finished being composed, it's committed. A tag can be committed +/// explicitly, or a tag is automatically committed once the user's selection moves outside +/// the tag. +/// +/// A committed tag is non-editable. The user's selection is prevented from entering the +/// tag. If the user's selection is collapsed, the caret will be placed on one side of the +/// tag, or the other. If the user's selection is expanded, then the user will either select +/// the entire tag, or none of the tag. +/// +/// ## Cancelled Tags +/// When the user presses ESCAPE while composing a tag, the composing [stableTagComposingAttribution] +/// is replaced with a [stableTagCancelledAttribution]. This ends the current composing behavior, +/// and also prevents composing from starting again, whenever the user happens to place the caret +/// in the given text. +class StableTagPlugin extends SuperEditorPlugin { + /// The key used to access the [StableTagIndex] in an attached [Editor]. + static const stableTagIndexKey = "stableTagIndex"; + + StableTagPlugin({ + TagRule? tagRule, + }) : _tagRule = tagRule ?? userTagRule, + tagIndex = StableTagIndex() { + _requestHandlers = [ + (editor, request) => request is FillInComposingStableTagRequest + ? FillInComposingUserTagCommand(request.tag, request.tagRule) + : null, + (editor, request) => request is CancelComposingStableTagRequest // + ? CancelComposingStableTagCommand(request.tagRule) + : null, + ]; + + _reactions = [ + TagUserReaction( + tagRule: _tagRule, + onUpdateComposingStableTag: tagIndex._onComposingStableTagFound, + ), + AdjustSelectionAroundTagReaction(_tagRule), + ]; + } + + void dispose() { + tagIndex.dispose(); + } + + late final TagRule _tagRule; + + /// Index of all stable tags in the document, which changes as the user adds and removes tags. + final StableTagIndex tagIndex; + + @override + void attach(Editor editor) { + editor + ..context.put(StableTagPlugin.stableTagIndexKey, tagIndex) + ..requestHandlers.insertAll(0, _requestHandlers) + ..reactionPipeline.insertAll(0, _reactions); + } + + @override + void detach(Editor editor) { + editor + ..context.remove(StableTagPlugin.stableTagIndexKey, tagIndex) + ..requestHandlers.removeWhere((item) => _requestHandlers.contains(item)) + ..reactionPipeline.removeWhere((item) => _reactions.contains(item)); + } + + late final List _requestHandlers; + + late final List _reactions; + + @override + List get keyboardActions => [_cancelOnEscape]; + ExecutionInstruction _cancelOnEscape({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, + }) { + if (keyEvent is KeyDownEvent || keyEvent is KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.escape) { + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + CancelComposingStableTagRequest(_tagRule), + ]); + + return ExecutionInstruction.haltExecution; + } +} + +/// [TagRule] for user tags. +/// +/// Stable tags can use any [TagRule]. This rule is provided as a convenience due to +/// the popularity of user tagging. +final userTagRule = TagRule(trigger: "@", excludedCharacters: {" ", "."}); + +/// An [EditRequest] that replaces a composing stable tag with the given [tag] +/// and commits it. +/// +/// For example, the user types "@da|", and then selects "dash" from a list of +/// matching users. This request replaces "@da|" with "@dash |" and converts the tag +/// from a composing user tag to a committed user tag. +/// +/// For this request to have an effect, the user's selection must sit somewhere within +/// the composing user tag. +class FillInComposingStableTagRequest implements EditRequest { + const FillInComposingStableTagRequest( + this.tag, + this.tagRule, + ); + + final String tag; + final TagRule tagRule; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FillInComposingStableTagRequest && + runtimeType == other.runtimeType && + tag == other.tag && + tagRule == other.tagRule; + + @override + int get hashCode => tag.hashCode ^ tagRule.hashCode; +} + +class FillInComposingUserTagCommand extends EditCommand { + const FillInComposingUserTagCommand( + this._tag, + this._tagRule, + ); + + final String _tag; + final TagRule _tagRule; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.find(Editor.composerKey); + + final selection = composer.selection; + if (selection == null) { + // There shouldn't be a composing stable tag without a selection. Either way, + // we can't find the desired composing stable tag without a selection position + // to guide us. Fizzle. + editorStableTagsLog.warning("Tried to fill in a composing stable tag, but there's no user selection."); + return; + } + + // Look for a composing tag at the extent, or the base. + final base = selection.base; + final extent = selection.extent; + TagAroundPosition? composingToken; + TextNode? textNode; + + if (base.nodePosition is TextNodePosition) { + textNode = document.getNodeById(selection.base.nodeId) as TextNode; + composingToken = TagFinder.findTagAroundPosition( + tagRule: _tagRule, + nodeId: textNode.id, + text: textNode.text, + expansionPosition: base.nodePosition as TextNodePosition, + isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(stableTagComposingAttribution), + ); + } + if (composingToken == null && extent.nodePosition is TextNodePosition) { + textNode = document.getNodeById(selection.extent.nodeId) as TextNode; + composingToken = TagFinder.findTagAroundPosition( + tagRule: _tagRule, + nodeId: textNode.id, + text: textNode.text, + expansionPosition: base.nodePosition as TextNodePosition, + isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(stableTagComposingAttribution), + ); + } + + if (composingToken == null) { + // There's no composing tag near either side of the user's selection. Fizzle. + editorStableTagsLog.warning( + "Tried to fill in a composing stable tag, but there's no composing stable tag near the user's selection."); + return; + } + + final stableTagAttribution = CommittedStableTagAttribution(_tag); + + // Delete the composing stable tag text. + executor.executeCommand( + DeleteContentCommand( + documentRange: textNode!.selectionBetween( + composingToken.indexedTag.startOffset, + composingToken.indexedTag.endOffset, + ), + ), + ); + // Insert a committed stable tag. + executor.executeCommand( + InsertAttributedTextCommand( + documentPosition: textNode.positionAt(composingToken.indexedTag.startOffset), + textToInsert: AttributedText( + "${composingToken.indexedTag.tag.trigger}$_tag ", + AttributedSpans( + attributions: [ + SpanMarker(attribution: stableTagAttribution, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: stableTagAttribution, offset: _tag.length, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ); + // Place the caret at the end of the inserted text. + executor.executeCommand( + ChangeSelectionCommand( + // +1 for trigger symbol, +1 for space after the token + textNode.selectionAt(composingToken.indexedTag.startOffset + _tag.length + 2), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ); + } +} + +/// An [EditRequest] that cancels an on-going stable tag composition near the user's selection. +/// +/// When a user is in the process of composing a stable tag, that tag is given an attribution +/// to identify it. After this request is processed, that attribution will be removed from +/// the text, which will also remove any related UI, such as a suggested-value popover. +/// +/// This request doesn't change the user's selection. +class CancelComposingStableTagRequest implements EditRequest { + const CancelComposingStableTagRequest(this.tagRule); + + final TagRule tagRule; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CancelComposingStableTagRequest && runtimeType == other.runtimeType && tagRule == other.tagRule; + + @override + int get hashCode => tagRule.hashCode; +} + +class CancelComposingStableTagCommand extends EditCommand { + const CancelComposingStableTagCommand(this._tagRule); + + final TagRule _tagRule; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + final composer = context.find(Editor.composerKey); + + final selection = composer.selection; + if (selection == null) { + // There shouldn't be a composing stable tag without a selection. Either way, + // we can't find the desired composing user tag without a selection position + // to guide us. Fizzle. + editorStableTagsLog.warning("Tried to cancel a composing stable tag, but there's no user selection."); + return; + } + + // Look for a composing tag at the extent, or the base. + final base = selection.base; + final extent = selection.extent; + TagAroundPosition? composingToken; + TextNode? textNode; + + if (base.nodePosition is TextNodePosition) { + textNode = document.getNodeById(selection.base.nodeId) as TextNode; + composingToken = TagFinder.findTagAroundPosition( + tagRule: _tagRule, + nodeId: textNode.id, + text: textNode.text, + expansionPosition: base.nodePosition as TextNodePosition, + isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(stableTagComposingAttribution), + ); + } + if (composingToken == null && extent.nodePosition is TextNodePosition) { + textNode = document.getNodeById(selection.extent.nodeId) as TextNode; + composingToken = TagFinder.findTagAroundPosition( + tagRule: _tagRule, + nodeId: textNode.id, + text: textNode.text, + expansionPosition: base.nodePosition as TextNodePosition, + isTokenCandidate: (tokenAttributions) => tokenAttributions.contains(stableTagComposingAttribution), + ); + } + + if (composingToken == null) { + // There's no composing tag near either side of the user's selection. Fizzle. + editorStableTagsLog.warning( + "Tried to cancel a composing stable tag, but there's no composing stable tag near the user's selection."); + return; + } + + // Remove the composing attribution. + executor.executeCommand( + RemoveTextAttributionsCommand( + documentRange: textNode!.selectionBetween( + composingToken.indexedTag.startOffset, + composingToken.indexedTag.endOffset, + ), + attributions: {stableTagComposingAttribution}, + ), + ); + executor.executeCommand( + AddTextAttributionsCommand( + documentRange: textNode.selectionBetween( + composingToken.indexedTag.startOffset, + composingToken.indexedTag.startOffset + 1, + ), + attributions: {stableTagCancelledAttribution}, + ), + ); + } +} + +extension StableTagIndexEditable on EditContext { + /// Returns the [StableTagIndex] that the [StableTagPlugin] added to the attached [Editor]. + /// + /// This accessor is provided as a convenience so that clients don't need to call `find()` + /// on the [EditContext]. + StableTagIndex get stableTagIndex => find(StableTagPlugin.stableTagIndexKey); +} + +/// An [EditReaction] that creates, updates, and removes composing stable tags, and commits those +/// composing tags, causing them to become uneditable. +class TagUserReaction extends EditReaction { + const TagUserReaction({ + required TagRule tagRule, + this.onUpdateComposingStableTag, + }) : _tagRule = tagRule; + + final TagRule _tagRule; + + final OnUpdateComposingStableTag? onUpdateComposingStableTag; + + @override + void react(EditContext editContext, RequestDispatcher requestDispatcher, List changeList) { + editorStableTagsLog.info("Reacting to possible stable tagging"); + editorStableTagsLog.info("Incoming change list:"); + editorStableTagsLog.info(changeList.map((event) => event.runtimeType).toList()); + editorStableTagsLog.info( + "Caret position: ${editContext.find(Editor.composerKey).selection?.extent.nodePosition}"); + + final document = editContext.document; + _healCancelledTags(requestDispatcher, document, changeList); + + _adjustTagAttributionsAroundAlteredTags(editContext, requestDispatcher, changeList); + + _removeInvalidTags(editContext, requestDispatcher, changeList); + + _createNewComposingTag(editContext, requestDispatcher, changeList); + + // Run tag commits after updating tags, above, so that we don't commit an in-progress + // tag when a new character is added to the end of the tag. + _commitCompletedComposingTag(editContext, requestDispatcher, changeList); + + _updateTagIndex(editContext, changeList); + } + + /// Finds all cancelled stable tags across all changed text nodes in [changeList] and corrects + /// any invalid attribution bounds that may have been introduced by edits. + void _healCancelledTags(RequestDispatcher requestDispatcher, MutableDocument document, List changeList) { + final healChangeRequests = []; + + for (final event in changeList) { + if (event is! DocumentEdit) { + continue; + } + + final change = event.change; + if (change is! NodeChangeEvent) { + continue; + } + + final node = document.getNodeById(change.nodeId); + if (node is! TextNode) { + continue; + } + + // The content in a TextNode changed. Check for the existence of any + // out-of-sync cancelled tags and fix them. + healChangeRequests.addAll( + _healCancelledTagsInTextNode(requestDispatcher, node), + ); + } + + // Run all the requests to heal the various cancelled tags. + requestDispatcher.execute(healChangeRequests); + } + + List _healCancelledTagsInTextNode(RequestDispatcher requestDispatcher, TextNode node) { + final cancelledTagRanges = node.text.getAttributionSpansInRange( + attributionFilter: (a) => a == stableTagCancelledAttribution, + range: SpanRange(0, node.text.length - 1), + ); + + final changeRequests = []; + + for (final range in cancelledTagRanges) { + final cancelledText = node.text.substring(range.start, range.end + 1); // +1 because substring is exclusive + if (_tagRule.isTrigger(cancelledText)) { + // This is a legitimate cancellation attribution. + continue; + } + + DocumentSelection? addedRange; + for (final trigger in _tagRule.triggers) { + if (cancelledText.contains(trigger)) { + // This cancelled range includes more than just a trigger. Reduce it back + // down to the trigger. + final triggerIndex = cancelledText.indexOf(trigger); + addedRange = node.selectionBetween(triggerIndex, triggerIndex); + } + } + + changeRequests.addAll([ + RemoveTextAttributionsRequest( + documentRange: node.selectionBetween(range.start, range.end), + attributions: {stableTagCancelledAttribution}, + ), + if (addedRange != null) // + AddTextAttributionsRequest( + documentRange: addedRange, + attributions: {stableTagCancelledAttribution}, + ), + ]); + } + + return changeRequests; + } + + /// Finds a composing stable tag near the caret and adjusts the attribution bounds so that + /// the tag content remains attributed. + /// + /// Examples: + /// + /// - |@joh|n -> |@john| + /// - |@john and| -> |@john| and + /// + void _adjustTagAttributionsAroundAlteredTags( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ) { + final document = editContext.document; + + final composingToken = _findComposingTagAtCaret(editContext); + if (composingToken != null) { + final tagRange = SpanRange(composingToken.indexedTag.startOffset, composingToken.indexedTag.endOffset); + final hasComposingThroughout = + composingToken.indexedTag.computeLeadingSpanForAttribution(document, stableTagComposingAttribution) == + tagRange; + + if (hasComposingThroughout) { + return; + } + + // The token is only partially attributed. Expand the attribution around the token. + requestDispatcher.execute([ + AddTextAttributionsRequest( + documentRange: DocumentSelection( + base: composingToken.indexedTag.start, + extent: composingToken.indexedTag.end, + ), + attributions: {stableTagComposingAttribution}, + ), + ]); + + return; + } + } + + /// Removes composing or cancelled stable tag attributions from any tag that no longer + /// matches the pattern of a stable tag. + /// + /// Example: + /// + /// - |@john| -> |john| -> john + void _removeInvalidTags( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ) { + editorStableTagsLog.info("Removing invalid tags."); + final document = editContext.document; + final nodesToInspect = {}; + for (final edit in changeList) { + // We only care about deleted text, in case the deletion made an existing tag invalid. + if (edit is! DocumentEdit) { + continue; + } + final change = edit.change; + if (change is! TextDeletedEvent) { + continue; + } + if (document.getNodeById(change.nodeId) == null) { + // This node was deleted sometime later. No need to consider it. + continue; + } + + // We only care about deleted text when the deleted text contains at least one tag. + final tagsInDeletedText = change.deletedText.getAttributionSpansByFilter( + (attribution) => attribution == stableTagComposingAttribution || attribution is CommittedStableTagAttribution, + ); + if (tagsInDeletedText.isEmpty) { + continue; + } + + nodesToInspect.add(change.nodeId); + } + editorStableTagsLog.fine("Found ${nodesToInspect.length} impacted nodes with tags that might be invalid"); + + // Inspect every TextNode where a text deletion impacted a tag. + final removeTagRequests = {}; + final deleteTagRequests = {}; + for (final nodeId in nodesToInspect) { + final textNode = document.getNodeById(nodeId) as TextNode; + + // If a composing tag no longer contains a trigger ("@"), remove the attribution. + final allComposingTags = textNode.text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == stableTagComposingAttribution, + range: SpanRange(0, textNode.text.length - 1), + ); + + for (final tag in allComposingTags) { + final tagText = textNode.text.substring(tag.start, tag.end + 1); + + if (!_tagRule.doesTextStartWithTrigger(tagText)) { + editorStableTagsLog.info("Removing tag with value: '$tagText'"); + + onUpdateComposingStableTag?.call(null); + + removeTagRequests.add( + RemoveTextAttributionsRequest( + documentRange: textNode.selectionBetween(tag.start, tag.end + 1), + attributions: {stableTagComposingAttribution}, + ), + ); + } + } + + // If a stable tag's content no longer matches its attribution value, then + // assume that the user tried to delete part of it. Delete the whole thing, + // because we don't allow partial committed user tags. + + // Collect all the stable tags in this node. The list is sorted such that + // later tags appear before earlier tags. This way, as we delete tags, each + // deleted tag won't impact the character offsets of the following tags + // that we delete. + final allStableTags = textNode.text + .getAttributionSpansInRange( + attributionFilter: (attribution) => attribution is CommittedStableTagAttribution, + range: SpanRange(0, textNode.text.length - 1), + ) + .sorted((tag1, tag2) => tag2.start - tag1.start); + + // Track the impact of deletions on selection bounds, then update the selection + // to reflect the deletions. + final composer = editContext.find(Editor.composerKey); + + final baseBeforeDeletions = composer.selection!.base; + int baseOffsetAfterDeletions = baseBeforeDeletions.nodePosition is TextNodePosition + ? (baseBeforeDeletions.nodePosition as TextNodePosition).offset + : -1; + + final extentBeforeDeletions = composer.selection!.extent; + int extentOffsetAfterDeletions = extentBeforeDeletions.nodePosition is TextNodePosition + ? (extentBeforeDeletions.nodePosition as TextNodePosition).offset + : -1; + + for (final tag in allStableTags) { + final tagText = textNode.text.substring(tag.start, tag.end + 1); + final attribution = tag.attribution as CommittedStableTagAttribution; + final containsTrigger = _tagRule.isTrigger(textNode.text.toPlainText()[tag.start]); + + if (!containsTrigger || tagText.substring(1) != attribution.tagValue) { + // The tag was partially deleted. Delete the whole thing. + final deleteFrom = tag.start; + final deleteTo = tag.end + 1; // +1 because SpanRange is inclusive and text position is exclusive + editorStableTagsLog.info("Deleting partial tag '$tagText': $deleteFrom -> $deleteTo"); + + if (baseBeforeDeletions.nodeId == textNode.id) { + if (baseOffsetAfterDeletions >= deleteTo) { + // The base sits beyond the entire deletion region. Push the base up by the + // length of the deletion region. + baseOffsetAfterDeletions -= deleteTo - deleteFrom; + } else if (baseOffsetAfterDeletions > deleteFrom) { + // The base sits somewhere within the deletion region. Move it to the beginning + // of the deletion region. + baseOffsetAfterDeletions = deleteFrom; + } + } + + if (extentBeforeDeletions.nodeId == textNode.id) { + if (extentOffsetAfterDeletions >= deleteTo) { + // The extent sits beyond the entire deletion region. Push the extent up by the + // length of the deletion region. + extentOffsetAfterDeletions -= deleteTo - deleteFrom; + } else if (extentOffsetAfterDeletions > deleteFrom) { + // The extent sits somewhere within the deletion region. Move it to the beginning + // of the deletion region. + extentOffsetAfterDeletions = deleteFrom; + } + } + + deleteTagRequests.add( + DeleteContentRequest( + documentRange: textNode.selectionBetween(deleteFrom, deleteTo), + ), + ); + } + } + + if (deleteTagRequests.isNotEmpty) { + deleteTagRequests.add( + ChangeSelectionRequest( + DocumentSelection( + base: baseOffsetAfterDeletions >= 0 ? textNode.positionAt(baseOffsetAfterDeletions) : baseBeforeDeletions, + extent: extentOffsetAfterDeletions >= 0 + ? textNode.positionAt(extentOffsetAfterDeletions) + : extentBeforeDeletions, + ), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ); + } + } + + // Run all the tag attribution removal requests, and tag deletion requests, + // that we queued up. + requestDispatcher.execute([ + ...removeTagRequests, + ...deleteTagRequests, + ]); + } + + /// Find any text near the caret that fits the pattern of a user tag and convert it into a + /// composing tag. + void _createNewComposingTag( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ) { + editorStableTagsLog.fine("Looking for a tag around the caret."); + final composer = editContext.find(Editor.composerKey); + if (composer.selection == null || !composer.selection!.isCollapsed) { + // We only tag when the selection is collapsed. Our selection is null or expanded. Return. + return; + } + final selectionPosition = composer.selection!.extent; + final caretPosition = selectionPosition.nodePosition; + if (caretPosition is! TextNodePosition) { + // Tagging only happens in the middle of text. The selected content isn't text. Return. + return; + } + + final document = editContext.document; + final selectedNode = document.getNodeById(selectionPosition.nodeId); + if (selectedNode is! TextNode) { + // Tagging only happens in the middle of text. The selected content isn't text. Return. + return; + } + + final existingComposingTag = TagFinder.findTagAroundPosition( + tagRule: _tagRule, + nodeId: selectedNode.id, + text: selectedNode.text, + expansionPosition: caretPosition, + isTokenCandidate: (tokenAttributions) { + return tokenAttributions.contains(stableTagComposingAttribution); + }, + ); + if (existingComposingTag != null && caretPosition.offset > existingComposingTag.indexedTag.startOffset) { + onUpdateComposingStableTag?.call( + ComposingStableTag( + selectedNode.rangeBetween( + existingComposingTag.indexedTag.startOffset + 1, + existingComposingTag.indexedTag.endOffset, + ), + existingComposingTag.indexedTag.tag.trigger, + existingComposingTag.indexedTag.tag.token, + ), + ); + return; + } + + final nonAttributedTagAroundCaret = TagFinder.findTagAroundPosition( + tagRule: _tagRule, + nodeId: selectedNode.id, + text: selectedNode.text, + expansionPosition: caretPosition, + isTokenCandidate: (tokenAttributions) { + return !tokenAttributions.contains(stableTagComposingAttribution) && + !tokenAttributions.contains(stableTagCancelledAttribution) && + !tokenAttributions.any((attribution) => attribution is CommittedStableTagAttribution); + }); + + if (nonAttributedTagAroundCaret == null) { + // There's no tag around the caret. + editorStableTagsLog.fine("There's no tag around the caret, fizzling"); + onUpdateComposingStableTag?.call(null); + return; + } + + // We found a non-attributed stable tag near the caret. Give it a composing + // attribution and report it as the composing tag. + editorImeLog.fine("Found a stable token around caret: ${nonAttributedTagAroundCaret.indexedTag.tag}"); + onUpdateComposingStableTag?.call( + ComposingStableTag( + selectedNode.rangeBetween( + // +1 to remove trigger symbol + nonAttributedTagAroundCaret.indexedTag.startOffset + 1, + nonAttributedTagAroundCaret.indexedTag.endOffset, + ), + nonAttributedTagAroundCaret.indexedTag.tag.trigger, + nonAttributedTagAroundCaret.indexedTag.tag.token, + ), + ); + + requestDispatcher.execute([ + AddTextAttributionsRequest( + documentRange: selectedNode.selectionBetween( + nonAttributedTagAroundCaret.indexedTag.startOffset, + nonAttributedTagAroundCaret.indexedTag.endOffset, + ), + attributions: { + stableTagComposingAttribution, + }, + ), + ]); + } + + /// Find any composing tag that's no longer being composed, and commit it. + void _commitCompletedComposingTag( + EditContext editContext, + RequestDispatcher requestDispatcher, + List changeList, + ) { + editorStableTagsLog.fine("Looking for completed tags to commit."); + final document = editContext.document; + final composingTagNodeCandidates = {}; + for (final edit in changeList) { + if (edit is DocumentEdit && (edit.change is TextInsertionEvent || edit.change is TextDeletedEvent)) { + composingTagNodeCandidates.add((edit.change as NodeChangeEvent).nodeId); + } else if (edit is SelectionChangeEvent) { + final oldSelection = edit.oldSelection; + if (oldSelection == null) { + continue; + } + + if (oldSelection.base.nodePosition is TextNodePosition) { + // The old selection might belong to a node that was removed. Make sure + // the old node exists. If it does, add the node ID as a candidate. + final nodeId = oldSelection.base.nodeId; + if (document.getNodeById(nodeId) != null) { + composingTagNodeCandidates.add(nodeId); + } + } + if (oldSelection.extent.nodePosition is TextNodePosition) { + // The old selection might belong to a node that was removed. Make sure + // the old node exists. If it does, add the node ID as a candidate. + final nodeId = oldSelection.extent.nodeId; + if (document.getNodeById(nodeId) != null) { + composingTagNodeCandidates.add(nodeId); + } + } + } else if (edit is DocumentEdit && edit.change is NodeRemovedEvent) { + // Make sure we don't try to track a node where text was edited, if that + // node was later removed. + final change = (edit).change as NodeRemovedEvent; + composingTagNodeCandidates.remove(change.nodeId); + } + } + if (composingTagNodeCandidates.isEmpty) { + return; + } + + final composer = editContext.find(Editor.composerKey); + final selection = composer.selection; + for (final textNodeId in composingTagNodeCandidates) { + editorStableTagsLog.fine("Checking node $textNodeId for composing tags to commit"); + final textNode = document.getNodeById(textNodeId) as TextNode; + final allTags = TagFinder.findAllTagsInTextNode(textNode, _tagRule); + final composingTags = + allTags.where((tag) => tag.computeLeadingSpanForAttribution(document, stableTagComposingAttribution).isValid); + editorStableTagsLog.fine("Composing tags in node: $composingTags"); + + for (final composingTag in composingTags) { + if (selection == null || selection.extent.nodeId != textNodeId || selection.base.nodeId != textNodeId) { + editorStableTagsLog + .info("Committing tag because selection is null, or selection moved to different node: '$composingTag'"); + _commitTag(requestDispatcher, textNode, composingTag); + continue; + } + + final extentPosition = selection.extent.nodePosition as TextNodePosition; + if (selection.isCollapsed && + (extentPosition.offset <= composingTag.startOffset || extentPosition.offset > composingTag.endOffset)) { + editorStableTagsLog + .info("Committing tag because the caret is out of range: '$composingTag', extent: $extentPosition"); + _commitTag(requestDispatcher, textNode, composingTag); + continue; + } + + editorStableTagsLog.fine("Allowing tag '$composingTag' to continue composing without committing it."); + } + } + } + + void _commitTag(RequestDispatcher requestDispatcher, TextNode textNode, IndexedTag tag) { + onUpdateComposingStableTag?.call(null); + + final tagSelection = textNode.selectionBetween(tag.startOffset, tag.endOffset); + + requestDispatcher + // Remove composing tag attribution. + ..execute([ + RemoveTextAttributionsRequest( + documentRange: tagSelection, + attributions: {stableTagComposingAttribution}, + ) + ]) + // Add stable tag attribution. + ..execute([ + AddTextAttributionsRequest( + documentRange: tagSelection, + attributions: { + CommittedStableTagAttribution(textNode.text.substring( + tag.startOffset + 1, // +1 to remove the trigger ("@") from the value + tag.endOffset, + )) + }, + ) + ]); + } + + TagAroundPosition? _findComposingTagAtCaret(EditContext editContext) { + return _findTagAtCaret(editContext, (attributions) => attributions.contains(stableTagComposingAttribution)); + } + + TagAroundPosition? _findTagAtCaret( + EditContext editContext, + bool Function(Set attributions) tagSelector, + ) { + final composer = editContext.find(Editor.composerKey); + if (composer.selection == null || !composer.selection!.isCollapsed) { + // We only tag when the selection is collapsed. Our selection is null or expanded. Return. + return null; + } + final selectionPosition = composer.selection!.extent; + final caretPosition = selectionPosition.nodePosition; + if (caretPosition is! TextNodePosition) { + // Tagging only happens in the middle of text. The selected content isn't text. Return. + return null; + } + + final document = editContext.document; + final selectedNode = document.getNodeById(selectionPosition.nodeId); + if (selectedNode is! TextNode) { + // Tagging only happens in the middle of text. The selected content isn't text. Return. + return null; + } + + return TagFinder.findTagAroundPosition( + tagRule: _tagRule, + nodeId: selectedNode.id, + text: selectedNode.text, + expansionPosition: caretPosition, + isTokenCandidate: tagSelector, + ); + } + + void _updateTagIndex(EditContext editContext, List changeList) { + final document = editContext.document; + final index = editContext.stableTagIndex; + for (final event in changeList) { + if (event is! DocumentEdit) { + continue; + } + + final change = event.change; + if (change is! NodeDocumentChange) { + return; + } + if (document.getNodeById(change.nodeId) is! TextNode) { + return; + } + + if (change is NodeRemovedEvent) { + index._clearCommittedTagsInNode(change.nodeId); + index._clearCancelledTagsInNode(change.nodeId); + } else if (change is NodeInsertedEvent) { + index._setCommittedTagsInNode( + change.nodeId, + _findAllTagsInNode(document, change.nodeId, (attribution) => attribution is CommittedStableTagAttribution), + ); + index._setCancelledTagsInNode( + change.nodeId, + _findAllTagsInNode(document, change.nodeId, (attribution) => attribution == stableTagCancelledAttribution), + ); + } else if (change is NodeChangeEvent) { + index._setCommittedTagsInNode( + change.nodeId, + _findAllTagsInNode(document, change.nodeId, (attribution) => attribution is CommittedStableTagAttribution), + ); + + index._clearCancelledTagsInNode(change.nodeId); + index._setCancelledTagsInNode( + change.nodeId, + _findAllTagsInNode(document, change.nodeId, (attribution) => attribution == stableTagCancelledAttribution), + ); + } + } + } + + Set _findAllTagsInNode(Document document, String nodeId, AttributionFilter attributionFilter) { + final textNode = document.getNodeById(nodeId) as TextNode; + final allTags = textNode.text + .getAttributionSpansInRange( + attributionFilter: attributionFilter, + range: SpanRange(0, textNode.text.length - 1), + ) + .map( + (span) => IndexedTag( + Tag.fromRaw(textNode.text.substring(span.start, span.end + 1)), + textNode.id, + span.start, + ), + ) + .toSet(); + + return allTags; + } +} + +typedef OnUpdateComposingStableTag = void Function(ComposingStableTag? composingStableTag); + +/// Collects references to all stable tags in a document for easy querying. +class StableTagIndex with ChangeNotifier implements Editable { + /// Returns the active [ComposingStableTag], if the user is currently composing a stable tag, + /// or `null` if no stable tag is currently being composed. + ValueListenable get composingStableTag => _composingStableTag; + final _composingStableTag = ValueNotifier(null); + + void _onComposingStableTagFound(ComposingStableTag? tag) { + _composingStableTag.value = tag; + } + + final _committedTags = >{}; + + Set getCommittedTagsInTextNode(String nodeId) => _committedTags[nodeId] ?? {}; + + Set getAllCommittedTags() { + final tags = {}; + for (final value in _committedTags.values) { + tags.addAll(value); + } + return tags; + } + + void _setCommittedTagsInNode(String nodeId, Set tags) { + _committedTags[nodeId] ??= {}; + + if (const DeepCollectionEquality().equals(_committedTags[nodeId], tags)) { + return; + } + + _committedTags[nodeId]! + ..clear() + ..addAll(tags); + _onChange(); + } + + void _clearCommittedTagsInNode(String nodeId) { + if (_committedTags[nodeId] == null || _committedTags[nodeId]!.isEmpty) { + return; + } + + _committedTags[nodeId]?.clear(); + _onChange(); + } + + final _cancelledTags = >{}; + + Set getCancelledTagsInTextNode(String nodeId) => _cancelledTags[nodeId] ?? {}; + + Set getAllCancelledTags() { + final tags = {}; + for (final value in _cancelledTags.values) { + tags.addAll(value); + } + return tags; + } + + void _setCancelledTagsInNode(String nodeId, Set tags) { + _cancelledTags[nodeId] ??= {}; + + if (const DeepCollectionEquality().equals(_cancelledTags[nodeId], tags)) { + return; + } + + _cancelledTags[nodeId]! + ..clear() + ..addAll(tags); + _onChange(); + } + + void _clearCancelledTagsInNode(String nodeId) { + if (_cancelledTags[nodeId] == null || _cancelledTags[nodeId]!.isEmpty) { + return; + } + + _cancelledTags[nodeId]?.clear(); + _onChange(); + } + + bool _isInATransaction = false; + bool _didChange = false; + + @override + void onTransactionStart() { + _isInATransaction = true; + _didChange = false; + } + + void _onChange() { + if (!_isInATransaction) { + return; + } + + _didChange = true; + } + + @override + void onTransactionEnd(List edits) { + _isInATransaction = false; + if (_didChange) { + _didChange = false; + notifyListeners(); + } + } + + @override + void reset() { + _composingStableTag.value = null; + _committedTags.clear(); + _cancelledTags.clear(); + } +} + +class ComposingStableTag { + const ComposingStableTag(this.contentBounds, this.trigger, this.token); + + final DocumentRange contentBounds; + final String trigger; + final String token; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ComposingStableTag && + runtimeType == other.runtimeType && + contentBounds == other.contentBounds && + trigger == other.trigger && + token == other.token; + + @override + int get hashCode => contentBounds.hashCode ^ trigger.hashCode ^ token.hashCode; + + @override + String toString() => "[ComposingStableTag] - '$trigger', '$token', bounds: $contentBounds"; +} + +/// An [EditReaction] that prevents partial selection of a stable user tag. +class AdjustSelectionAroundTagReaction extends EditReaction { + const AdjustSelectionAroundTagReaction(this._tagRule); + + final TagRule _tagRule; + + @override + void react(EditContext editContext, RequestDispatcher requestDispatcher, List changeList) { + editorStableTagsLog.info("KeepCaretOutOfTagReaction - react()"); + + SelectionChangeEvent? selectionChangeEvent; + bool hasNonSelectionOrComposingRegionChange = false; + + if (changeList.length == 2) { + // Check if we have any event that isn't a selection or composing region change. + hasNonSelectionOrComposingRegionChange = + changeList.any((e) => e is! SelectionChangeEvent && e is! ComposingRegionChangeEvent); + selectionChangeEvent = changeList.firstWhereOrNull((e) => e is SelectionChangeEvent) as SelectionChangeEvent?; + } else if (changeList.length == 1 && changeList.first is SelectionChangeEvent) { + selectionChangeEvent = changeList.first as SelectionChangeEvent; + } + + if (hasNonSelectionOrComposingRegionChange || selectionChangeEvent == null) { + // We only want to move the caret when we're confident about what changed. Therefore, + // we only react to changes that are solely a selection or composing region change, + // i.e., we ignore situations like text entry, text deletion, etc. + editorStableTagsLog.info(" - change list isn't just a single SelectionChangeEvent: $changeList"); + return; + } + + editorStableTagsLog.info(" - we received just one selection change event. Checking for user tag."); + + final document = editContext.document; + + final newCaret = selectionChangeEvent.newSelection?.extent; + if (newCaret == null) { + editorStableTagsLog.fine(" - there's no caret/extent. Fizzling."); + return; + } + + if (selectionChangeEvent.newSelection!.isCollapsed) { + final textNode = document.getNodeById(newCaret.nodeId); + if (textNode == null || textNode is! TextNode) { + // The selected content isn't text. We don't need to worry about it. + editorStableTagsLog.fine(" - selected content isn't text. Fizzling."); + return; + } + + _adjustCaretPosition( + editContext: editContext, + requestDispatcher: requestDispatcher, + textNode: textNode, + selectionChangeEvent: selectionChangeEvent, + newCaret: newCaret, + ); + } else { + _adjustExpandedSelection( + editContext: editContext, + requestDispatcher: requestDispatcher, + selectionChangeEvent: selectionChangeEvent, + newCaret: newCaret, + ); + } + } + + void _adjustCaretPosition({ + required EditContext editContext, + required RequestDispatcher requestDispatcher, + required TextNode textNode, + required SelectionChangeEvent selectionChangeEvent, + required DocumentPosition newCaret, + }) { + editorStableTagsLog.fine("Adjusting the caret position to avoid stable tags."); + + final tagAroundCaret = _findTagAroundPosition( + textNode.id, + textNode.text, + newCaret.nodePosition as TextNodePosition, + (attribution) => attribution is CommittedStableTagAttribution, + ); + if (tagAroundCaret == null) { + // The caret isn't in a tag. We don't need to adjust anything. + editorStableTagsLog + .fine(" - the caret isn't in a tag. Fizzling. Selection:\n${selectionChangeEvent.newSelection}"); + return; + } + editorStableTagsLog.fine("Found tag around caret - $tagAroundCaret"); + + // The new caret position sits inside of a tag. We need to move it outside the tag. + editorStableTagsLog.fine("Selection change type: ${selectionChangeEvent.changeType}"); + switch (selectionChangeEvent.changeType) { + case SelectionChangeType.insertContent: + // It's not obvious how this would happen when inserting content. We'll play it + // safe and do nothing in this case. + return; + case SelectionChangeType.placeCaret: + case SelectionChangeType.collapseSelection: + case SelectionChangeType.alteredContent: + case SelectionChangeType.deleteContent: + // Move the caret to the nearest edge of the tag. + _moveCaretToNearestTagEdge(requestDispatcher, selectionChangeEvent, textNode.id, tagAroundCaret); + break; + case SelectionChangeType.pushCaret: + // Move the caret to the side of the tag in the direction of push motion. + _pushCaretToOppositeTagEdge(editContext, requestDispatcher, selectionChangeEvent, textNode.id, tagAroundCaret); + break; + case SelectionChangeType.placeExtent: + case SelectionChangeType.pushExtent: + case SelectionChangeType.expandSelection: + throw AssertionError( + "A collapsed selection reported a SelectionChangeType for an expanded selection: ${selectionChangeEvent.changeType}\n${selectionChangeEvent.newSelection}"); + case SelectionChangeType.clearSelection: + throw AssertionError("Expected a collapsed selection but there was no selection."); + } + } + + void _adjustExpandedSelection({ + required EditContext editContext, + required RequestDispatcher requestDispatcher, + required SelectionChangeEvent selectionChangeEvent, + required DocumentPosition newCaret, + }) { + editorStableTagsLog.fine("Adjusting an expanded selection to avoid a partial stable tag selection."); + + final document = editContext.document; + final extentNode = document.getNodeById(newCaret.nodeId); + if (extentNode is! TextNode) { + // The caret isn't sitting in text. Fizzle. + return; + } + + final tagAroundCaret = _findTagAroundPosition( + extentNode.id, + extentNode.text, + newCaret.nodePosition as TextNodePosition, + (attribution) => attribution is CommittedStableTagAttribution, + ); + + // The new caret position sits inside of a tag. We need to move it outside the tag. + editorStableTagsLog.fine("Selection change type: ${selectionChangeEvent.changeType}"); + switch (selectionChangeEvent.changeType) { + case SelectionChangeType.insertContent: + // It's not obvious how this would happen when inserting content. We'll play it + // safe and do nothing in this case. + return; + case SelectionChangeType.placeExtent: + case SelectionChangeType.alteredContent: + case SelectionChangeType.deleteContent: + if (tagAroundCaret == null) { + return; + } + + // Move the caret to the nearest edge of the tag. + _moveCaretToNearestTagEdge(requestDispatcher, selectionChangeEvent, extentNode.id, tagAroundCaret); + break; + case SelectionChangeType.pushExtent: + if (tagAroundCaret == null) { + return; + } + + // Expand the selection by pushing the caret to the side of the tag in the direction of push motion. + _pushCaretToOppositeTagEdge( + editContext, + requestDispatcher, + selectionChangeEvent, + extentNode.id, + tagAroundCaret, + expand: true, + ); + break; + case SelectionChangeType.expandSelection: + // Move the base or extent to the side of the tag in the direction of push motion. + TextNode? baseNode; + final basePosition = selectionChangeEvent.newSelection!.base; + if (basePosition.nodePosition is TextNodePosition) { + baseNode = document.getNodeById(basePosition.nodeId) as TextNode; + } + + _pushExpandedSelectionAroundTags( + editContext, + requestDispatcher, + selectionChangeEvent, + baseNode: baseNode, + extentNode: extentNode, + ); + break; + case SelectionChangeType.placeCaret: + case SelectionChangeType.pushCaret: + case SelectionChangeType.collapseSelection: + throw AssertionError( + "An expanded selection reported a SelectionChangeType for a collapsed selection: ${selectionChangeEvent.changeType}\n${selectionChangeEvent.newSelection}"); + case SelectionChangeType.clearSelection: + throw AssertionError("Expected a collapsed selection but there was no selection."); + } + } + + TagAroundPosition? _findTagAroundPosition( + String nodeId, + AttributedText paragraphText, + TextNodePosition position, + bool Function(Attribution) attributionSelector, + ) { + final tagAroundCaret = TagFinder.findTagAroundPosition( + tagRule: _tagRule, + nodeId: nodeId, + text: paragraphText, + expansionPosition: position, + isTokenCandidate: (tokenAttributions) => tokenAttributions.any(attributionSelector), + ); + if (tagAroundCaret == null) { + return null; + } + if (tagAroundCaret.searchOffsetInToken == 0 || + tagAroundCaret.searchOffsetInToken == tagAroundCaret.indexedTag.tag.raw.length) { + // The token is either on the starting edge, e.g., "|@tag", or at the ending edge, + // e.g., "@tag|". We don't care about those scenarios when looking for the caret + // inside of the token. + return null; + } + + final tokenAttributions = paragraphText.getAllAttributionsThroughout( + SpanRange( + tagAroundCaret.indexedTag.startOffset, + tagAroundCaret.indexedTag.endOffset - 1, + ), + ); + if (tokenAttributions.any((attribution) => attribution is CommittedStableTagAttribution)) { + // This token is a user tag. Return it. + return tagAroundCaret; + } + + return null; + } + + void _moveCaretToNearestTagEdge( + RequestDispatcher requestDispatcher, + SelectionChangeEvent selectionChangeEvent, + String textNodeId, + TagAroundPosition tagAroundCaret, + ) { + DocumentSelection? newSelection; + editorStableTagsLog.info("oldCaret is null. Pushing caret to end of tag."); + // The caret was placed directly in the token without a previous selection. This might + // be a user tap, or programmatic placement. Move the caret to the nearest edge of the + // token. + if ((tagAroundCaret.searchOffset - tagAroundCaret.indexedTag.startOffset).abs() < + (tagAroundCaret.searchOffset - tagAroundCaret.indexedTag.endOffset).abs()) { + // Move the caret to the start of the tag. + newSelection = DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: textNodeId, + nodePosition: TextNodePosition(offset: tagAroundCaret.indexedTag.startOffset), + ), + ); + } else { + // Move the caret to the end of the tag. + newSelection = DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: textNodeId, + nodePosition: TextNodePosition(offset: tagAroundCaret.indexedTag.endOffset), + ), + ); + } + + requestDispatcher.execute([ + ChangeSelectionRequest( + newSelection, + newSelection.isCollapsed ? SelectionChangeType.pushCaret : SelectionChangeType.expandSelection, + SelectionReason.contentChange, + ), + ]); + } + + void _pushCaretToOppositeTagEdge( + EditContext editContext, + RequestDispatcher requestDispatcher, + SelectionChangeEvent selectionChangeEvent, + String textNodeId, + TagAroundPosition tagAroundCaret, { + bool expand = false, + }) { + editorStableTagsLog.info("Pushing caret to other side of token - tag around caret: $tagAroundCaret"); + final Document document = editContext.document; + + final pushDirection = document.getAffinityBetween( + base: selectionChangeEvent.oldSelection!.extent, + extent: selectionChangeEvent.newSelection!.extent, + ); + + late int textOffset; + switch (pushDirection) { + case TextAffinity.upstream: + // Move to starting edge. + textOffset = tagAroundCaret.indexedTag.startOffset; + break; + case TextAffinity.downstream: + // Move to ending edge. + textOffset = tagAroundCaret.indexedTag.endOffset; + break; + } + + final newSelection = expand + ? DocumentSelection( + base: selectionChangeEvent.newSelection!.base, + extent: DocumentPosition( + nodeId: selectionChangeEvent.newSelection!.extent.nodeId, + nodePosition: TextNodePosition( + offset: textOffset, + ), + ), + ) + : DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: selectionChangeEvent.newSelection!.extent.nodeId, + nodePosition: TextNodePosition( + offset: textOffset, + ), + ), + ); + + requestDispatcher.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.pushCaret, + SelectionReason.contentChange, + ), + ]); + } + + void _pushExpandedSelectionAroundTags( + EditContext editContext, + RequestDispatcher requestDispatcher, + SelectionChangeEvent selectionChangeEvent, { + required TextNode? baseNode, + required TextNode? extentNode, + }) { + editorStableTagsLog.info("Pushing expanded selection to other side(s) of token(s)"); + + final document = editContext.document; + final selection = selectionChangeEvent.newSelection!; + final selectionAffinity = document.getAffinityForSelection(selection); + + final tagAroundBase = baseNode != null + ? _findTagAroundPosition( + baseNode.id, + baseNode.text, + selectionChangeEvent.newSelection!.base.nodePosition as TextNodePosition, + (attribution) => attribution is CommittedStableTagAttribution, + ) + : null; + + DocumentPosition? newBasePosition; + if (tagAroundBase != null) { + newBasePosition = DocumentPosition( + nodeId: selection.base.nodeId, + nodePosition: selectionAffinity == TextAffinity.downstream // + ? TextNodePosition(offset: tagAroundBase.indexedTag.startOffset) + : TextNodePosition(offset: tagAroundBase.indexedTag.endOffset), + ); + } + + final tagAroundExtent = extentNode != null + ? _findTagAroundPosition( + extentNode.id, + extentNode.text, + selectionChangeEvent.newSelection!.extent.nodePosition as TextNodePosition, + (attribution) => attribution is CommittedStableTagAttribution, + ) + : null; + + DocumentPosition? newExtentPosition; + if (tagAroundExtent != null) { + newExtentPosition = DocumentPosition( + nodeId: selection.extent.nodeId, + nodePosition: selectionAffinity == TextAffinity.downstream // + ? TextNodePosition(offset: tagAroundExtent.indexedTag.endOffset) + : TextNodePosition(offset: tagAroundExtent.indexedTag.startOffset), + ); + } + + if (newBasePosition == null && newExtentPosition == null) { + // No adjustment is needed. + editorStableTagsLog.info("No selection adjustment is needed."); + return; + } + + requestDispatcher.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: newBasePosition ?? selectionChangeEvent.newSelection!.base, + extent: newExtentPosition ?? selectionChangeEvent.newSelection!.extent, + ), + SelectionChangeType.expandSelection, + SelectionReason.contentChange, + ), + ]); + } +} + +/// An attribution for a stable tag that's currently being composed. +const stableTagComposingAttribution = NamedAttribution("stable-tag-composing"); + +/// An attribution for a stable tag that was being composed and then was cancelled. +/// +/// This attribution is used to prevent automatically converting a cancelled composition +/// back to a composing tag. +const stableTagCancelledAttribution = NamedAttribution("stable-tag-cancelled"); + +/// An attribution for a committed tag, i.e., a stable tag that's done being composed and +/// shouldn't be partially selectable or editable. +class CommittedStableTagAttribution implements Attribution { + const CommittedStableTagAttribution(this.tagValue); + + @override + String get id => tagValue; + + final String tagValue; + + @override + bool canMergeWith(Attribution other) { + return this == other; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CommittedStableTagAttribution && runtimeType == other.runtimeType && tagValue == other.tagValue; + + @override + int get hashCode => tagValue.hashCode; + + @override + String toString() { + return '[CommittedStableTagAttribution]: $tagValue'; + } +} diff --git a/super_editor/lib/src/default_editor/text_tokenizing/tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/tags.dart new file mode 100644 index 0000000000..24ef3b9dbd --- /dev/null +++ b/super_editor/lib/src/default_editor/text_tokenizing/tags.dart @@ -0,0 +1,433 @@ +import 'dart:math'; + +import 'package:attributed_text/attributed_text.dart'; +import 'package:characters/characters.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; + +/// A set of tools for finding tags within document text. +class TagFinder { + /// Finds a tag that touches the given [expansionPosition] and returns that tag, + /// indexed within the document, along with the [expansionPosition]. + static TagAroundPosition? findTagAroundPosition({ + required TagRule tagRule, + required String nodeId, + required AttributedText text, + required TextNodePosition expansionPosition, + required bool Function(Set tokenAttributions) isTokenCandidate, + }) { + final rawText = text.toPlainText(); + if (rawText.isEmpty) { + return null; + } + + int splitIndex = min(expansionPosition.offset, rawText.length); + splitIndex = max(splitIndex, 0); + + // Create 2 splits of characters to navigate upstream and downstream the caret position. + // ex: "this is a very|long string" + // -> split around the caret into charactersBefore="this is a very" and charactersAfter="long string" + final charactersBefore = rawText.substring(0, splitIndex).characters; + final iteratorUpstream = charactersBefore.iteratorAtEnd; + + final charactersAfter = rawText.substring(splitIndex).characters; + final iteratorDownstream = charactersAfter.iterator; + + if (charactersBefore.isNotEmpty && tagRule.excludedCharacters.contains(charactersBefore.last)) { + // The character where we're supposed to begin our expansion is a + // character that's not allowed in a tag. Therefore, no tag exists + // around the search offset. + return null; + } + + // Move upstream until we find a trigger character or an excluded character. + while (iteratorUpstream.moveBack()) { + final currentCharacter = iteratorUpstream.current; + if (tagRule.excludedCharacters.contains(currentCharacter)) { + // The upstream character isn't allowed to appear in a tag. end the search. + return null; + } + + if (tagRule.isTrigger(currentCharacter)) { + // The character we are reading is a trigger. + // We move the iteratorUpstream one last time to include the trigger in the tokenRange and stop looking any + // further upstream + iteratorUpstream.moveBack(); + break; + } + } + + // Move downstream the caret position until we find excluded character or reach the end of the text. + while (iteratorDownstream.moveNext()) { + final current = iteratorDownstream.current; + if (tagRule.excludedCharacters.contains(current)) { + break; + } + } + + final tokenStartOffset = splitIndex - iteratorUpstream.stringAfterLength; + final tokenRange = SpanRange(tokenStartOffset, splitIndex + iteratorDownstream.stringBeforeLength); + + final tagText = text.substringInRange(tokenRange); + if (!tagRule.doesTextStartWithTrigger(tagText)) { + return null; + } + + final tokenAttributions = text.getAttributionSpansInRange(attributionFilter: (a) => true, range: tokenRange); + if (!isTokenCandidate(tokenAttributions.map((span) => span.attribution).toSet())) { + return null; + } + + return TagAroundPosition( + indexedTag: IndexedTag( + Tag(tagRule.extractTriggerFrom(tagText)!, tagText.substring(1)), + nodeId, + tokenStartOffset, + ), + searchOffset: expansionPosition.offset, + ); + } + + /// Finds and returns all tags in the given [textNode], which meet the given [rule]. + static Set findAllTagsInTextNode(TextNode textNode, TagRule rule) { + final plainText = textNode.text.toPlainText(); + final tags = {}; + + int characterIndex = 0; + int? tagStartIndex; + late StringBuffer tagBuffer; + for (final character in plainText.characters) { + if (rule.isTrigger(character)) { + if (tagStartIndex != null) { + // We found a trigger, but we're still accumulating a tag from an earlier + // trigger. End the tag we were accumulating. + tags.add(IndexedTag( + Tag.fromRaw(tagBuffer.toString()), + textNode.id, + tagStartIndex, + )); + } + + // Start accumulating a new tag, because we hit a trigger character. + tagStartIndex = characterIndex; + tagBuffer = StringBuffer(); + } + + if (tagStartIndex != null && rule.excludedCharacters.contains(character)) { + // We're accumulating a tag and we hit a character that isn't allowed to + // appear in a tag. End the tag we were accumulating. + tags.add(IndexedTag( + Tag.fromRaw(tagBuffer.toString()), + textNode.id, + tagStartIndex, + )); + + tagStartIndex = null; + } else if (tagStartIndex != null) { + // We're accumulating a tag. Add this character to the tag. + tagBuffer.write(character); + } + + characterIndex += 1; + } + + if (tagStartIndex != null) { + // We were assembling a tag and it went to the end of the text. End the tag. + tags.add(IndexedTag( + Tag.fromRaw(tagBuffer.toString()), + textNode.id, + tagStartIndex, + )); + } + + return tags; + } + + const TagFinder._(); +} + +/// An [IndexedTag], along with a text position about which the tag was found. +/// +/// This data structure is useful for inspecting active typing into a token. +class TagAroundPosition { + const TagAroundPosition({ + required this.indexedTag, + required this.searchOffset, + }); + + /// The [IndexedTag] that surrounds the caret. + final IndexedTag indexedTag; + + /// The text offset of the tag search position, from the start of the [TextNode] that + /// contains the [indexedTag]. + final int searchOffset; + + /// The text offset of the tag search position from the start of the [indexedTag]. + int get searchOffsetInToken => searchOffset - indexedTag.startOffset; + + @override + String toString() => "[TagAroundPosition] - indexedTag: '$indexedTag', search offset in tag: $searchOffsetInToken"; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TagAroundPosition && + runtimeType == other.runtimeType && + indexedTag == other.indexedTag && + searchOffset == other.searchOffset; + + @override + int get hashCode => indexedTag.hashCode ^ searchOffset.hashCode; +} + +/// A rule for matching a text token to a tag. +/// +/// A tag begins with a character in [triggers], and is then followed by one or more +/// non-whitespace characters, except for [excludedCharacters]. +class TagRule { + TagRule.multiple( + this.triggers, { + this.excludedCharacters = const {}, + }) : assert(triggers.isNotEmpty, "At least one trigger must be provided") { + for (final trigger in triggers) { + assert(trigger.length == 1, "Triggers must be exactly one character long"); + } + } + + TagRule({ + required String trigger, + this.excludedCharacters = const {}, + }) : assert(trigger.length == 1, "Trigger must be exactly one character long") { + triggers = {trigger}; + } + + /// The set of `String`s that constitute the start of a "tag", within the context + /// of this rule. + late final Set triggers; + + /// Returns `true` if the given [text] matches any one of this rule's [triggers]. + bool isTrigger(String text) => triggers.contains(text); + + /// Returns `true` if the given [text] starts with any one of this rule's [triggers]. + bool doesTextStartWithTrigger(String text) => extractTriggerFrom(text) != null; + + /// Returns `true` if the given [text] contains any [triggers] anywhere within the + /// text, or `false` if it doesn't. + bool doesTextContainTriggers(String text) { + for (final trigger in triggers) { + if (text.contains(trigger)) { + return true; + } + } + return false; + } + + /// Searches [candidate] for triggers, starting from the beginning, to the end, and + /// returns a list with every trigger and that trigger's index in [candidate]. + List<(int index, String trigger)> findAllTriggers(String candidate) { + final triggers = <(int index, String trigger)>[]; + var searchIndex = 0; + while (searchIndex < candidate.length) { + final nextTrigger = findNextTrigger(candidate, searchIndex); + if (nextTrigger == null) { + break; + } + + triggers.add(nextTrigger); + searchIndex = nextTrigger.$1 + 1; + } + + return triggers; + } + + /// Inspects text in [candidate], beginning at [start], and returns the index and trigger + /// for the first trigger found at or after [start], or `null` if no trigger is found in + /// the remaining text. + (int index, String trigger)? findNextTrigger(String candidate, [int start = 0]) { + var index = start; + while (index < candidate.length) { + final trigger = extractTriggerFrom(candidate.substring(index)); + if (trigger != null) { + return (index, trigger); + } + + index += 1; + } + + return null; + } + + String? extractTriggerFrom(String candidate) { + for (final trigger in triggers) { + if (candidate.startsWith(trigger)) { + return trigger; + } + } + return null; + } + + @Deprecated("Use 'triggers' and 'hasTrigger' instead - plugin now supports multiple triggers") + String get trigger { + assert(triggers.length == 1); + + return triggers.first; + } + + final Set excludedCharacters; + + /// Returns `true` if the entire [candidate] complies with this [TagRule]. + /// + /// For example, assume "#" is the trigger and that "." is an excluded character. + /// + /// "#flutter" returns `true`. + /// + /// "#flut.ter" returns `false`. + /// + /// "flutter" returns `false`. + /// + bool isTag(String candidate) { + if (!doesTextStartWithTrigger(candidate)) { + return false; + } + + for (final excludedCharacter in excludedCharacters) { + if (candidate.contains(excludedCharacter)) { + return false; + } + } + + return true; + } + + /// Extracts and returns a compliant tag from the beginning of the given [candidate], or `null` if + /// the [candidate] doesn't begin with a compliant tag. + /// + /// "#flutter" -> "#flutter" + /// "#flut.ter" -> "#flut" + /// "#flutter dash" -> "#flutter" + /// "#.flutter" -> `null` + /// "flutter" -> `null` + /// + String? findTagAtBeginning(String candidate) { + final trigger = extractTriggerFrom(candidate); + if (trigger == null) { + return null; + } + + final buffer = StringBuffer(trigger); + for (final character in candidate.characters.toList().sublist(1)) { + if (excludedCharacters.contains(character)) { + break; + } + + buffer.write(character); + } + + if (buffer.length == trigger.length) { + // We didn't find any non-excluded characters after the trigger. + return null; + } + + return buffer.toString(); + } +} + +/// A [Tag] and its position within a [Document]. +/// +/// A tag is a segment of text, which usually fits some kind of pattern, such "#flutter", which begins +/// with a "#" and is followed by some number of non-whitespace characters. +/// +/// A tag may be attributed, but there's no requirement that the [tag] in a [IndexedTag] have any +/// particular attributions applied to it. Moreover, if an attribution is applied, it's possible +/// that the attribution is currently out of sync with the tag text bounds. It's the client's +/// responsibility to monitor the attribution bounds and keep them in sync with the content. +/// The [IndexedTag] data structure is a tool that makes such management easier. +class IndexedTag { + const IndexedTag(this.tag, this.nodeId, this.startOffset); + + /// The plain-text tag value. + final Tag tag; + + /// The node ID of the [TextNode] that contains this tag. + final String nodeId; + + /// The text offset of the trigger symbol for this tag within the given [TextNode]. + final int startOffset; + + /// The fully-specified [DocumentPosition] associated with the tag's [startOffset]. + DocumentPosition get start => DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: startOffset)); + + /// The text offset immediately after the final character in this tag, within the given [TextNode]. + int get endOffset => startOffset + tag.raw.length; + + /// The fully-specified [DocumentPosition] associated with the tag's [endOffset]. + DocumentPosition get end => DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: endOffset)); + + /// The [DocumentRange] from [start] to [end]. + DocumentRange get range => DocumentRange(start: start, end: end); + + /// The length of the [tag]'s text. + int get length => tag.raw.length; + + /// Collects and returns all attributions in this tag's [TextNode], between the + /// [start] of the tag and the [end] of the tag. + AttributedSpans computeTagSpans(Document document) => + (document.getNodeById(nodeId) as TextNode).text.copyText(startOffset, endOffset - 1).spans; + + /// Assuming that this tag begins with the given [attribution], this method returns + /// the [SpanRange] for the given [attribution], beginning at the [start] of this tag. + /// + /// This is useful to determine whether a tag attribution fully spans the tag. + SpanRange computeLeadingSpanForAttribution(Document document, Attribution attribution) { + final text = (document.getNodeById(nodeId) as TextNode).text; + if (!text.hasAttributionAt(startOffset, attribution: attribution)) { + return SpanRange.empty; + } + + return text.getAttributedRange({attribution}, startOffset); + } + + @override + String toString() => "[IndexedToken] - '${tag.raw}', $startOffset -> $endOffset, node: $nodeId"; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is IndexedTag && + runtimeType == other.runtimeType && + tag == other.tag && + nodeId == other.nodeId && + startOffset == other.startOffset && + endOffset == other.endOffset; + + @override + int get hashCode => tag.hashCode ^ nodeId.hashCode ^ startOffset.hashCode ^ endOffset.hashCode; +} + +/// A text tag, e.g., "@dash", "#flutter". +class Tag { + factory Tag.fromRaw(String tag) => Tag(tag[0], tag.substring(1)); + + const Tag(this.trigger, this.token); + + /// The character that triggered the tag, e.g., "@". + final String trigger; + + /// The token within the tag, e.g., returns "dash" from the tag "@dash" + final String token; + + /// The full trigger + token, e.g., "@dash". + String get raw => "$trigger$token"; + + @override + String toString() => "[Tag] - '$raw'"; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Tag && runtimeType == other.runtimeType && trigger == other.trigger && token == other.token; + + @override + int get hashCode => trigger.hashCode ^ token.hashCode; +} diff --git a/super_editor/lib/src/default_editor/text_tools.dart b/super_editor/lib/src/default_editor/text_tools.dart index 14a029e4a7..d084487ed4 100644 --- a/super_editor/lib/src/default_editor/text_tools.dart +++ b/super_editor/lib/src/default_editor/text_tools.dart @@ -1,5 +1,5 @@ import 'dart:math'; -import 'dart:ui'; + import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_layout.dart'; @@ -34,7 +34,9 @@ DocumentSelection? getWordSelection({ return null; } - final TextSelection wordTextSelection = (component as TextComposable).getWordSelectionAt(nodePosition); + // Create a new TextNodePosition to ensure that we're searching with downstream affinity, for consistent results. + final searchPosition = TextNodePosition(offset: nodePosition.offset); + final TextSelection wordTextSelection = (component as TextComposable).getWordSelectionAt(searchPosition); final wordNodeSelection = TextNodeSelection.fromTextSelection(wordTextSelection); _log.log('getWordSelection', ' - word selection: $wordNodeSelection'); diff --git a/super_editor/lib/src/default_editor/unknown_component.dart b/super_editor/lib/src/default_editor/unknown_component.dart index 34943cafc6..813529dce1 100644 --- a/super_editor/lib/src/default_editor/unknown_component.dart +++ b/super_editor/lib/src/default_editor/unknown_component.dart @@ -9,18 +9,40 @@ class UnknownComponentBuilder implements ComponentBuilder { @override SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { - return null; + return _UnknownViewModel( + nodeId: node.id, + createdAt: node.metadata[NodeMetadata.createdAt], + padding: EdgeInsets.zero, + ); } @override Widget? createComponent( SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { editorLayoutLog.warning("Building component widget for unknown component: $componentViewModel"); - return SizedBox( + return UnknownComponent( key: componentContext.componentKey, - width: double.infinity, - height: 100, - child: const Placeholder(), + ); + } +} + +/// A [SingleColumnLayoutComponentViewModel] that represents an unknown content. +/// +/// This is used so the editor doesn't crash when it encounters a node that it +/// doesn't know how to render. +class _UnknownViewModel extends SingleColumnLayoutComponentViewModel { + _UnknownViewModel({ + required super.nodeId, + super.createdAt, + required super.padding, + }); + + @override + SingleColumnLayoutComponentViewModel copy() { + return _UnknownViewModel( + nodeId: nodeId, + createdAt: createdAt, + padding: padding, ); } } @@ -31,11 +53,13 @@ class UnknownComponentBuilder implements ComponentBuilder { /// `DocumentNode` for which there is no corresponding /// component builder. class UnknownComponent extends StatelessWidget { + const UnknownComponent({super.key}); + @override Widget build(BuildContext context) { return const SizedBox( width: double.infinity, - height: 54, + height: 100, child: Placeholder(), ); } diff --git a/super_editor/lib/src/document_operations/selection_operations.dart b/super_editor/lib/src/document_operations/selection_operations.dart index a0091639bb..4d0d99aa14 100644 --- a/super_editor/lib/src/document_operations/selection_operations.dart +++ b/super_editor/lib/src/document_operations/selection_operations.dart @@ -1,6 +1,8 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; import 'package:super_editor/src/default_editor/text_tools.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; @@ -22,9 +24,10 @@ import '../core/document_selection.dart'; /// Returns `true` if the selection is moved and `false` otherwise, e.g., there /// are no selectable nodes in the document. bool moveSelectionToNearestSelectableNode({ + required Editor editor, required Document document, required DocumentLayoutResolver documentLayoutResolver, - required ValueNotifier selection, + required DocumentSelection? currentSelection, required DocumentNode startingNode, bool expand = false, }) { @@ -32,7 +35,7 @@ bool moveSelectionToNearestSelectableNode({ NodePosition? newPosition; // Try to find a new selection downstream. - final downstreamNode = _getDownstreamSelectableNodeAfter(document, documentLayoutResolver, startingNode); + final downstreamNode = getDownstreamSelectableNodeAfter(document, documentLayoutResolver, startingNode); if (downstreamNode != null) { newNodeId = downstreamNode.id; final nextComponent = documentLayoutResolver().getComponentByNodeId(newNodeId); @@ -41,7 +44,7 @@ bool moveSelectionToNearestSelectableNode({ // Try to find a new selection upstream. if (newPosition == null) { - final upstreamNode = _getUpstreamSelectableNodeBefore(document, documentLayoutResolver, startingNode); + final upstreamNode = getUpstreamSelectableNodeBefore(document, documentLayoutResolver, startingNode); if (upstreamNode != null) { newNodeId = upstreamNode.id; final previousComponent = documentLayoutResolver().getComponentByNodeId(newNodeId); @@ -60,10 +63,24 @@ bool moveSelectionToNearestSelectableNode({ if (expand) { // Selection should be expanded. - selection.value = selection.value!.expandTo(newExtent); + editor.execute([ + ChangeSelectionRequest( + currentSelection!.expandTo(newExtent), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); } else { // Selection should be replaced by new collapsed position. - selection.value = DocumentSelection.collapsed(position: newExtent); + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed(position: newExtent), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); } return true; @@ -71,7 +88,7 @@ bool moveSelectionToNearestSelectableNode({ /// Returns the first [DocumentNode] after [startingNode] whose /// [DocumentComponent] is visually selectable. -DocumentNode? _getDownstreamSelectableNodeAfter( +DocumentNode? getDownstreamSelectableNodeAfter( Document document, DocumentLayoutResolver documentLayoutResolver, DocumentNode startingNode, @@ -96,7 +113,7 @@ DocumentNode? _getDownstreamSelectableNodeAfter( /// Returns the first [DocumentNode] before [startingNode] whose /// [DocumentComponent] is visually selectable. -DocumentNode? _getUpstreamSelectableNodeBefore( +DocumentNode? getUpstreamSelectableNodeBefore( Document document, DocumentLayoutResolver documentLayoutResolver, DocumentNode startingNode, @@ -119,85 +136,6 @@ DocumentNode? _getUpstreamSelectableNodeBefore( return selectableNode; } -/// Calculates an appropriate [DocumentSelection] from an (x,y) -/// [baseOffsetInDocument], to an (x,y) [extentOffsetInDocument], setting -/// the new document selection in the given [selection]. -void selectRegion({ - required DocumentLayout documentLayout, - required Offset baseOffsetInDocument, - required Offset extentOffsetInDocument, - required SelectionType selectionType, - bool expandSelection = false, - required ValueNotifier selection, -}) { - docGesturesLog.info("Selecting region with selection mode: $selectionType"); - DocumentSelection? regionSelection = documentLayout.getDocumentSelectionInRegion( - baseOffsetInDocument, - extentOffsetInDocument, - ); - DocumentPosition? basePosition = regionSelection?.base; - DocumentPosition? extentPosition = regionSelection?.extent; - docGesturesLog.fine(" - base: $basePosition, extent: $extentPosition"); - - if (basePosition == null || extentPosition == null) { - selection.value = null; - return; - } - - if (selectionType == SelectionType.paragraph) { - final baseParagraphSelection = getParagraphSelection( - docPosition: basePosition, - docLayout: documentLayout, - ); - if (baseParagraphSelection == null) { - selection.value = null; - return; - } - basePosition = baseOffsetInDocument.dy < extentOffsetInDocument.dy - ? baseParagraphSelection.base - : baseParagraphSelection.extent; - - final extentParagraphSelection = getParagraphSelection( - docPosition: extentPosition, - docLayout: documentLayout, - ); - if (extentParagraphSelection == null) { - selection.value = null; - return; - } - extentPosition = baseOffsetInDocument.dy < extentOffsetInDocument.dy - ? extentParagraphSelection.extent - : extentParagraphSelection.base; - } else if (selectionType == SelectionType.word) { - final baseWordSelection = getWordSelection( - docPosition: basePosition, - docLayout: documentLayout, - ); - if (baseWordSelection == null) { - selection.value = null; - return; - } - basePosition = baseWordSelection.base; - - final extentWordSelection = getWordSelection( - docPosition: extentPosition, - docLayout: documentLayout, - ); - if (extentWordSelection == null) { - selection.value = null; - return; - } - extentPosition = extentWordSelection.extent; - } - - selection.value = (DocumentSelection( - // If desired, expand the selection instead of replacing it. - base: expandSelection ? selection.value?.base ?? basePosition : basePosition, - extent: extentPosition, - )); - docGesturesLog.fine("Selected region: ${selection.value}"); -} - enum SelectionType { position, word, @@ -237,79 +175,37 @@ bool selectBlockAt(DocumentPosition position, ValueNotifier return true; } -bool selectParagraphAt({ - required DocumentPosition docPosition, - required DocumentLayout docLayout, - required ValueNotifier selection, -}) { - final newSelection = getParagraphSelection(docPosition: docPosition, docLayout: docLayout); - if (newSelection != null) { - selection.value = newSelection; - return true; - } else { - return false; +DocumentSelection? getBlockSelection(DocumentPosition caretPosition) { + if (caretPosition.nodePosition is! UpstreamDownstreamNodePosition) { + return null; } -} -void moveToNearestSelectableComponent( - Document document, - DocumentLayout documentLayout, - ValueNotifier selection, - String nodeId, - DocumentComponent component, -) { - // TODO: this was taken from CommonOps. We don't have CommonOps in this - // interactor, because it's for read-only documents. Selection operations - // should probably be moved to something outside of CommonOps - DocumentNode startingNode = document.getNodeById(nodeId)!; - String? newNodeId; - NodePosition? newPosition; - - // Try to find a new selection downstream. - final downstreamNode = _getDownstreamSelectableNodeAfter(document, () => documentLayout, startingNode); - if (downstreamNode != null) { - newNodeId = downstreamNode.id; - final nextComponent = documentLayout.getComponentByNodeId(newNodeId); - newPosition = nextComponent?.getBeginningPosition(); - } - - // Try to find a new selection upstream. - if (newPosition == null) { - final upstreamNode = _getUpstreamSelectableNodeBefore(document, () => documentLayout, startingNode); - if (upstreamNode != null) { - newNodeId = upstreamNode.id; - final previousComponent = documentLayout.getComponentByNodeId(newNodeId); - newPosition = previousComponent?.getBeginningPosition(); - } - } - - if (newNodeId == null || newPosition == null) { - return; - } - - selection.value = selection.value!.expandTo( - DocumentPosition( - nodeId: newNodeId, - nodePosition: newPosition, + return DocumentSelection( + base: DocumentPosition( + nodeId: caretPosition.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: caretPosition.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.downstream(), ), ); } bool moveCaretUpstream({ - required Document document, + required Editor editor, required DocumentLayout documentLayout, - required ValueNotifier selectionNotifier, MovementModifier? movementModifier, required bool retainCollapsedSelection, }) { - final selection = selectionNotifier.value; + final selection = editor.composer.selection; if (selection == null) { return false; } final currentExtent = selection.extent; final nodeId = currentExtent.nodeId; - final node = document.getNodeById(nodeId); + final node = editor.document.getNodeById(nodeId); if (node == null) { return false; } @@ -323,7 +219,7 @@ bool moveCaretUpstream({ if (newExtentNodePosition == null) { // Move to next node - final nextNode = _getUpstreamSelectableNodeBefore(document, () => documentLayout, node); + final nextNode = getUpstreamSelectableNodeBefore(editor.document, () => documentLayout, node); if (nextNode == null) { // We're at the beginning of the document and can't go anywhere. @@ -347,7 +243,13 @@ bool moveCaretUpstream({ if (newSelection.isCollapsed && !retainCollapsedSelection) { newSelection = null; } - selectionNotifier.value = newSelection; + editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.pushCaret, + SelectionReason.userInteraction, + ), + ]); return true; } @@ -369,20 +271,19 @@ bool moveCaretUpstream({ /// selection collapsed but the extent stayed in the same place. Returns /// [false] if the extent did not move and the selection did not change. bool moveCaretDownstream({ - required Document document, + required Editor editor, required DocumentLayout documentLayout, - required ValueNotifier selectionNotifier, MovementModifier? movementModifier, required bool retainCollapsedSelection, }) { - final selection = selectionNotifier.value; + final selection = editor.composer.selection; if (selection == null) { return false; } final currentExtent = selection.extent; final nodeId = currentExtent.nodeId; - final node = document.getNodeById(nodeId); + final node = editor.document.getNodeById(nodeId); if (node == null) { return false; } @@ -396,7 +297,7 @@ bool moveCaretDownstream({ if (newExtentNodePosition == null) { // Move to next node - final nextNode = _getDownstreamSelectableNodeAfter(document, () => documentLayout, node); + final nextNode = getDownstreamSelectableNodeAfter(editor.document, () => documentLayout, node); if (nextNode == null) { // We're at the beginning/end of the document and can't go @@ -421,7 +322,13 @@ bool moveCaretDownstream({ if (newSelection.isCollapsed && !retainCollapsedSelection) { newSelection = null; } - selectionNotifier.value = newSelection; + editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.pushCaret, + SelectionReason.userInteraction, + ), + ]); return true; } @@ -446,19 +353,18 @@ bool moveCaretDownstream({ /// selection collapsed but the extent stayed in the same place. Returns /// [false] if the extent did not move and the selection did not change. bool moveCaretUp({ - required Document document, - required ValueNotifier selectionNotifier, + required Editor editor, required DocumentLayout documentLayout, required bool retainCollapsedSelection, }) { - final selection = selectionNotifier.value; + final selection = editor.composer.selection; if (selection == null) { return false; } final currentExtent = selection.extent; final nodeId = currentExtent.nodeId; - final node = document.getNodeById(nodeId); + final node = editor.document.getNodeById(nodeId); if (node == null) { return false; } @@ -472,7 +378,7 @@ bool moveCaretUp({ if (newExtentNodePosition == null) { // Move to next node - final nextNode = _getUpstreamSelectableNodeBefore(document, () => documentLayout, node); + final nextNode = getUpstreamSelectableNodeBefore(editor.document, () => documentLayout, node); if (nextNode != null) { newExtentNodeId = nextNode.id; final nextComponent = documentLayout.getComponentByNodeId(nextNode.id); @@ -498,7 +404,14 @@ bool moveCaretUp({ if (newSelection.isCollapsed && !retainCollapsedSelection) { newSelection = null; } - selectionNotifier.value = newSelection; + + editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.pushCaret, + SelectionReason.userInteraction, + ), + ]); return true; } @@ -523,19 +436,18 @@ bool moveCaretUp({ /// selection collapsed but the extent stayed in the same place. Returns /// [false] if the extent did not move and the selection did not change. bool moveCaretDown({ - required Document document, + required Editor editor, required DocumentLayout documentLayout, - required ValueNotifier selectionNotifier, required bool retainCollapsedSelection, }) { - final selection = selectionNotifier.value; + final selection = editor.composer.selection; if (selection == null) { return false; } final currentExtent = selection.extent; final nodeId = currentExtent.nodeId; - final node = document.getNodeById(nodeId); + final node = editor.document.getNodeById(nodeId); if (node == null) { return false; } @@ -549,7 +461,7 @@ bool moveCaretDown({ if (newExtentNodePosition == null) { // Move to next node - final nextNode = _getDownstreamSelectableNodeAfter(document, () => documentLayout, node); + final nextNode = getDownstreamSelectableNodeAfter(editor.document, () => documentLayout, node); if (nextNode != null) { newExtentNodeId = nextNode.id; final nextComponent = documentLayout.getComponentByNodeId(nextNode.id); @@ -575,7 +487,13 @@ bool moveCaretDown({ if (newSelection.isCollapsed && !retainCollapsedSelection) { newSelection = null; } - selectionNotifier.value = newSelection; + editor.execute([ + ChangeSelectionRequest( + newSelection, + SelectionChangeType.pushCaret, + SelectionReason.userInteraction, + ), + ]); return true; } @@ -584,22 +502,27 @@ bool moveCaretDown({ /// /// Returns `true` if any content was selected, or `false` if the document /// is empty. -bool selectAll(Document document, ValueNotifier selection) { - final nodes = document.nodes; - if (nodes.isEmpty) { +bool selectAll(Editor editor) { + if (editor.document.isEmpty) { return false; } - selection.value = DocumentSelection( - base: DocumentPosition( - nodeId: nodes.first.id, - nodePosition: nodes.first.beginningPosition, - ), - extent: DocumentPosition( - nodeId: nodes.last.id, - nodePosition: nodes.last.endPosition, + editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: editor.document.first.id, + nodePosition: editor.document.first.beginningPosition, + ), + extent: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: editor.document.last.endPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, ), - ); + ]); return true; } diff --git a/super_editor/lib/src/infrastructure/_logging.dart b/super_editor/lib/src/infrastructure/_logging.dart index 89d5d3d2d3..563584afd5 100644 --- a/super_editor/lib/src/infrastructure/_logging.dart +++ b/super_editor/lib/src/infrastructure/_logging.dart @@ -2,16 +2,29 @@ import 'package:logging/logging.dart' as logging; +export 'package:logging/logging.dart' show Level; + class LogNames { static const editor = 'editor'; + static const editorEdits = 'editor.edits'; + static const editorPolicies = 'editor.policies'; static const editorScrolling = 'editor.scrolling'; static const editorGestures = 'editor.gestures'; static const editorKeys = 'editor.keys'; static const editorIme = 'editor.ime'; + static const editorImeConnection = 'editor.ime.connection'; + static const editorImeDeltas = 'editor.ime.deltas'; + static const editorIosFloatingCursor = 'editor.ios.floatingCursor'; static const editorLayout = 'editor.layout'; static const editorStyle = 'editor.style'; static const editorDocument = 'editor.document'; static const editorCommonOps = 'editor.ops'; + static const editorTokens = 'editor.tokens'; + static const editorTags = 'editor.tokens.tags'; + static const editorUserTags = 'editor.tokens.tags.users'; + static const editorHashTags = 'editor.tokens.tags.hash'; + static const editorActionTags = 'editor.tokens.tags.action'; + static const editorSpellingAndGrammar = 'editor.spellingAndGrammar'; static const reader = 'reader'; static const readerScrolling = 'reader.scrolling'; @@ -31,20 +44,44 @@ class LogNames { static const androidTextField = 'textfield.android'; static const iosTextField = 'textfield.ios'; + static const superIme = 'superime'; static const infrastructure = 'infrastructure'; + static const keyboardPanel = 'infrastructure.keyboardPanel'; + static const longPressSelection = 'infrastructure.gestures.longPress'; + static const scheduler = 'infrastructure.scheduler'; + static const contentLayers = 'infrastructure.content_layers'; static const attributions = 'infrastructure.attributions'; } +// Chat +final messagePageElementLog = logging.Logger('chat.messagePage.element'); +final messagePageLayoutLog = logging.Logger('chat.messagePage.layout'); +final messagePagePaintLog = logging.Logger('chat.messagePage.paint'); +final messageEditorHeightLog = logging.Logger('chat.messagePage.editorHeight'); + +// Super Editor final editorLog = logging.Logger(LogNames.editor); +final editorEditsLog = logging.Logger(LogNames.editorEdits); +final editorPoliciesLog = logging.Logger(LogNames.editorPolicies); final editorScrollingLog = logging.Logger(LogNames.editorScrolling); final editorGesturesLog = logging.Logger(LogNames.editorGestures); final editorKeyLog = logging.Logger(LogNames.editorKeys); final editorImeLog = logging.Logger(LogNames.editorIme); +final editorImeConnectionLog = logging.Logger(LogNames.editorImeConnection); +final editorImeDeltasLog = logging.Logger(LogNames.editorImeDeltas); +final editorIosFloatingCursorLog = logging.Logger(LogNames.editorIosFloatingCursor); final editorLayoutLog = logging.Logger(LogNames.editorLayout); final editorStyleLog = logging.Logger(LogNames.editorStyle); final editorDocLog = logging.Logger(LogNames.editorDocument); final editorOpsLog = logging.Logger(LogNames.editorCommonOps); - +final editorTokensLog = logging.Logger(LogNames.editorTokens); +final editorTagsLog = logging.Logger(LogNames.editorTags); +final editorStableTagsLog = logging.Logger(LogNames.editorUserTags); +final editorPatternTagsLog = logging.Logger(LogNames.editorHashTags); +final editorActionTagsLog = logging.Logger(LogNames.editorActionTags); +final editorSpellingAndGrammarLog = logging.Logger(LogNames.editorSpellingAndGrammar); + +// Super Reader final readerLog = logging.Logger(LogNames.reader); final readerScrollingLog = logging.Logger(LogNames.readerScrolling); final readerGesturesLog = logging.Logger(LogNames.readerGestures); @@ -55,14 +92,21 @@ final readerStyleLog = logging.Logger(LogNames.readerStyle); final readerDocLog = logging.Logger(LogNames.readerDocument); final readerOpsLog = logging.Logger(LogNames.readerCommonOps); +// Text Fields. final textFieldLog = logging.Logger(LogNames.textField); final scrollingTextFieldLog = logging.Logger(LogNames.scrollingTextField); final imeTextFieldLog = logging.Logger(LogNames.imeTextField); final androidTextFieldLog = logging.Logger(LogNames.androidTextField); final iosTextFieldLog = logging.Logger(LogNames.iosTextField); +// Infrastructure. final docGesturesLog = logging.Logger(LogNames.documentGestures); +final superImeLog = logging.Logger(LogNames.superIme); final infrastructureLog = logging.Logger(LogNames.infrastructure); +final keyboardPanelLog = logging.Logger(LogNames.keyboardPanel); +final longPressSelectionLog = logging.Logger(LogNames.longPressSelection); +final schedulerLog = logging.Logger(LogNames.scheduler); +final contentLayersLog = logging.Logger(LogNames.contentLayers); final attributionsLog = logging.Logger(LogNames.attributions); final _activeLoggers = {}; diff --git a/super_editor/lib/src/infrastructure/actions.dart b/super_editor/lib/src/infrastructure/actions.dart new file mode 100644 index 0000000000..c2b085585b --- /dev/null +++ b/super_editor/lib/src/infrastructure/actions.dart @@ -0,0 +1,201 @@ +import 'package:flutter/widgets.dart'; + +/// Blocks a set of intents from launching [Action]s. +/// +/// An [IntentBlocker] blocks [Action]s from running by stopping the associated +/// [Intent]s from bubbling any further up the widget tree. +/// +/// Flutter includes default [Action]s at app-level that might not be desirable for +/// all applications. +/// +/// To prevent [Intent]s from bubbling up to the default [Action]s, locate any widget +/// below the [Actions] widget that handles the desired [Intent]s (typically [MaterialApp] +/// or [WidgetsApp]), and wrap it with an [IntentBlocker] widget, passing the types +/// of the [Intent]s that should be blocked into the [intents] parameter. +/// +/// For example, the following code blocks [ScrollIntent]s from bubbling up: +/// +/// IntentBlocker( +/// intents: { ScrollIntent }, +/// child: child, +/// ) +class IntentBlocker extends StatelessWidget { + const IntentBlocker({ + super.key, + required this.intents, + required this.child, + }); + + /// The types of intents that should be blocked. + final Set intents; + + /// The rest of the widget tree. + final Widget child; + + @override + Widget build(BuildContext context) { + final actions = >{}; + for (final intent in intents) { + actions[intent] = DoNothingAction(consumesKey: false); + } + + return Actions( + actions: { + ...actions, + // Flutter might dispatch Intents individually or as a group. We want + // to also block any desired Intents when they are inside a group. + PrioritizedIntents: _BlockIntentInsideGroupAction( + intents: intents, + ) + }, + child: child, + ); + } +} + +/// A set of [Intent]s, which Flutter dispatches by default on non-Apple +/// platforms (Android, Windows, Linux), that should have its associated +/// [Action]s blocked. +/// +/// {@template flutter_default_actions} +/// For example: The user presses the SPACE key on web. By default +/// Flutter emits an `ActivateIntent` and a `ScrollIntent`. But you +/// probably want to insert a " " in some text instead of activating +/// or scrolling. Those default Flutter intents must be blocked for +/// two reasons. First, you don't want to scroll down every time you +/// type a space. Second, the SPACE key event won't have a chance to +/// be handled by the OS IME if Flutter handles the key with an [Action]. +/// Therefore, you should preveng this set of [Intent]s from bubbling up, +/// to block those default Flutter behaviors, let the IME handle the key event, +/// and enjoy expected text input. +/// {@endtemplate} +/// +/// To prevent the default Flutter [Action]s, locate the specific widget +/// where you want to run non-default behaviors, such as inserting a space +/// instead of scrolling. Wrap that widget with an [IntentBlocker] widget, and +/// then pass this set of [Intent] types to block the default behaviors: +/// +/// IntentBlocker( +/// intents: nonAppleBlockedIntents, +/// child: SuperEditor(), +/// ) +/// +/// See [WidgetsApp.defaultShortcuts] for the list of keybindings that Flutter +/// adds by default. +final Set nonAppleBlockedIntents = { + ActivateIntent, + ScrollIntent, +}; + +/// A set of [Intent]s, which Flutter dispatches by default on Apple +/// platforms (macOS and iOS), that should have its associated +/// [Action]s blocked. +/// +/// {@macro flutter_default_actions} +/// +/// To prevent the default Flutter [Action]s, locate the specific widget +/// where you want to run non-default behaviors, such as inserting a space +/// instead of scrolling. Wrap that widget with an [IntentBlocker] widget, and +/// then pass this set of [Intent] types to block the default behaviors: +/// +/// IntentBlocker( +/// intents: appleBlockedIntents, +/// child: SuperEditor(), +/// ) +/// +/// See [WidgetsApp.defaultShortcuts] for the list of keybindings that Flutter +/// adds by default. +final Set appleBlockedIntents = { + // Generated by pressing LEFT/RIGHT ARROW. + ExtendSelectionByCharacterIntent, + // Generated by pressing UP/DOWN ARROW. + ExtendSelectionVerticallyToAdjacentLineIntent, + // Generated by pressing PAGE UP/DOWN. + ScrollIntent, + // Generated by pressing HOME/END. + ScrollToDocumentBoundaryIntent, + // Generated by pressing TAB. + NextFocusIntent, + // Generated by pressing SHIFT + TAB. + PreviousFocusIntent, + // Generated by pressing SPACE. + ActivateIntent, +}; + +/// An [Action], which blocks certain [Intent]s dispatched inside a group of [Intent]s +/// from launching other [Action]s. +/// +/// A [_BlockIntentInsideGroupAction] blocks other [Action]s from running by stopping +/// the associated [Intent]s from bubbling any further up the widget tree. +/// +/// To prevent [Intent]s inside of a group from bubbling up to the default [Action]s, locate any widget +/// below the [Actions] widget that handles the desired [Intent]s (typically [MaterialApp] +/// or [WidgetsApp]), and wrap it with an [Actions] widget. Then, associate [PrioritizedIntents] +/// with [_BlockIntentInsideGroupAction] pass the set of [Intent]s that should be blocked. +/// +/// For example, the following code blocks [ScrollIntent]s from bubbling up: +/// +/// Actions( +/// actions: { +/// PrioritizedIntents: _BlockIntentInsideGroupAction( +/// intents: { ScrollIntent }, +/// ) +/// }, +/// child: child, +/// ); +class _BlockIntentInsideGroupAction extends Action { + _BlockIntentInsideGroupAction({ + required this.intents, + }); + + final Set intents; + + @override + bool consumesKey(PrioritizedIntents intent) => false; + + @override + void invoke(PrioritizedIntents intent) {} + + @override + bool isEnabled(PrioritizedIntents intent, [BuildContext? context]) { + final FocusNode? focus = primaryFocus; + if (focus == null || focus.context == null) { + return false; + } + + for (final candidateIntent in intent.orderedIntents) { + if (_hasEnabledAction(candidateIntent, context)) { + // Flutter wants to run an Action for this intent. + if (intents.contains(candidateIntent.runtimeType)) { + // We want to prevent this intent from bubbling up. Return `true` to + // signal to Flutter that we want to handle it. + return true; + } + + // We don't care about the intent that is going to have its corresponding + // Action executed. Let it bubble up so Flutter will execute it. + return false; + } + } + + // We didn't find any intents with a corresponding enabled Action. Let the + // intent bubble up. + return false; + } + + bool _hasEnabledAction(Intent intent, BuildContext? context) { + final Action? candidateAction = Actions.maybeFind( + primaryFocus!.context!, + intent: intent, + ); + + if (candidateAction == null) { + // We didn't find an Action associated with the given intent. + return false; + } + + return (candidateAction is ContextAction) + ? candidateAction.isEnabled(intent, context) + : candidateAction.isEnabled(intent); + } +} diff --git a/super_editor/lib/src/infrastructure/attributed_text_styles.dart b/super_editor/lib/src/infrastructure/attributed_text_styles.dart index 7ff66c2b44..37496ef984 100644 --- a/super_editor/lib/src/infrastructure/attributed_text_styles.dart +++ b/super_editor/lib/src/infrastructure/attributed_text_styles.dart @@ -1,5 +1,5 @@ import 'package:attributed_text/attributed_text.dart'; -import 'package:flutter/painting.dart'; +import 'package:flutter/widgets.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; /// Creates the desired [TextStyle] given the [attributions] associated @@ -9,15 +9,100 @@ import 'package:super_editor/src/infrastructure/_logging.dart'; typedef AttributionStyleBuilder = TextStyle Function(Set attributions); extension ToSpanRange on TextRange { - SpanRange toSpanRange() => SpanRange(start: start, end: end); + SpanRange toSpanRange() => SpanRange(start, end); } extension ComputeTextSpan on AttributedText { + /// Returns a Flutter [InlineSpan] comprised of styled text and widgets + /// based on an [AttributedText]. + /// + /// The given [styleBuilder] interprets the meaning of every attribution + /// and constructs [TextStyle]s accordingly. + /// + /// The given [inlineWidgetBuilders] interprets every placeholder `Object` + /// and builds a corresponding inline widget. + InlineSpan computeInlineSpan( + BuildContext context, + AttributionStyleBuilder styleBuilder, + InlineWidgetBuilderChain inlineWidgetBuilders, + ) { + if (isEmpty) { + // There is no text and therefore no attributions. + return TextSpan(text: '', style: styleBuilder({})); + } + + final inlineSpans = []; + + final collapsedSpans = spans.collapseSpans(contentLength: length); + + for (final span in collapsedSpans) { + final textStyle = styleBuilder(span.attributions); + + // A single span might be divided in multiple inline spans if there are placeholders. + // Keep track of the start of the current inline span. + int startOfMostRecentTextRun = span.start; + + // Look for placeholders within the current span and split the span accordingly. + for (int i = span.start; i <= span.end; i++) { + if (placeholders[i] != null) { + // We found a placeholder. Build a widget for it. + + if (i > startOfMostRecentTextRun) { + // There is text before the placeholder. Add the current text run to the span. + inlineSpans.add( + TextSpan( + text: substring(startOfMostRecentTextRun, i), + style: textStyle, + ), + ); + } + + Widget? inlineWidget; + for (final builder in inlineWidgetBuilders) { + inlineWidget = builder(context, textStyle, placeholders[i]!); + if (inlineWidget != null) { + break; + } + } + + if (inlineWidget != null) { + inlineSpans.add( + _LayoutOptimizedWidgetSpan( + alignment: PlaceholderAlignment.middle, + child: inlineWidget, + ), + ); + } + + // Start another inline span after the placeholder. + startOfMostRecentTextRun = i + 1; + } + } + + if (startOfMostRecentTextRun <= span.end) { + // There is text after the last placeholder or there is no placeholder at all. + inlineSpans.add( + TextSpan( + text: substring(startOfMostRecentTextRun, span.end + 1), + style: textStyle, + ), + ); + } + } + + return TextSpan( + text: "", + children: inlineSpans, + style: styleBuilder({}), + ); + } + /// Returns a Flutter [TextSpan] that is styled based on the /// attributions within this [AttributedText]. /// /// The given [styleBuilder] interprets the meaning of every /// attribution and constructs [TextStyle]s accordingly. + @Deprecated("Use computeInlineSpan() instead, which adds support for inline widgets.") TextSpan computeTextSpan(AttributionStyleBuilder styleBuilder) { attributionsLog.fine('text length: ${text.length}'); attributionsLog.fine('attributions used to compute spans:'); @@ -45,3 +130,63 @@ extension ComputeTextSpan on AttributedText { ); } } + +/// A Chain of Responsibility that builds widgets for text inline placeholders. +/// +/// The first [InlineWidgetBuilder] that returns a non-null [Widget] is used by +/// the client. +typedef InlineWidgetBuilderChain = List; + +/// Builder that returns a [Widget] for a given [placeholder], or `null` +/// if this builder doesn't know how to build the given [placeholder]. +/// +/// The given [textStyle] is the style applied to the text in the vicinity +/// of the placeholder. +typedef InlineWidgetBuilder = Widget? Function( + BuildContext context, + TextStyle textStyle, + Object placeholder, +); + +/// A [WidgetSpan] that does not re-layout its child changed. +/// +/// The [WidgetSpan] class always invalidates its layout when the child +/// widget changes. However, this shouldn't happen, since invalidating +/// the layout should happen at `RenderObject` level. +/// +/// When the child widget do change its layout, i.e., by changing its size, +/// the build pipeline will already mark the layout as dirty. +class _LayoutOptimizedWidgetSpan extends WidgetSpan { + const _LayoutOptimizedWidgetSpan({ + required Widget child, + required PlaceholderAlignment alignment, + }) : super(child: child, alignment: alignment); + + @override + RenderComparison compareTo(InlineSpan other) { + if (identical(this, other)) { + return RenderComparison.identical; + } + if (other.runtimeType != runtimeType) { + return RenderComparison.layout; + } + if ((style == null) != (other.style == null)) { + return RenderComparison.layout; + } + final typedOther = other as WidgetSpan; + if (alignment != typedOther.alignment) { + return RenderComparison.layout; + } + RenderComparison result = RenderComparison.identical; + if (style != null) { + final candidate = style!.compareTo(other.style!); + if (candidate.index > result.index) { + result = candidate; + } + if (result == RenderComparison.layout) { + return result; + } + } + return result; + } +} diff --git a/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart b/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart new file mode 100644 index 0000000000..87b1a2787c --- /dev/null +++ b/super_editor/lib/src/infrastructure/attribution_layout_bounds.dart @@ -0,0 +1,136 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; + +/// Places invisible widgets around runs of attributed text. +/// +/// The attributions that are bounded are selected with a given [selector]. +/// +/// The bounding widget is built with a given [builder], so that any number +/// of use-cases can be implemented with this widget. This widget is sized +/// as wide and tall as the attributed text run. If text is laid out across +/// multiple lines, the [builder] widget is made as wide and as tall as the +/// bounding box which includes all lines of that text. +class AttributionBounds extends ContentLayerStatefulWidget { + const AttributionBounds({ + Key? key, + required this.document, + required this.layout, + required this.selector, + required this.builder, + }) : super(key: key); + + final Document document; + final DocumentLayout layout; + final AttributionBoundsSelector selector; + final AttributionBoundsBuilder builder; + + @override + ContentLayerState> createState() => + _AttributionBoundsState(); +} + +class _AttributionBoundsState extends ContentLayerState> { + @override + void initState() { + super.initState(); + widget.document.addListener(_onDocumentChange); + } + + @override + void dispose() { + widget.document.removeListener(_onDocumentChange); + super.dispose(); + } + + void _onDocumentChange(DocumentChangeLog changeLog) { + if (!mounted) { + return; + } + + setState(() { + // Rebuild, which will cause ContentLayerState to re-compute layout data, i.e., attribution bounds. + }); + } + + @override + List? computeLayoutData(Element? contentElement, RenderObject? contentLayout) { + final bounds = []; + + for (final node in widget.document) { + if (node is! TextNode) { + continue; + } + + final spans = node.text.getAttributionSpansInRange( + attributionFilter: widget.selector, + range: SpanRange(0, node.text.length - 1), + ); + + for (final span in spans) { + final range = DocumentRange( + start: DocumentPosition(nodeId: node.id, nodePosition: TextNodePosition(offset: span.start)), + end: DocumentPosition(nodeId: node.id, nodePosition: TextNodePosition(offset: span.end + 1)), + ); + + bounds.add( + AttributionBoundsLayout( + span.attribution, + widget.layout.getRectForSelection(range.start, range.end) ?? Rect.zero, + ), + ); + } + } + + return bounds; + } + + @override + Widget doBuild(BuildContext context, List? layoutData) { + if (layoutData == null) { + return const SizedBox(); + } + + return IgnorePointer( + child: Stack( + children: _buildBounds(layoutData), + ), + ); + } + + List _buildBounds(List bounds) { + final boundWidgets = []; + for (final bound in bounds) { + final boundWidget = widget.builder(context, bound.attribution); + if (boundWidget != null) { + boundWidgets.add( + Positioned.fromRect( + rect: bound.rect, + child: boundWidget, + ), + ); + } + } + + return boundWidgets; + } +} + +class AttributionBoundsLayout { + const AttributionBoundsLayout(this.attribution, this.rect); + + final Attribution attribution; + final Rect rect; +} + +/// Filter function that decides whether the text with the given [attribution] +/// should have a widget boundary placed around it. +typedef AttributionBoundsSelector = bool Function(Attribution attribution); + +/// Builder that (optionally) returns a widget that is positioned at the size +/// and location of attributed text. +typedef AttributionBoundsBuilder = Widget? Function(BuildContext context, Attribution attribution); diff --git a/super_editor/lib/src/infrastructure/blinking_caret.dart b/super_editor/lib/src/infrastructure/blinking_caret.dart index 6df36084d7..806a103088 100644 --- a/super_editor/lib/src/infrastructure/blinking_caret.dart +++ b/super_editor/lib/src/infrastructure/blinking_caret.dart @@ -39,6 +39,7 @@ class BlinkingCaretState extends State with SingleTickerProviderS BlinkController( tickerProvider: this, ); + if (widget.caretOffset != null) { _caretBlinkController.jumpToOpaque(); } @@ -48,6 +49,17 @@ class BlinkingCaretState extends State with SingleTickerProviderS void didUpdateWidget(BlinkingCaret oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + if (oldWidget.controller == null) { + _caretBlinkController.dispose(); + } + + _caretBlinkController = widget.controller ?? + BlinkController( + tickerProvider: this, + ); + } + if (widget.caretOffset != oldWidget.caretOffset) { if (widget.caretOffset != null) { _caretBlinkController.jumpToOpaque(); @@ -116,7 +128,7 @@ class _CaretPainter extends CustomPainter { return; } - caretPaint.color = caretColor.withOpacity(blinkController.opacity); + caretPaint.color = caretColor.withValues(alpha: blinkController.opacity); final height = caretHeight?.roundToDouble() ?? size.height; diff --git a/super_editor/lib/src/infrastructure/content_layers.dart b/super_editor/lib/src/infrastructure/content_layers.dart new file mode 100644 index 0000000000..79913f74c8 --- /dev/null +++ b/super_editor/lib/src/infrastructure/content_layers.dart @@ -0,0 +1,729 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// Widget that displays [content] above a number of [underlays], and beneath a number of +/// [overlays]. +/// +/// This widget is similar in behavior to a `Stack`, except this widget alters the build +/// and layout order to support use-cases where various layers depend upon the layout of +/// a single [content] layer. +/// +/// This widget is useful for use-cases where decorations need to be positioned relative +/// to content within the [content] widget. For example, this [ContentLayers] might be +/// used to display a document as [content] and then display text selection as an +/// underlay, the caret as an overlay, and user comments as another overlay. +/// +/// The layers are sized to be exactly the same as the [content], and the layers are +/// positioned at the same (x,y) as [content]. +/// +/// The layers are built after [content] is laid out, so that the layers can inspect the +/// [content] layout during the layers' build phase. This makes it easy, for example, to +/// position a caret on top of a document, using only the widget tree. +/// +/// Some of the implementation details differ between `RenderBox` and `RenderSliver` use-cases, +/// therefore this class is abstract. Use either the box or sliver version of this widget +/// depending on your use-case. +abstract class ContentLayers extends RenderObjectWidget { + const ContentLayers({ + super.key, + this.underlays = const [], + required this.content, + this.overlays = const [], + }); + + /// Layers displayed beneath the [content]. + /// + /// These layers are placed at the same (x,y) as [content], and they're forced to layout + /// at the exact same size as [content]. + /// + /// {@template layers_as_builders} + /// Layers are structured as [WidgetBuilder]s so that they can be re-built whenever + /// the content layout changes, without interference from Flutter's standard build system. + /// Ideally, layers would be pure [Widget]s, but this is a consequence of how Flutter's + /// [BuildOwner] works. For more details, see https://github.com/flutter/flutter/issues/123305 + /// and https://github.com/superlistapp/super_editor/pull/1239 + /// {@endtemplate} + final List underlays; + + /// The primary content displayed in this widget, which determines the size and location + /// of all [underlays] and [overlays]. + final Widget Function(VoidCallback onBuildScheduled) content; + + /// Layers displayed above the [content]. + /// + /// These layers are placed at the same (x,y) as [content], and they're forced to layout + /// at the exact same size as [content]. + /// + /// {@macro layers_as_builders} + final List overlays; + + @override + RenderObjectElement createElement() { + return ContentLayersElement(this); + } + + @override + RenderObject createRenderObject(BuildContext context); +} + +/// `Element` for a [ContentLayers] widget. +/// +/// Must have a [renderObject] of type [RenderContentLayers]. +class ContentLayersElement extends RenderObjectElement { + /// The real Flutter framework `onBuildScheduled` callback. + /// + /// This property is non-null when one or more [ContentLayersElement]s are in the + /// tree, and `null` otherwise. + /// + /// This callback is held statically, rather than per-instance, because Flutter + /// might activate a new [ContentLayersElement] before deactivating an old + /// [ContentLayersElement], or there might be multiple [ContentLayersElement]s + /// in the tree. In these cases, we can't consistently replace Flutter's + /// `onBuildScheduled` callback without losing the original callback. + static VoidCallback? _realOnBuildScheduled; + + /// Listeners that are registered by [ContentLayersElement]s to find out when + /// the Flutter framework schedules builds, so that [ContentLayerElement]s can + /// manage their layers to avoid invalid build timing. + static final _onBuildListeners = {}; + + /// The Flutter framework has scheduled a build by calling `onBuildScheduled` + /// on a [BuildOwner]. + /// + /// This global static method calls build schedule listeners on all instances + /// of [ContentLayersElement], which registered a listener with [_onBuildListeners]. + static void _globalOnBuildScheduled() { + // Call the real Flutter onBuildScheduled callback so Flutter works as expected. + _realOnBuildScheduled!(); + + for (final listener in _onBuildListeners) { + listener(); + } + } + + ContentLayersElement(ContentLayers widget) : super(widget); + + List _underlays = []; + Element? _content; + List _overlays = []; + + // We need to track the children for which framework has called `forgetChild`, + // these need to be excluded from the `visitChildren` method until next `update()`. + // `forgetChild` is called for elements that will be re-parented to avoid unmounting + // and remounting them. + final Set _forgottenChildren = HashSet(); + + @override + ContentLayers get widget => super.widget as ContentLayers; + + @override + RenderContentLayers get renderObject => super.renderObject as RenderContentLayers; + + @override + void mount(Element? parent, Object? newSlot) { + contentLayersLog.fine("ContentLayersElement - mounting"); + super.mount(parent, newSlot); + + // Intercept calls to the BuildOwner's onBuildScheduled so that we can hijack an + // opportunity to check our subtrees for dirty elements before they rebuild. + if (_realOnBuildScheduled == null) { + _realOnBuildScheduled = owner!.onBuildScheduled!; + owner!.onBuildScheduled = _globalOnBuildScheduled; + _onBuildListeners.add(_onBuildScheduled); + } + + _content = inflateWidget(widget.content(_onContentBuildScheduled), contentSlot); + } + + @override + void activate() { + contentLayersLog.fine("ContentLayersElement - activating"); + super.activate(); + } + + @override + void deactivate() { + contentLayersLog.fine("ContentLayersElement - deactivating"); + // We have to deactivate the underlays and overlays ourselves, because we + // intentionally don't visit them in visitChildren(). + for (final underlay in _underlays) { + deactivateChild(underlay); + } + _underlays = const []; + + for (final overlay in _overlays) { + deactivateChild(overlay); + } + _overlays = const []; + + super.deactivate(); + } + + @override + void unmount() { + contentLayersLog.fine("ContentLayersElement - unmounting"); + + // Remove our intercepting onBuildScheduled callback. + _onBuildListeners.remove(_onBuildScheduled); + if (_onBuildListeners.isEmpty) { + owner!.onBuildScheduled = _realOnBuildScheduled; + } + + super.unmount(); + } + + void _onBuildScheduled() { + contentLayersLog.finer("ON BUILD SCHEDULED"); + + // Schedule a callback to run at the beginning of the next frame so we can check + // for dirty subtrees. + // + // If the content is dirty, but the layers are clean, then the layers won't attempt + // to rebuild, and we can let Flutter build the content whenever it wants. + // + // If a layer is dirty, but the content is clean, then the content layout is still + // valid, and we can let Flutter build the layer whenever it wants. + // + // However, if both the content and at least one layer are both dirty, then we must + // make absolutely sure that the content builds first. To do this, we deactivate the + // layer Elements, preventing Flutter from rebuilding them, and then we reactivate + // the layers during the next layout pass, after the content is laid out. + SchedulerBinding.instance.scheduleFrameCallback((timeStamp) { + contentLayersLog.finer("SCHEDULED FRAME CALLBACK"); + if (!mounted) { + contentLayersLog.finer("We've unmounted since the end of the frame. Fizzling."); + return; + } + + final isContentDirty = _isContentDirty(); + final isAnyLayerDirty = _isAnyLayerDirty(); + + if (isContentDirty && isAnyLayerDirty) { + contentLayersLog.fine("Marking needs build because content and at least one layer are both dirty."); + _temporarilyForgetLayers(); + } + }); + } + + bool _isContentDirty() => _isSubtreeDirty(_content!); + + bool _isAnyLayerDirty() { + contentLayersLog.finer("Checking if any layer is dirty"); + bool hasDirtyElements = false; + + contentLayersLog.finer("Checking underlays"); + for (final underlay in _underlays) { + contentLayersLog.finer(() => " - Is underlay ($underlay) subtree dirty? ${_isSubtreeDirty(underlay)}"); + hasDirtyElements = hasDirtyElements || _isSubtreeDirty(underlay); + } + + contentLayersLog.finer("Checking overlays"); + for (final overlay in _overlays) { + contentLayersLog.finer(() => " - Is overlay ($overlay) subtree dirty? ${_isSubtreeDirty(overlay)}"); + hasDirtyElements = hasDirtyElements || _isSubtreeDirty(overlay); + } + + return hasDirtyElements; + } + + static bool _isDirty = false; + + bool _isSubtreeDirty(Element element) { + _isDirty = false; + element.visitChildren(_isSubtreeDirtyVisitor); + return _isDirty; + } + +// This is intentionally static to prevent closure allocation during + // the traversal of the element tree. + static void _isSubtreeDirtyVisitor(Element element) { + // Can't use the () => message syntax because it allocates a closure. + assert(() { + if (contentLayersLog.isLoggable(Level.FINEST)) { + contentLayersLog.finest("Finding dirty children for: $element"); + } + return true; + }()); + if (element.dirty) { + assert(() { + if (contentLayersLog.isLoggable(Level.FINEST)) { + contentLayersLog.finest("Found a dirty child: $element"); + } + return true; + }()); + _isDirty = true; + return; + } + element.visitChildren(_isSubtreeDirtyVisitor); + } + + void _onContentBuildScheduled() { + _temporarilyForgetLayers(); + } + + @override + void markNeedsBuild() { + contentLayersLog.finer("ContentLayersElement - marking needs build"); + super.markNeedsBuild(); + } + + /// Builds the underlays and overlays. + void buildLayers() { + contentLayersLog.finer("ContentLayersElement - (re)building layers"); + final List underlays = List.filled(widget.underlays.length, _NullElement.instance); + for (int i = 0; i < underlays.length; i += 1) { + late final Element child; + if (i > _underlays.length - 1) { + child = inflateWidget(widget.underlays[i](this), UnderlaySlot(i)); + } else { + child = super.updateChild(_underlays[i], widget.underlays[i](this), UnderlaySlot(i))!; + } + underlays[i] = child; + } + _underlays = underlays; + + final List overlays = List.filled(widget.overlays.length, _NullElement.instance); + for (int i = 0; i < overlays.length; i += 1) { + late final Element child; + if (i > _overlays.length - 1) { + child = inflateWidget(widget.overlays[i](this), OverlaySlot(i)); + } else { + child = super.updateChild(_overlays[i], widget.overlays[i](this), OverlaySlot(i))!; + } + overlays[i] = child; + } + _overlays = overlays; + } + + @override + Element inflateWidget(Widget newWidget, Object? newSlot) { + final Element newChild = super.inflateWidget(newWidget, newSlot); + + assert(_debugCheckHasAssociatedRenderObject(newChild)); + + return newChild; + } + + /// Forgets the overlay and underlay children so that they don't run build at a + /// problematic time, but the same layers can be brought back later, with retained + /// `Element` and `State` objects. + /// + /// Note: If the layers are deactivated, rather than forgotten, new `Element`s and + /// `State`s will be created on every build, which prevents layer `State` objects + /// from retaining information across builds, thus defeating the purpose of using + /// a `StatefulWidget`. + void _temporarilyForgetLayers() { + contentLayersLog.finer("ContentLayersElement - temporarily forgetting layers"); + for (final underlay in _underlays) { + // Calling super.forgetChild directly to avoid adding it to _forgottenChildren. + // We're doing this to prevent the children from building, but not from + // being enumerated in visitChildren, which would happen with this.forgetChild. + super.forgetChild(underlay); + } + + for (final overlay in _overlays) { + // Calling super.forgetChild directly to avoid adding it to _forgottenChildren. + // We're doing this to prevent the children from building, but not from + // being enumerated in visitChildren, which would happen with this.forgetChild. + super.forgetChild(overlay); + } + } + + @override + void update(ContentLayers newWidget) { + super.update(newWidget); + + final newContent = widget.content(_onContentBuildScheduled); + + assert(widget == newWidget); + assert(!debugChildrenHaveDuplicateKeys(widget, [newContent])); + + _content = updateChild(_content, newContent, contentSlot); + + if (!renderObject.contentNeedsLayout) { + // Layout has already run. No layout bounds changed. There might be a + // non-layout change that needs to be painted, e.g., change to theme brightness. + // Re-build all layers, which is safe to do because no layout constraints changed. + buildLayers(); + } + // Else, dirty content layout will cause this whole widget to re-layout. The + // layers will be re-built during that layout pass. + + // super.update() and updateChild() is where the framework reparents + // forgotten children. Therefore, at this point, the framework is + // done with the concept of forgotten children, so we clear our + // local cache of them, too. + _forgottenChildren.clear(); + } + + @override + Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) { + if (newSlot != contentSlot) { + // Never update underlays or overlays because they MUST only build during + // layout. + return null; + } + + return super.updateChild(child, newWidget, newSlot); + } + + @override + void insertRenderObjectChild(RenderObject child, Object? slot) { + assert(slot != null); + assert(isContentLayersSlot(slot!), "Invalid ContentLayers slot: $slot"); + + renderObject.insertChild(child, slot!); + } + + @override + void moveRenderObjectChild(RenderObject child, Object? oldSlot, Object? newSlot) { + assert(child.parent == renderObject); + assert(oldSlot != null); + assert(newSlot != null); + assert(isContentLayersSlot(oldSlot!), "Invalid ContentLayers slot: $oldSlot"); + assert(isContentLayersSlot(newSlot!), "Invalid ContentLayers slot: $newSlot"); + + // Can't move renderBox children to and from content slot (which is a sliver) + if (oldSlot == contentSlot || newSlot == contentSlot) { + assert(false); + } else { + renderObject.moveChildLayer(child as RenderBox, oldSlot!, newSlot!); + } + } + + @override + void removeRenderObjectChild(RenderObject child, Object? slot) { + assert(child.parent == renderObject); + assert(slot != null); + assert(isContentLayersSlot(slot!), "Invalid ContentLayers slot: $slot"); + + renderObject.removeChild(child, slot!); + } + + @override + void forgetChild(Element child) { + _forgottenChildren.add(child); + super.forgetChild(child); + } + + @override + void visitChildren(ElementVisitor visitor) { + // It is the responsibility of `visitChildren` to skip over forgotten children. + if (_content != null && !_forgottenChildren.contains(_content)) { + visitor(_content!); + } + + // WARNING: Do not visit underlays or overlays when "locked". If you do, then the pipeline + // owner will collect those children for rebuild, e.g., for hot reload, and the + // pipeline owner will tell them to build before the content is laid out. We only + // want the underlays and overlays to build during the layout phase, after the + // content is laid out. + + // FIXME: locked is supposed to be private. We're using it as a proxy indication for when + // the build owner wants to build. Find an appropriate way to distinguish this. + // ignore: invalid_use_of_protected_member + if (!WidgetsBinding.instance.locked) { + for (final Element child in _underlays) { + if (!_forgottenChildren.contains(child)) { + visitor(child); + } + } + + for (final Element child in _overlays) { + if (!_forgottenChildren.contains(child)) { + visitor(child); + } + } + } + } + + bool _debugCheckHasAssociatedRenderObject(Element newChild) { + assert(() { + if (newChild.renderObject == null) { + FlutterError.reportError( + FlutterErrorDetails( + exception: FlutterError.fromParts([ + ErrorSummary('The children of `ContentLayersElement` must each have an associated render object.'), + ErrorHint( + 'This typically means that the `${newChild.widget}` or its children\n' + 'are not a subtype of `RenderObjectWidget`.', + ), + newChild.describeElement('The following element does not have an associated render object'), + DiagnosticsDebugCreator(DebugCreator(newChild)), + ]), + ), + ); + } + return true; + }()); + return true; + } +} + +abstract class RenderContentLayers implements RenderObject { + /// Whether this render object's layout information or its content + /// layout information is dirty. + /// + /// This is set to `true` when `markNeedsLayout` is called and it's + /// set to `false` after laying out the content. + bool get contentNeedsLayout; + + void insertChild(covariant RenderObject child, Object slot); + + void moveChildLayer(covariant RenderObject child, Object oldSlot, Object newSlot); + + void removeChild(covariant RenderObject child, Object slot); +} + +bool isContentLayersSlot(Object slot) => slot == contentSlot || slot is UnderlaySlot || slot is OverlaySlot; + +const contentSlot = "content"; + +class UnderlaySlot extends _IndexedSlot { + const UnderlaySlot(int index) : super(index); + + @override + String toString() => "[$UnderlaySlot] - underlay index: $index"; +} + +class OverlaySlot extends _IndexedSlot { + const OverlaySlot(int index) : super(index); + + @override + String toString() => "[$OverlaySlot] - overlay index: $index"; +} + +class _IndexedSlot { + const _IndexedSlot(this.index); + + final int index; +} + +/// Used as a placeholder in [List] objects when the actual +/// elements are not yet determined. +/// +/// Copied from the framework. +class _NullElement extends Element { + _NullElement() : super(const _NullWidget()); + + static _NullElement instance = _NullElement(); + + @override + bool get debugDoingBuild => throw UnimplementedError(); +} + +class _NullWidget extends Widget { + const _NullWidget(); + + @override + Element createElement() => throw UnimplementedError(); +} + +/// A widget builder, which builds a [ContentLayerWidget]. +typedef ContentLayerWidgetBuilder = ContentLayerWidget Function(BuildContext context); + +/// A widget that can be displayed as a layer in a [ContentLayers] widget. +/// +/// [ContentLayers] uses a special type of layer widget to avoid timing issues with +/// Flutter's build order. This timing issue is only a concern when a layer +/// widget inspects content layout within [ContentLayers]. However, to prevent +/// developer confusion and mistakes, all layer widgets are forced to be +/// [ContentLayerWidget]s. +/// +/// Extend [ContentLayerStatefulWidget] to create a layer that's based on the +/// content layout within the ancestor [ContentLayers], and requires mutable state. +/// +/// Extend [ContentLayerStatelessWidget] to create a layer that's based on the +/// content layout within the ancestor [ContentLayers], but doesn't require mutable +/// state. +/// +/// To quickly and easily build a layer from a traditional widget tree, create a +/// [ContentLayerProxyWidget] with the desired subtree. This approach is a +/// quicker and more convenient alternative to [ContentLayerStatelessWidget] +/// for the simplest of layer trees. +abstract class ContentLayerWidget implements Widget { + // Marker interface. +} + +/// A [ContentLayerWidget] that displays nothing. +/// +/// Useful when a layer should conditionally display content. An [EmptyContentLayer] can +/// be returned in cases where no visuals are desired. +class EmptyContentLayer extends ContentLayerStatelessWidget { + const EmptyContentLayer({super.key}); + + @override + Widget doBuild(BuildContext context, Element? contentElement, RenderObject? contentLayout) { + return const SizedBox(); + } +} + +/// Widget that builds a [ContentLayers] layer based on a traditional widget +/// subtree, as represented by the given [child]. +/// +/// The [child] subtree must NOT access the content layout within [ContentLayers]. +/// +/// This widget is an escape hatch to easily display traditional widget subtrees +/// as content layers, when those layers don't care about the layout of the content. +class ContentLayerProxyWidget extends ContentLayerStatelessWidget { + const ContentLayerProxyWidget({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget doBuild(BuildContext context, Element? contentElement, RenderObject? contentLayout) { + return child; + } +} + +/// Widget that builds a stateless [ContentLayers] layer, which is given access +/// to the ancestor [ContentLayers] content [Element] and [RenderObject]. +abstract class ContentLayerStatelessWidget extends StatelessWidget implements ContentLayerWidget { + const ContentLayerStatelessWidget({super.key}); + + @override + Widget build(BuildContext context) { + final contentLayers = (context as Element).findAncestorContentLayers(); + final contentElement = contentLayers?._content; + final contentLayout = contentElement?.findRenderObject(); + + return doBuild(context, contentElement, contentLayout); + } + + @protected + Widget doBuild(BuildContext context, Element? contentElement, RenderObject? contentLayout); +} + +/// Widget that builds a stateful [ContentLayers] layer, which is given access +/// to the ancestor [ContentLayers] content [Element] and [RenderObject]. +/// +/// See [ContentLayerState] for information about why a special type of [StatefulWidget] +/// is required for use within [ContentLayers]. +abstract class ContentLayerStatefulWidget extends StatefulWidget implements ContentLayerWidget { + const ContentLayerStatefulWidget({super.key}); + + @override + StatefulElement createElement() => ContentLayerStatefulElement(this); + + @override + ContentLayerState createState(); +} + +/// A [StatefulElement] that looks for an ancestor [ContentLayersElement] and marks +/// that element as needing to rebuild any time that this [ContentLayerStatefulElement] +/// needs to rebuild. +/// +/// In effect, this [Element] connects its dirty state to an ancestor [ContentLayersElement]. +class ContentLayerStatefulElement extends StatefulElement { + ContentLayerStatefulElement(super.widget); + + bool _isActive = false; + + @override + void activate() { + super.activate(); + _isActive = true; + } + + @override + void deactivate() { + _isActive = false; + super.deactivate(); + } + + @override + void markNeedsBuild() { + if (_isActive && mounted) { + // Our Element is attached to the tree. Mark our ancestor ContentLayers as + // needing to build, too. + // + // Flutter blows up if we try to climb the Element tree when this Element + // isn't active, because when this Element is deactivated, it's technically + // detached from the tree until its reactivated or disposed. + findAncestorContentLayers()?.markNeedsBuild(); + } + + super.markNeedsBuild(); + } +} + +extension on Element { + /// Finds and returns a [ContentLayersElement] by walking up the [Element] tree, + /// beginning with this [Element]. + ContentLayersElement? findAncestorContentLayers() { + ContentLayersElement? contentLayersElement; + + visitAncestorElements((element) { + if (element is ContentLayersElement) { + contentLayersElement = element; + return false; + } + + return true; + }); + + return contentLayersElement; + } +} + +/// A state object for a [ContentLayerStatefulWidget]. +/// +/// A [ContentLayerState] needs to be implemented a little bit differently than +/// a traditional [StatefulWidget]. Calling `setState()` will cause this widget +/// to rebuild, but the ancestor [ContentLayers] has no control over WHEN this +/// widget will rebuild. This widget might rebuild before the content layer can +/// run its layout. If this widget then attempts to query the content layout, +/// Flutter throws an exception. +/// +/// To work around the rebuild timing issues, a [ContentLayerState] separates +/// layout inspection from the build process. A [ContentLayerState] should +/// collect all the layout information it needs in [computeLayoutData] and then +/// it should build its subtree in [doBuild]. +/// +/// A [ContentLayerState] should NOT implement [build] - that implementation is +/// handled on your behalf, and it coordinates between [computeLayoutData] and +/// [doBuild]. +abstract class ContentLayerState + extends State { + @protected + LayoutDataType? get layoutData => _layoutData; + LayoutDataType? _layoutData; + + /// Traditional build method for this widget - this method should not be overridden + /// in subclasses. + @override + Widget build(BuildContext context) { + final contentLayers = (context as Element).findAncestorContentLayers(); + final contentElement = contentLayers?._content; + final contentLayout = contentElement?.findRenderObject(); + + if (contentLayers != null && !contentLayers.renderObject.contentNeedsLayout) { + _layoutData = computeLayoutData(contentElement, contentLayout); + } + + return doBuild(context, _layoutData); + } + + /// Computes and returns cached layout data, derived from the content layer's [Element] + /// and [RenderObject]. + /// + /// Subclasses can choose what action to take when the [contentElement] or [contentLayout] + /// are `null`, and therefore unavailable. + LayoutDataType? computeLayoutData(Element? contentElement, RenderObject? contentLayout); + + /// Composes and returns the subtree for this widget. + /// + /// This method should be treated as the replacement for the traditional [build] method. + /// + /// [doBuild] is provided with the latest available layout data, which was computed + /// by [computeLayoutData]. + @protected + Widget doBuild(BuildContext context, LayoutDataType? layoutData); +} diff --git a/super_editor/lib/src/infrastructure/content_layers_for_boxes.dart b/super_editor/lib/src/infrastructure/content_layers_for_boxes.dart new file mode 100644 index 0000000000..c9818d0a7b --- /dev/null +++ b/super_editor/lib/src/infrastructure/content_layers_for_boxes.dart @@ -0,0 +1,288 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; + +/// A [ContentLayers] widget that's implemented to work with Render Boxes. +class BoxContentLayers extends ContentLayers { + const BoxContentLayers({ + super.key, + super.underlays = const [], + required super.content, + super.overlays = const [], + }); + + @override + RenderBoxContentLayers createRenderObject(BuildContext context) { + return RenderBoxContentLayers(context as ContentLayersElement); + } +} + +/// `RenderObject` for a [BoxContentLayers] widget. +/// +/// Must be given an `Element` of type [ContentLayersElement]. +class RenderBoxContentLayers extends RenderBox implements RenderContentLayers { + RenderBoxContentLayers(this._element); + + @override + void dispose() { + _element = null; + super.dispose(); + } + + ContentLayersElement? _element; + + final _underlays = []; + RenderBox? _content; + final _overlays = []; + + @override + bool get contentNeedsLayout => _contentNeedsLayout; + bool _contentNeedsLayout = true; + + /// Whether we are in the middle of a [performLayout] call. + bool _runningLayout = false; + + @override + void attach(PipelineOwner owner) { + contentLayersLog.info("Attaching RenderBoxContentLayers to owner: $owner"); + super.attach(owner); + + visitChildren((child) { + child.attach(owner); + }); + } + + @override + void detach() { + contentLayersLog.info("detach()'ing RenderBoxContentLayers from pipeline"); + // IMPORTANT: we must detach ourselves before detaching our children. + // This is a Flutter framework requirement. + super.detach(); + + // Detach our children. + visitChildren((child) { + child.detach(); + }); + } + + @override + void markNeedsLayout() { + super.markNeedsLayout(); + + if (_runningLayout) { + // We are already in a layout phase. + // + // When we call ContentLayerElement.buildLayers, markNeedsLayout is called again. + // We don't want to mark the content as dirty, because otherwise the layers will + // never build. + return; + } + _contentNeedsLayout = true; + } + + @override + List debugDescribeChildren() { + final childDiagnostics = []; + + if (_content != null) { + childDiagnostics.add(_content!.toDiagnosticsNode(name: "content")); + } + + for (int i = 0; i < _underlays.length; i += 1) { + childDiagnostics.add(_underlays[i].toDiagnosticsNode(name: "underlay-$i")); + } + for (int i = 0; i < _overlays.length; i += 1) { + childDiagnostics.add(_overlays[i].toDiagnosticsNode(name: "overlay-#$i")); + } + + return childDiagnostics; + } + + @override + void insertChild(RenderBox child, Object slot) { + assert(isContentLayersSlot(slot)); + + if (slot == contentSlot) { + _content = child; + } else if (slot is UnderlaySlot) { + _underlays.insert(slot.index, child); + } else if (slot is OverlaySlot) { + _overlays.insert(slot.index, child); + } + + adoptChild(child); + } + + @override + void moveChildLayer(RenderBox child, Object oldSlot, Object newSlot) { + assert(oldSlot is UnderlaySlot || oldSlot is OverlaySlot); + assert(newSlot is UnderlaySlot || newSlot is OverlaySlot); + + if (oldSlot is UnderlaySlot) { + assert(_underlays.contains(child)); + _underlays.remove(child); + } else if (oldSlot is OverlaySlot) { + assert(_overlays.contains(child)); + _overlays.remove(child); + } + + if (newSlot is UnderlaySlot) { + _underlays.insert(newSlot.index, child); + } else if (newSlot is OverlaySlot) { + _overlays.insert(newSlot.index, child); + } + } + + @override + void removeChild(RenderBox child, Object slot) { + assert(isContentLayersSlot(slot)); + + if (slot == contentSlot) { + _content = null; + } else if (slot is UnderlaySlot) { + _underlays.remove(child); + } else if (slot is OverlaySlot) { + _overlays.remove(child); + } + + dropChild(child); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + if (_content != null) { + visitor(_content!); + } + + for (final RenderBox child in _underlays) { + visitor(child); + } + + for (final RenderBox child in _overlays) { + visitor(child); + } + } + + @override + Size computeDryLayout(BoxConstraints constraints) => _content?.computeDryLayout(constraints) ?? Size.zero; + + @override + double computeMinIntrinsicWidth(double height) => _content?.computeMinIntrinsicWidth(height) ?? 0.0; + + @override + double computeMaxIntrinsicWidth(double height) => _content?.computeMaxIntrinsicWidth(height) ?? 0.0; + + @override + double computeMinIntrinsicHeight(double width) => _content?.computeMinIntrinsicHeight(width) ?? 0.0; + + @override + double computeMaxIntrinsicHeight(double width) => _content?.computeMaxIntrinsicHeight(width) ?? 0.0; + + @override + void performLayout() { + contentLayersLog.info("Laying out BoxContentLayers"); + if (_content == null) { + size = Size.zero; + _contentNeedsLayout = false; + return; + } + + _runningLayout = true; + + // Always layout the content first, so that layers can inspect the content layout. + contentLayersLog.fine("Laying out content - $_content"); + _content!.layout(constraints, parentUsesSize: true); + contentLayersLog.fine("Content after layout: $_content"); + + // The size of the layers, and the our size, is exactly the same as the content. + size = _content!.size; + + _contentNeedsLayout = false; + + // Build the underlay and overlays during the layout phase so that they can inspect an + // up-to-date content layout. + // + // This behavior is what allows us to avoid layers that are always one frame behind the + // content changes. + contentLayersLog.fine("Building layers"); + invokeLayoutCallback((constraints) { + // Usually, widgets are built during the build phase, but we're building the layers + // during layout phase, so we need to explicitly tell Flutter to build all elements. + _element!.owner!.buildScope(_element!, () { + _element!.buildLayers(); + }); + }); + contentLayersLog.finer("Done building layers"); + + contentLayersLog.fine("Laying out layers (${_underlays.length} underlays, ${_overlays.length} overlays)"); + // Layout the layers below and above the content. + final layerConstraints = BoxConstraints.tight(size); + + for (final underlay in _underlays) { + contentLayersLog.fine("Laying out underlay: $underlay"); + underlay.layout(layerConstraints); + } + for (final overlay in _overlays) { + contentLayersLog.fine("Laying out overlay: $overlay"); + overlay.layout(layerConstraints); + } + + _runningLayout = false; + contentLayersLog.finer("Done laying out layers"); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + if (_content == null) { + return false; + } + + // Run hit tests in reverse-paint order. + bool didHit = false; + + // First, hit-test overlays. + for (final overlay in _overlays) { + didHit = overlay.hitTest(result, position: position); + if (didHit) { + return true; + } + } + + // Second, hit-test the content. + didHit = _content!.hitTest(result, position: position); + if (didHit) { + return true; + } + + // Third, hit-test the underlays. + for (final underlay in _underlays) { + didHit = underlay.hitTest(result, position: position) || didHit; + if (didHit) { + return true; + } + } + + return false; + } + + @override + void paint(PaintingContext context, Offset offset) { + if (_content == null) { + return; + } + + // First, paint the underlays. + for (final underlay in _underlays) { + context.paintChild(underlay, offset); + } + + // Second, paint the content. + context.paintChild(_content!, offset); + + // Third, paint the overlays. + for (final overlay in _overlays) { + context.paintChild(overlay, offset); + } + } +} diff --git a/super_editor/lib/src/infrastructure/content_layers_for_slivers.dart b/super_editor/lib/src/infrastructure/content_layers_for_slivers.dart new file mode 100644 index 0000000000..11098f5c8a --- /dev/null +++ b/super_editor/lib/src/infrastructure/content_layers_for_slivers.dart @@ -0,0 +1,334 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; + +/// A [ContentLayers] widget that's implemented to work with Slivers. +class SliverContentLayers extends ContentLayers { + const SliverContentLayers({ + super.key, + super.underlays = const [], + required super.content, + super.overlays = const [], + }); + + @override + RenderSliverContentLayers createRenderObject(BuildContext context) { + return RenderSliverContentLayers(context as ContentLayersElement); + } +} + +/// `RenderObject` for a [SliverContentLayers] widget. +/// +/// Must be given an `Element` of type [ContentLayersElement]. +class RenderSliverContentLayers extends RenderSliver with RenderSliverHelpers implements RenderContentLayers { + RenderSliverContentLayers(this._element); + + @override + void dispose() { + _element = null; + super.dispose(); + } + + ContentLayersElement? _element; + + final _underlays = []; + RenderSliver? _content; + final _overlays = []; + + @override + bool get contentNeedsLayout => _contentNeedsLayout; + bool _contentNeedsLayout = true; + + /// Whether we are at the middle of a [performLayout] call. + bool _runningLayout = false; + + @override + void attach(PipelineOwner owner) { + contentLayersLog.info("Attaching RenderSliverContentLayers to owner: $owner"); + super.attach(owner); + + visitChildren((child) { + child.attach(owner); + }); + } + + @override + void detach() { + contentLayersLog.info("detach()'ing RenderSliverContentLayers from pipeline"); + // IMPORTANT: we must detach ourselves before detaching our children. + // This is a Flutter framework requirement. + super.detach(); + + // Detach our children. + visitChildren((child) { + child.detach(); + }); + } + + @override + void markNeedsLayout() { + super.markNeedsLayout(); + + if (_runningLayout) { + // We are already in a layout phase. + // + // When we call ContentLayerElement.buildLayers, markNeedsLayout is called again. + // We don't to mark the content as dirty, because otherwise the layers will + // never build. + return; + } + _contentNeedsLayout = true; + } + + @override + List debugDescribeChildren() { + final childDiagnostics = []; + + if (_content != null) { + childDiagnostics.add(_content!.toDiagnosticsNode(name: "content")); + } + + for (int i = 0; i < _underlays.length; i += 1) { + childDiagnostics.add(_underlays[i].toDiagnosticsNode(name: "underlay-$i")); + } + for (int i = 0; i < _overlays.length; i += 1) { + childDiagnostics.add(_overlays[i].toDiagnosticsNode(name: "overlay-#$i")); + } + + return childDiagnostics; + } + + @override + void insertChild(RenderObject child, Object slot) { + assert(isContentLayersSlot(slot)); + + if (slot == contentSlot) { + _content = child as RenderSliver; + } else if (slot is UnderlaySlot) { + _underlays.insert(slot.index, child as RenderBox); + } else if (slot is OverlaySlot) { + _overlays.insert(slot.index, child as RenderBox); + } + + adoptChild(child); + } + + @override + void moveChildLayer(RenderBox child, Object oldSlot, Object newSlot) { + assert(oldSlot is UnderlaySlot || oldSlot is OverlaySlot); + assert(newSlot is UnderlaySlot || newSlot is OverlaySlot); + + if (oldSlot is UnderlaySlot) { + assert(_underlays.contains(child)); + _underlays.remove(child); + } else if (oldSlot is OverlaySlot) { + assert(_overlays.contains(child)); + _overlays.remove(child); + } + + if (newSlot is UnderlaySlot) { + _underlays.insert(newSlot.index, child); + } else if (newSlot is OverlaySlot) { + _overlays.insert(newSlot.index, child); + } + } + + @override + void removeChild(RenderObject child, Object slot) { + assert(isContentLayersSlot(slot)); + + if (slot == contentSlot) { + _content = null; + } else if (slot is UnderlaySlot) { + _underlays.remove(child); + } else if (slot is OverlaySlot) { + _overlays.remove(child); + } + + dropChild(child); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + if (_content != null) { + visitor(_content!); + } + + for (final RenderBox child in _underlays) { + visitor(child); + } + + for (final RenderBox child in _overlays) { + visitor(child); + } + } + + @override + void performLayout() { + contentLayersLog.info("Laying out SliverContentLayers"); + if (_content == null) { + geometry = SliverGeometry.zero; + _contentNeedsLayout = false; + return; + } + + _runningLayout = true; + + // Always layout the content first, so that layers can inspect the content layout. + contentLayersLog.fine("Laying out content - $_content"); + (_content!.parentData! as SliverLogicalParentData).layoutOffset = 0.0; + _content!.layout(constraints, parentUsesSize: true); + contentLayersLog.fine("Content after layout: $_content"); + + // The size of the layers, and the our size, is exactly the same as the content. + final SliverGeometry sliverLayoutGeometry = _content!.geometry!; + if (sliverLayoutGeometry.scrollOffsetCorrection != null) { + geometry = SliverGeometry( + scrollOffsetCorrection: sliverLayoutGeometry.scrollOffsetCorrection, + ); + return; + } + geometry = SliverGeometry( + scrollExtent: sliverLayoutGeometry.scrollExtent, + paintExtent: sliverLayoutGeometry.paintExtent, + maxPaintExtent: sliverLayoutGeometry.maxPaintExtent, + maxScrollObstructionExtent: sliverLayoutGeometry.maxScrollObstructionExtent, + cacheExtent: sliverLayoutGeometry.cacheExtent, + hasVisualOverflow: sliverLayoutGeometry.hasVisualOverflow, + ); + + _contentNeedsLayout = false; + + // Build the underlay and overlays during the layout phase so that they can inspect an + // up-to-date content layout. + // + // This behavior is what allows us to avoid layers that are always one frame behind the + // content changes. + contentLayersLog.fine("Building layers"); + invokeLayoutCallback((constraints) { + // Usually, widgets are built during the build phase, but we're building the layers + // during layout phase, so we need to explicitly tell Flutter to build all elements. + _element!.owner!.buildScope(_element!, () { + _element!.buildLayers(); + }); + }); + contentLayersLog.finer("Done building layers"); + + contentLayersLog.fine("Laying out layers (${_underlays.length} underlays, ${_overlays.length} overlays)"); + // Layout the layers below and above the content. + final layerConstraints = ScrollingBoxConstraints( + minWidth: constraints.crossAxisExtent, + maxWidth: constraints.crossAxisExtent, + minHeight: sliverLayoutGeometry.scrollExtent, + maxHeight: sliverLayoutGeometry.scrollExtent, + scrollOffset: constraints.scrollOffset, + ); + + for (final underlay in _underlays) { + final childParentData = underlay.parentData! as SliverLogicalParentData; + childParentData.layoutOffset = -constraints.scrollOffset; + contentLayersLog.fine("Laying out underlay: $underlay"); + underlay.layout(layerConstraints); + } + for (final overlay in _overlays) { + final childParentData = overlay.parentData! as SliverLogicalParentData; + childParentData.layoutOffset = -constraints.scrollOffset; + contentLayersLog.fine("Laying out overlay: $overlay"); + overlay.layout(layerConstraints); + } + + _runningLayout = false; + contentLayersLog.finer("Done laying out layers"); + } + + @override + bool hitTestChildren( + SliverHitTestResult result, { + required double mainAxisPosition, + required double crossAxisPosition, + }) { + if (_content == null) { + return false; + } + + // Run hit tests in reverse-paint order. + bool didHit = false; + + final boxResult = BoxHitTestResult.wrap(result); + + // First, hit-test overlays. + for (final overlay in _overlays) { + didHit = + hitTestBoxChild(boxResult, overlay, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); + if (didHit) { + return true; + } + } + + // Second, hit-test the content. + didHit = _content!.hitTest(result, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); + if (didHit) { + return true; + } + + // Third, hit-test the underlays. + for (final underlay in _underlays) { + didHit = hitTestBoxChild(boxResult, underlay, + mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); + if (didHit) { + return true; + } + } + + return false; + } + + @override + void paint(PaintingContext context, Offset offset) { + if (_content == null) { + return; + } + + void paintChild(RenderObject child) { + final childParentData = child.parentData! as SliverLogicalParentData; + context.paintChild( + child, + offset + Offset(0, childParentData.layoutOffset!), + ); + } + + // First, paint the underlays. + for (final underlay in _underlays) { + paintChild(underlay); + } + + // Second, paint the content. + paintChild(_content!); + + // Third, paint the overlays. + for (final overlay in _overlays) { + paintChild(overlay); + } + } + + @override + void applyPaintTransform(covariant RenderObject child, Matrix4 transform) { + final childParentData = child.parentData! as SliverLogicalParentData; + transform.translate(0.0, childParentData.layoutOffset!); + } + + @override + double childMainAxisPosition(covariant RenderObject child) { + final childParentData = child.parentData! as SliverLogicalParentData; + return childParentData.layoutOffset!; + } + + @override + void setupParentData(covariant RenderObject child) { + child.parentData = _ChildParentData(); + } +} + +class _ChildParentData extends SliverLogicalParentData with ContainerParentDataMixin {} diff --git a/super_editor/lib/src/infrastructure/document_context.dart b/super_editor/lib/src/infrastructure/document_context.dart new file mode 100644 index 0000000000..9febf1f879 --- /dev/null +++ b/super_editor/lib/src/infrastructure/document_context.dart @@ -0,0 +1,149 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; + +/// Collection of core artifacts used to create various document use-cases. +class DocumentContext { + DocumentContext({ + required this.editor, + required DocumentLayout Function() getDocumentLayout, + }) : _getDocumentLayout = getDocumentLayout; + + final Editor editor; + + /// The [Document] that's currently being displayed. + Document get document => editor.document; + + /// The current selection within the displayed document. + DocumentComposer get composer => editor.composer; + + /// The document layout that is a visual representation of the document. + /// + /// This member might change over time. + DocumentLayout get documentLayout => _getDocumentLayout(); + final DocumentLayout Function() _getDocumentLayout; +} + +/// Executes this action, if the action wants to run, and returns +/// a desired [ExecutionInstruction] to either continue or halt +/// execution of actions. +/// +/// It is possible that an action makes changes and then returns +/// [ExecutionInstruction.continueExecution] to continue execution. +/// +/// It is possible that an action does nothing and then returns +/// [ExecutionInstruction.haltExecution] to prevent further execution. +typedef DocumentKeyboardAction = ExecutionInstruction Function({ + required DocumentContext documentContext, + required KeyEvent keyEvent, +}); + +/// A proxy for a [DocumentKeyboardAction] that filters events based +/// on [onKeyUp], [onKeyDown], and [shortcut]. +/// +/// If [onKeyUp] is `false`, all key-up events are ignored. If [onKeyDown] is +/// `false`, all key-down events are ignored. If [shortcut] is non-null, all +/// events that don't match the [shortcut] key presses are ignored. +/// +/// This proxy is optional. Individual [DocumentKeyboardAction]s can +/// make these same decisions about key handling. This proxy is provided as +/// a convenience for the average use-case, which typically tries to match +/// a specific shortcut for either an up or down key event. +DocumentKeyboardAction createDocumentShortcut( + DocumentKeyboardAction action, { + LogicalKeyboardKey? keyPressedOrReleased, + Set? triggers, + bool? isShiftPressed, + bool? isCmdPressed, + bool? isCtlPressed, + bool? isAltPressed, + bool onKeyUp = false, + bool onKeyDown = true, + Set? platforms, +}) { + if (onKeyUp == false && onKeyDown == false) { + throw Exception( + "Invalid shortcut definition. Both onKeyUp and onKeyDown are false. This shortcut will never be triggered."); + } + + return ({required DocumentContext documentContext, required KeyEvent keyEvent}) { + if (keyEvent is KeyUpEvent && !onKeyUp) { + return ExecutionInstruction.continueExecution; + } + + if ((keyEvent is KeyDownEvent || keyEvent is KeyRepeatEvent) && !onKeyDown) { + return ExecutionInstruction.continueExecution; + } + + if (isCmdPressed != null && isCmdPressed != HardwareKeyboard.instance.isMetaPressed) { + return ExecutionInstruction.continueExecution; + } + + if (isCtlPressed != null && isCtlPressed != HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + + if (isAltPressed != null && isAltPressed != HardwareKeyboard.instance.isAltPressed) { + return ExecutionInstruction.continueExecution; + } + + if (isShiftPressed != null) { + if (isShiftPressed && !HardwareKeyboard.instance.isShiftPressed) { + return ExecutionInstruction.continueExecution; + } else if (!isShiftPressed && HardwareKeyboard.instance.isShiftPressed) { + return ExecutionInstruction.continueExecution; + } + } + + if (keyPressedOrReleased != null && keyEvent.logicalKey != keyPressedOrReleased) { + // Manually account for the fact that Flutter pretends that different + // shift keys mean different things. + if ((keyPressedOrReleased == LogicalKeyboardKey.shift || + keyPressedOrReleased == LogicalKeyboardKey.shiftLeft || + keyPressedOrReleased == LogicalKeyboardKey.shiftRight) && + (keyEvent.logicalKey == LogicalKeyboardKey.shift || + keyEvent.logicalKey == LogicalKeyboardKey.shiftLeft || + keyEvent.logicalKey == LogicalKeyboardKey.shiftRight)) { + // This is a false positive signal. We're looking for a shift key trigger, and + // one of the shifts is the trigger. We don't care which one. + } else { + return ExecutionInstruction.continueExecution; + } + } + + if (triggers != null) { + for (final key in triggers) { + if (!HardwareKeyboard.instance.isLogicalKeyPressed(key)) { + // Manually account for the fact that Flutter pretends that different + // shift keys mean different things. + if (key == LogicalKeyboardKey.shift || + key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight) { + if (keyEvent.logicalKey == LogicalKeyboardKey.shift || + keyEvent.logicalKey == LogicalKeyboardKey.shiftLeft || + keyEvent.logicalKey == LogicalKeyboardKey.shiftRight) { + // This is a false positive signal. We're looking for a shift key trigger, and + // one of the shifts is the trigger. We don't care which one. + continue; + } + } + + // A required trigger key isn't currently pressed. We don't + // want to respond to this key event. + return ExecutionInstruction.continueExecution; + } + } + } + + if (platforms != null && !platforms.contains(defaultTargetPlatform)) { + return ExecutionInstruction.continueExecution; + } + + // The key event has passed all the proxy conditions. Run the real key action. + return action(documentContext: documentContext, keyEvent: keyEvent); + }; +} diff --git a/super_editor/lib/src/infrastructure/document_gestures_interaction_overrides.dart b/super_editor/lib/src/infrastructure/document_gestures_interaction_overrides.dart new file mode 100644 index 0000000000..be28f12962 --- /dev/null +++ b/super_editor/lib/src/infrastructure/document_gestures_interaction_overrides.dart @@ -0,0 +1,78 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; + +/// Delegate for mouse status and clicking on special types of content, +/// e.g., tapping on a link open the URL. +/// +/// Each [ContentTapDelegate] notifies its listeners whenever an +/// internal policy changes, which might impact the mouse cursor +/// style. For example, a handler in a desktop app, when hovering +/// over a link, might initially show a text cursor, but when the +/// user pressed CMD (or CTL), the mouse cursor would change to a +/// click cursor. Only the individual handlers know when or if such +/// a change should occur. When such a change does occur, the +/// handler notifies its listeners, and the handler expects that +/// someone will ask it for the desired mouse cursor style. +abstract class ContentTapDelegate with ChangeNotifier { + MouseCursor? mouseCursorForContentHover(DocumentPosition hoverPosition) { + return null; + } + + TapHandlingInstruction onTap(DocumentTapDetails details) { + return TapHandlingInstruction.continueHandling; + } + + TapHandlingInstruction onDoubleTap(DocumentTapDetails details) { + return TapHandlingInstruction.continueHandling; + } + + TapHandlingInstruction onTripleTap(DocumentTapDetails details) { + return TapHandlingInstruction.continueHandling; + } + + TapHandlingInstruction onPanStart(DocumentTapDetails details) { + return TapHandlingInstruction.continueHandling; + } + + TapHandlingInstruction onPanUpdate(DocumentTapDetails details) { + return TapHandlingInstruction.continueHandling; + } + + TapHandlingInstruction onPanEnd(DocumentTapDetails details) { + return TapHandlingInstruction.continueHandling; + } + + TapHandlingInstruction onPanCancel() { + return TapHandlingInstruction.continueHandling; + } +} + +/// Information about a gesture that occured within a [DocumentLayout]. +class DocumentTapDetails { + DocumentTapDetails({ + required this.documentLayout, + required this.layoutOffset, + required this.globalOffset, + }); + + /// The document layout. + /// + /// It can be used to pull information about the logical position + /// where the tap occurred. For example, to find the [DocumentPosition] + /// that is nearest to the tap, to find if the tap ocurred above + /// the first node or below the last node, etc. + final DocumentLayout documentLayout; + + /// The position of the gesture in [DocumentLayout]'s coordinate space. + final Offset layoutOffset; + + /// The position of the gesture in global coordinates. + final Offset globalOffset; +} + +enum TapHandlingInstruction { + halt, + continueHandling, +} diff --git a/super_editor/lib/src/infrastructure/documents/document_layers.dart b/super_editor/lib/src/infrastructure/documents/document_layers.dart new file mode 100644 index 0000000000..e16d290a9a --- /dev/null +++ b/super_editor/lib/src/infrastructure/documents/document_layers.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; + +/// A [ContentLayerStatelessWidget] that expects a content layer [Element] that +/// implements [DocumentLayout]. +/// +/// {@template document_layout_layer} +/// When working with documents, there might be any number of layers that need to +/// inspect the document layout. Each layer could manually locate the associated +/// [DocumentLayout], but to remove that repeated effort, this widget finds and +/// provides the [DocumentLayout] to its subclasses, so the subclasses can focus +/// on inspecting that layout. +/// {@endtemplate} +abstract class DocumentLayoutLayerStatelessWidget extends ContentLayerStatelessWidget { + const DocumentLayoutLayerStatelessWidget({super.key}); + + @override + Widget doBuild(BuildContext context, Element? contentElement, RenderObject? contentLayout) { + if (contentElement == null || contentElement is! StatefulElement || contentElement.state is! DocumentLayout) { + return const SizedBox(); + } + + return buildWithDocumentLayout(context, contentElement.state as DocumentLayout); + } + + @protected + Widget buildWithDocumentLayout(BuildContext context, DocumentLayout documentLayout); +} + +/// A [ContentLayerStatefulWidget] that expects a content layer [Element] that +/// implements [DocumentLayout]. +/// +/// {@macro document_layout_layer} +abstract class DocumentLayoutLayerStatefulWidget extends ContentLayerStatefulWidget { + const DocumentLayoutLayerStatefulWidget({super.key}); + + @override + DocumentLayoutLayerState createState(); +} + +abstract class DocumentLayoutLayerState + extends ContentLayerState { + @override + LayoutDataType? computeLayoutData(Element? contentElement, RenderObject? contentLayout) { + if (contentElement == null || contentElement is! StatefulElement || contentElement.state is! DocumentLayout) { + return null; + } + + return computeLayoutDataWithDocumentLayout(context, contentElement, contentElement.state as DocumentLayout); + } + + @protected + LayoutDataType? computeLayoutDataWithDocumentLayout( + BuildContext contentLayersContext, BuildContext documentContext, DocumentLayout documentLayout); +} diff --git a/super_editor/lib/src/infrastructure/documents/document_scaffold.dart b/super_editor/lib/src/infrastructure/documents/document_scaffold.dart new file mode 100644 index 0000000000..ca7fb8b994 --- /dev/null +++ b/super_editor/lib/src/infrastructure/documents/document_scaffold.dart @@ -0,0 +1,159 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/src/core/document_debug_paint.dart'; +import 'package:super_editor/src/default_editor/document_scrollable.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_layout.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/content_layers_for_slivers.dart'; +import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; + +/// A scaffold that combines pieces to create a scrolling single-column document, with +/// gestures placed beneath the document. +/// +/// This scaffold was created to de-duplicate significant overlap between `SuperEditor` +/// and `SuperReader`. This class is probably not generally useful. +class DocumentScaffold extends StatefulWidget { + const DocumentScaffold({ + super.key, + required this.documentLayoutLink, + required this.documentLayoutKey, + required this.viewportDecorationBuilder, + required this.gestureBuilder, + this.textInputBuilder, + this.scrollController, + required this.autoScrollController, + required this.scroller, + this.isScribbleInProgress, + required this.presenter, + required this.componentBuilders, + required this.shrinkWrap, + this.underlays = const [], + this.overlays = const [], + this.debugPaint = const DebugPaintConfig(), + }); + + /// [LayerLink] that's is attached to the document layout. + final LayerLink documentLayoutLink; + + /// [GlobalKey] that's attached to the document layout. + final GlobalKey documentLayoutKey; + + /// Builder that creates a gesture interaction widget, which is displayed + /// beneath the document, at the same size as the viewport. + final Widget Function(BuildContext context, {required Widget child}) + gestureBuilder; + + /// Builds the text input widget, if applicable. The text input system is placed + /// above the gesture system and beneath viewport decoration. + final Widget Function(BuildContext context, {required Widget child})? + textInputBuilder; + + /// Builds platform specific viewport decoration (such as toolbar overlay manager or magnifier overlay manager). + final Widget Function(BuildContext context, {required Widget child}) + viewportDecorationBuilder; + + /// Controls scrolling when this [DocumentScaffold] adds its own `Scrollable`, but + /// doesn't provide scrolling control when this [DocumentScaffold] uses an ancestor + /// `Scrollable`. + final ScrollController? scrollController; + + /// Controls auto-scrolling of the document's viewport. + final AutoScrollController autoScrollController; + + /// A [DocumentScroller], to which this scrollable attaches itself, so + /// that external actors, such as keyboard handlers, can query and change + /// the scroll offset. + final DocumentScroller? scroller; + + /// A listenable that reports whether a Scribble or stylus writing interaction + /// is currently in progress. When active, scrolling is disabled to avoid + /// interfering with scribble input. + final ValueListenable? isScribbleInProgress; + + /// Presenter that computes styles for a single-column layout, e.g., component padding, + /// text styles, selection. + final SingleColumnLayoutPresenter presenter; + + /// Priority list of widget factories that create instances of + /// each visual component displayed in the document layout, e.g., + /// paragraph component, image component, horizontal rule component, etc. + final List componentBuilders; + + /// Layers that are displayed below the document layout, aligned + /// with the location and size of the document layout. + final List underlays; + + /// Layers that are displayed on top of the document layout, aligned + /// with the location and size of the document layout. + final List overlays; + + /// Paints some extra visual ornamentation to help with debugging. + final DebugPaintConfig debugPaint; + + /// Whether the document should shrink-wrap its content. + /// Only used when the document is not inside a scrollable. + final bool shrinkWrap; + + @override + State createState() => _DocumentScaffoldState(); +} + +class _DocumentScaffoldState extends State { + @override + Widget build(BuildContext context) { + var child = _buildGestureSystem( + child: _buildDocumentLayout(), + ); + if (widget.textInputBuilder != null) { + child = widget.textInputBuilder!(context, child: child); + } + return _buildDocumentScrollable( + child: widget.viewportDecorationBuilder( + context, + child: child, + ), + ); + } + + /// Builds the widget tree that scrolls the document. This subtree might + /// introduce its own Scrollable, or it might defer to an ancestor + /// scrollable. This subtree also hooks up auto-scrolling capabilities. + Widget _buildDocumentScrollable({ + required Widget child, + }) { + return DocumentScrollable( + autoScroller: widget.autoScrollController, + scrollController: widget.scrollController, + scrollingMinimapId: widget.debugPaint.scrollingMinimapId, + scroller: widget.scroller, + isScribbleInProgress: widget.isScribbleInProgress, + shrinkWrap: widget.shrinkWrap, + showDebugPaint: widget.debugPaint.scrolling, + child: child, + ); + } + + /// Builds the widget tree that handles user gesture interaction + /// with the document, e.g., mouse input on desktop, or touch input + /// on mobile. + Widget _buildGestureSystem({ + required Widget child, + }) { + return widget.gestureBuilder(context, child: child); + } + + Widget _buildDocumentLayout() { + return SliverContentLayers( + content: (onBuildScheduled) => SingleColumnDocumentLayout( + key: widget.documentLayoutKey, + presenter: widget.presenter, + componentBuilders: widget.componentBuilders, + onBuildScheduled: onBuildScheduled, + showDebugPaint: widget.debugPaint.layout, + ), + underlays: widget.underlays, + overlays: widget.overlays, + ); + } +} diff --git a/super_editor/lib/src/infrastructure/documents/document_scroller.dart b/super_editor/lib/src/infrastructure/documents/document_scroller.dart new file mode 100644 index 0000000000..b6f6402733 --- /dev/null +++ b/super_editor/lib/src/infrastructure/documents/document_scroller.dart @@ -0,0 +1,74 @@ +import 'package:flutter/widgets.dart'; + +/// Scrolling status and controls for a document experience. +/// +/// Depending on the surrounding widget tree, a [DocumentScroller] might be attached +/// to a descendant `Scrollable`, which was added by the document experience widget +/// (like `SuperEditor` or `SuperReader`). Or, a [DocumentScroller] might be attached +/// to an ancestor `Scrollable`, if the document experience chooses to use an +/// ancestor `Scrollable`. +class DocumentScroller { + void dispose() { + _scrollChangeListeners.clear(); + } + + /// The height of a vertically scrolling viewport, or the width of a horizontally + /// scrolling viewport. + double get viewportDimension => _scrollPosition!.viewportDimension; + + /// The smallest possible scrolling offset, which is usually zero. + double get minScrollExtent => _scrollPosition!.minScrollExtent; + + /// The maximum possible scrolling offset, at which point the end of the scrolling + /// content is visible in the viewport. + double get maxScrollExtent => _scrollPosition!.maxScrollExtent; + + /// The current scroll offset in the viewport, which is represented by the number + /// of pixels between the top-left corner of the viewport, and the top-left corner + /// of the content that sits inside the viewport. + double get scrollOffset => _scrollPosition!.pixels; + + /// Immediately moves the [scrollOffset] to [newScrollOffset]. + void jumpTo(double newScrollOffset) { + _scrollPosition!.jumpTo(newScrollOffset); + } + + /// Immediately moves the [scrollOffset] by [delta] pixels. + void jumpBy(double delta) { + _scrollPosition!.jumpTo(_scrollPosition!.pixels + delta); + } + + /// Animates [scrollOffset] from its current offset to [to], over the given [duration] + /// of time, following the given animation [curve]. + void animateTo( + double to, { + required Duration duration, + Curve curve = Curves.easeInOut, + }) { + _scrollPosition!.animateTo(to, duration: duration, curve: curve); + } + + ScrollPosition? _scrollPosition; + + void attach(ScrollPosition scrollPosition) { + _scrollPosition = scrollPosition; + _scrollPosition!.addListener(_notifyScrollChangeListeners); + } + + void detach() { + _scrollPosition?.removeListener(_notifyScrollChangeListeners); + _scrollPosition = null; + } + + final _scrollChangeListeners = {}; + + void addScrollChangeListener(VoidCallback listener) => _scrollChangeListeners.add(listener); + + void removeScrollChangeListener(VoidCallback listener) => _scrollChangeListeners.remove(listener); + + void _notifyScrollChangeListeners() { + for (final listener in _scrollChangeListeners) { + listener(); + } + } +} diff --git a/super_editor/lib/src/infrastructure/documents/document_selection.dart b/super_editor/lib/src/infrastructure/documents/document_selection.dart new file mode 100644 index 0000000000..67acc833a3 --- /dev/null +++ b/super_editor/lib/src/infrastructure/documents/document_selection.dart @@ -0,0 +1,60 @@ +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; + +/// Given a [DocumentSelection], which might span text and non-text content, extracts +/// all text from that selection as an un-styled `String`. +String extractTextFromSelection({ + required Document document, + required DocumentSelection documentSelection, +}) { + final selectedNodes = document.getNodesInside( + documentSelection.base, + documentSelection.extent, + ); + + final buffer = StringBuffer(); + for (int i = 0; i < selectedNodes.length; ++i) { + final selectedNode = selectedNodes[i]; + dynamic nodeSelection; + + if (i == 0) { + // This is the first node and it may be partially selected. + final baseSelectionPosition = selectedNode.id == documentSelection.base.nodeId + ? documentSelection.base.nodePosition + : documentSelection.extent.nodePosition; + + final extentSelectionPosition = + selectedNodes.length > 1 ? selectedNode.endPosition : documentSelection.extent.nodePosition; + + nodeSelection = selectedNode.computeSelection( + base: baseSelectionPosition, + extent: extentSelectionPosition, + ); + } else if (i == selectedNodes.length - 1) { + // This is the last node and it may be partially selected. + final nodePosition = selectedNode.id == documentSelection.base.nodeId + ? documentSelection.base.nodePosition + : documentSelection.extent.nodePosition; + + nodeSelection = selectedNode.computeSelection( + base: selectedNode.beginningPosition, + extent: nodePosition, + ); + } else { + // This node is fully selected. Copy the whole thing. + nodeSelection = selectedNode.computeSelection( + base: selectedNode.beginningPosition, + extent: selectedNode.endPosition, + ); + } + + final nodeContent = selectedNode.copyContent(nodeSelection); + if (nodeContent != null) { + buffer.write(nodeContent); + if (i < selectedNodes.length - 1) { + buffer.writeln(); + } + } + } + return buffer.toString(); +} diff --git a/super_editor/lib/src/infrastructure/documents/selection_leader_document_layer.dart b/super_editor/lib/src/infrastructure/documents/selection_leader_document_layer.dart new file mode 100644 index 0000000000..93fff3ba90 --- /dev/null +++ b/super_editor/lib/src/infrastructure/documents/selection_leader_document_layer.dart @@ -0,0 +1,238 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; + +/// A document layer that positions leader widgets at the user's selection bounds. +/// +/// A collapsed selection has a single leader where the caret should appear. An expanded +/// selection has a leader at both sides of the selection, as well as a leader that spans +/// the entire expanded selection. Leader width is always `1` and leader height is based +/// on the document's self-reported caret height for the given document position. +/// +/// When no selection exists, no leaders are built in the layer's widget tree. +class SelectionLeadersDocumentLayer extends DocumentLayoutLayerStatefulWidget { + const SelectionLeadersDocumentLayer({ + Key? key, + required this.document, + required this.selection, + required this.links, + this.showDebugLeaderBounds = false, + }) : super(key: key); + + /// The editor's [Document], which is used to find the start and end of + /// the user's expanded selection. + final Document document; + + /// The current user's selection within a document. + final ValueListenable selection; + + /// Collections of [LayerLink]s, which are given to leader widgets that are + /// positioned at the selection bounds, and around the full selection. + final SelectionLayerLinks links; + + /// Whether to paint colorful bounds around the leader widgets, for debugging purposes. + final bool showDebugLeaderBounds; + + @override + DocumentLayoutLayerState createState() => + _SelectionLeadersDocumentLayerState(); +} + +class _SelectionLeadersDocumentLayerState + extends DocumentLayoutLayerState + with SingleTickerProviderStateMixin { + @override + void initState() { + super.initState(); + + widget.selection.addListener(_onSelectionChange); + } + + @override + void didUpdateWidget(SelectionLeadersDocumentLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + } + } + + @override + void dispose() { + widget.selection.removeListener(_onSelectionChange); + + super.dispose(); + } + + void _onSelectionChange() { + if (mounted && SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) { + // The Flutter pipeline isn't running. Schedule a re-build and re-position the caret. + setState(() { + // The leaders are positioned in the build() call. + }); + } + } + + /// Updates the caret rect, immediately, without scheduling a rebuild. + @override + DocumentSelectionLayout? computeLayoutDataWithDocumentLayout( + BuildContext contentLayersContext, BuildContext documentContext, DocumentLayout documentLayout) { + final documentSelection = widget.selection.value; + if (documentSelection == null) { + return null; + } + + final selectedComponent = documentLayout.getComponentByNodeId(widget.selection.value!.extent.nodeId); + if (selectedComponent == null) { + // Assume that we're in a momentary transitive state where the document layout + // just gained or lost a component. We expect this method ot run again in a moment + // to correct for this. + return null; + } + + if (documentSelection.isCollapsed) { + return DocumentSelectionLayout( + caret: documentLayout.getRectForPosition(documentSelection.extent)!, + ); + } else { + return DocumentSelectionLayout( + upstream: documentLayout.getRectForPosition( + widget.document.selectUpstreamPosition(documentSelection.base, documentSelection.extent), + )!, + downstream: documentLayout.getRectForPosition( + widget.document.selectDownstreamPosition(documentSelection.base, documentSelection.extent), + )!, + expandedSelectionBounds: documentLayout.getRectForSelection( + documentSelection.base, + documentSelection.extent, + ), + ); + } + } + + @override + Widget doBuild(BuildContext context, DocumentSelectionLayout? selectionLayout) { + if (selectionLayout == null) { + return const SizedBox(); + } + + return IgnorePointer( + child: Stack( + children: [ + if (selectionLayout.caret != null) + Positioned( + top: selectionLayout.caret!.top, + left: selectionLayout.caret!.left, + width: 1, + height: selectionLayout.caret!.height, + child: Leader( + link: widget.links.caretLink, + child: widget.showDebugLeaderBounds + ? DecoratedBox( + decoration: BoxDecoration(border: Border.all(width: 4, color: const Color(0xFFFF0000))), + ) + : null, + ), + ), + if (selectionLayout.upstream != null) + Positioned( + top: selectionLayout.upstream!.top, + left: selectionLayout.upstream!.left, + width: 1, + height: selectionLayout.upstream!.height, + child: Leader( + link: widget.links.upstreamLink, + child: widget.showDebugLeaderBounds + ? DecoratedBox( + decoration: BoxDecoration(border: Border.all(width: 4, color: const Color(0xFF00FF00))), + ) + : null, + ), + ), + if (selectionLayout.downstream != null) + Positioned( + top: selectionLayout.downstream!.top, + left: selectionLayout.downstream!.left, + width: 1, + height: selectionLayout.downstream!.height, + child: Leader( + link: widget.links.downstreamLink, + child: widget.showDebugLeaderBounds + ? DecoratedBox( + decoration: BoxDecoration(border: Border.all(width: 4, color: const Color(0xFF0000FF))), + ) + : null, + ), + ), + if (selectionLayout.expandedSelectionBounds != null) + Positioned.fromRect( + rect: selectionLayout.expandedSelectionBounds!, + child: Leader( + link: widget.links.expandedSelectionBoundsLink, + child: widget.showDebugLeaderBounds + ? DecoratedBox( + decoration: BoxDecoration(border: Border.all(width: 4, color: const Color(0xFFFF00FF))), + ) + : null, + ), + ), + ], + ), + ); + } +} + +/// Visual layout bounds related to a user selection in a document, such as the +/// caret rect, a bounding box around all selected content, etc. +class DocumentSelectionLayout { + DocumentSelectionLayout({ + this.caret, + this.upstream, + this.downstream, + this.expandedSelectionBounds, + }); + + final Rect? caret; + final Rect? upstream; + final Rect? downstream; + final Rect? expandedSelectionBounds; +} + +/// A collection of [LayerLink]s that should be positioned near important +/// visual selection locations, such as at the caret position. +class SelectionLayerLinks { + SelectionLayerLinks({ + LeaderLink? caretLink, + LeaderLink? upstreamLink, + LeaderLink? downstreamLink, + LeaderLink? expandedSelectionBoundsLink, + }) { + this.caretLink = caretLink ?? LeaderLink(); + this.upstreamLink = upstreamLink ?? LeaderLink(); + this.downstreamLink = downstreamLink ?? LeaderLink(); + this.expandedSelectionBoundsLink = expandedSelectionBoundsLink ?? LeaderLink(); + } + + /// [LayerLink] that's connected to a rectangle at the collapsed selection caret + /// position. + late final LeaderLink caretLink; + + /// [LayerLink] that's connected to a rectangle at the expanded selection upstream + /// position. + late final LeaderLink upstreamLink; + + /// [LayerLink] that's connected to a rectangle at the expanded selection downstream + /// position. + late final LeaderLink downstreamLink; + + /// [LayerLink] that's connected to a rectangle that bounds the entire expanded + /// selection, from the top of upstream to the bottom of downstream. + late final LeaderLink expandedSelectionBoundsLink; +} diff --git a/super_editor/lib/src/infrastructure/flutter/android_toolbar.dart b/super_editor/lib/src/infrastructure/flutter/android_toolbar.dart new file mode 100644 index 0000000000..9727302f71 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/android_toolbar.dart @@ -0,0 +1,704 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +const double _kToolbarHeight = 54.0; + +/// A toolbar containing the given children. If they overflow the width +/// available, then the overflowing children will be displayed in an overflow +/// menu. +/// +/// Extracted from Flutter's Material text selection toolbar implementation +/// from flutter/packages/flutter/lib/src/material/text_selection_toolbar.dart +class AndroidPopoverToolbar extends StatefulWidget { + const AndroidPopoverToolbar({ + required this.isAbove, + required this.toolbarBuilder, + required this.children, + }); + + final List children; + + // When true, the toolbar fits above its anchor and will be positioned there. + final bool isAbove; + + // Builds the toolbar that will be populated with the children and fit inside + // of the layout that adjusts to overflow. + final ToolbarBuilder toolbarBuilder; + + @override + _AndroidPopoverToolbarState createState() => _AndroidPopoverToolbarState(); +} + +class _AndroidPopoverToolbarState extends State with TickerProviderStateMixin { + // Whether or not the overflow menu is open. When it is closed, the menu + // items that don't overflow are shown. When it is open, only the overflowing + // menu items are shown. + bool _overflowOpen = false; + + // The key for _TextSelectionToolbarTrailingEdgeAlign. + UniqueKey _containerKey = UniqueKey(); + + // Close the menu and reset layout calculations, as in when the menu has + // changed and saved values are no longer relevant. This should be called in + // setState or another context where a rebuild is happening. + void _reset() { + // Change _TextSelectionToolbarTrailingEdgeAlign's key when the menu changes + // in order to cause it to rebuild. This lets it recalculate its + // saved width for the new set of children, and it prevents AnimatedSize + // from animating the size change. + _containerKey = UniqueKey(); + // If the menu items change, make sure the overflow menu is closed. This + // prevents getting into a broken state where _overflowOpen is true when + // there are not enough children to cause overflow. + _overflowOpen = false; + } + + @override + void didUpdateWidget(AndroidPopoverToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + // If the children are changing at all, the current page should be reset. + if (!listEquals(widget.children, oldWidget.children)) { + _reset(); + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final TextDirection textDirection = Directionality.of(context); + + return _TextSelectionToolbarTrailingEdgeAlign( + key: _containerKey, + overflowOpen: _overflowOpen, + textDirection: textDirection, + child: AnimatedSize( + // This duration was eyeballed on a Pixel 2 emulator running Android + // API 28. + duration: const Duration(milliseconds: 140), + child: widget.toolbarBuilder( + context, + _TextSelectionToolbarItemsLayout( + isAbove: widget.isAbove, + overflowOpen: _overflowOpen, + textDirection: textDirection, + children: [ + // TODO(justinmc): This overflow button should have its own slot in + // _TextSelectionToolbarItemsLayout separate from children, similar + // to how it's done in Cupertino's text selection menu. + // https://github.com/flutter/flutter/issues/69908 + // The navButton that shows and hides the overflow menu is the + // first child. + _TextSelectionToolbarOverflowButton( + key: _overflowOpen ? StandardComponentType.backButton.key : StandardComponentType.moreButton.key, + icon: Icon(_overflowOpen ? Icons.arrow_back : Icons.more_vert), + onPressed: () { + setState(() { + _overflowOpen = !_overflowOpen; + }); + }, + tooltip: _overflowOpen ? localizations.backButtonTooltip : localizations.moreButtonTooltip, + ), + ...widget.children, + ], + ), + ), + ), + ); + } +} + +// When the overflow menu is open, it tries to align its trailing edge to the +// trailing edge of the closed menu. This widget handles this effect by +// measuring and maintaining the width of the closed menu and aligning the child +// to that side. +class _TextSelectionToolbarTrailingEdgeAlign extends SingleChildRenderObjectWidget { + const _TextSelectionToolbarTrailingEdgeAlign({ + super.key, + required Widget super.child, + required this.overflowOpen, + required this.textDirection, + }); + + final bool overflowOpen; + final TextDirection textDirection; + + @override + _TextSelectionToolbarTrailingEdgeAlignRenderBox createRenderObject(BuildContext context) { + return _TextSelectionToolbarTrailingEdgeAlignRenderBox( + overflowOpen: overflowOpen, + textDirection: textDirection, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _TextSelectionToolbarTrailingEdgeAlignRenderBox renderObject, + ) { + renderObject + ..overflowOpen = overflowOpen + ..textDirection = textDirection; + } +} + +class _TextSelectionToolbarTrailingEdgeAlignRenderBox extends RenderProxyBox { + _TextSelectionToolbarTrailingEdgeAlignRenderBox({ + required bool overflowOpen, + required TextDirection textDirection, + }) : _textDirection = textDirection, + _overflowOpen = overflowOpen, + super(); + + // The width of the menu when it was closed. This is used to achieve the + // behavior where the open menu aligns its trailing edge to the closed menu's + // trailing edge. + double? _closedWidth; + + bool _overflowOpen; + bool get overflowOpen => _overflowOpen; + set overflowOpen(bool value) { + if (value == overflowOpen) { + return; + } + _overflowOpen = value; + markNeedsLayout(); + } + + TextDirection _textDirection; + TextDirection get textDirection => _textDirection; + set textDirection(TextDirection value) { + if (value == textDirection) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + @override + void performLayout() { + child!.layout(constraints.loosen(), parentUsesSize: true); + + // Save the width when the menu is closed. If the menu changes, this width + // is invalid, so it's important that this RenderBox be recreated in that + // case. Currently, this is achieved by providing a new key to + // _TextSelectionToolbarTrailingEdgeAlign. + if (!overflowOpen && _closedWidth == null) { + _closedWidth = child!.size.width; + } + + size = constraints.constrain( + Size( + // If the open menu is wider than the closed menu, just use its own width + // and don't worry about aligning the trailing edges. + // _closedWidth is used even when the menu is closed to allow it to + // animate its size while keeping the same edge alignment. + _closedWidth == null || child!.size.width > _closedWidth! ? child!.size.width : _closedWidth!, + child!.size.height, + ), + ); + + // Set the offset in the parent data such that the child will be aligned to + // the trailing edge, depending on the text direction. + final ToolbarItemsParentData childParentData = child!.parentData! as ToolbarItemsParentData; + childParentData.offset = Offset( + textDirection == TextDirection.rtl ? 0.0 : size.width - child!.size.width, + 0.0, + ); + } + + // Paint at the offset set in the parent data. + @override + void paint(PaintingContext context, Offset offset) { + final ToolbarItemsParentData childParentData = child!.parentData! as ToolbarItemsParentData; + context.paintChild(child!, childParentData.offset + offset); + } + + // Include the parent data offset in the hit test. + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + // The x, y parameters have the top left of the node's box as the origin. + final ToolbarItemsParentData childParentData = child!.parentData! as ToolbarItemsParentData; + return result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child!.hitTest(result, position: transformed); + }, + ); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! ToolbarItemsParentData) { + child.parentData = ToolbarItemsParentData(); + } + } + + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) { + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + transform.translateByDouble(childParentData.offset.dx, childParentData.offset.dy, 0, 1); + super.applyPaintTransform(child, transform); + } +} + +// Renders the menu items in the correct positions in the menu and its overflow +// submenu based on calculating which item would first overflow. +class _TextSelectionToolbarItemsLayout extends MultiChildRenderObjectWidget { + const _TextSelectionToolbarItemsLayout({ + required this.isAbove, + required this.overflowOpen, + required this.textDirection, + required super.children, + }); + + final bool isAbove; + final bool overflowOpen; + final TextDirection textDirection; + + @override + _RenderTextSelectionToolbarItemsLayout createRenderObject(BuildContext context) { + return _RenderTextSelectionToolbarItemsLayout( + isAbove: isAbove, + overflowOpen: overflowOpen, + textDirection: textDirection, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderTextSelectionToolbarItemsLayout renderObject, + ) { + renderObject + ..isAbove = isAbove + ..textDirection = textDirection + ..overflowOpen = overflowOpen; + } + + @override + _TextSelectionToolbarItemsLayoutElement createElement() => _TextSelectionToolbarItemsLayoutElement(this); +} + +class _TextSelectionToolbarItemsLayoutElement extends MultiChildRenderObjectElement { + _TextSelectionToolbarItemsLayoutElement(super.widget); + + static bool _shouldPaint(Element child) { + return (child.renderObject!.parentData! as ToolbarItemsParentData).shouldPaint; + } + + @override + void debugVisitOnstageChildren(ElementVisitor visitor) { + children.where(_shouldPaint).forEach(visitor); + } +} + +class _RenderTextSelectionToolbarItemsLayout extends RenderBox + with ContainerRenderObjectMixin { + _RenderTextSelectionToolbarItemsLayout({ + required bool isAbove, + required bool overflowOpen, + required TextDirection textDirection, + }) : _isAbove = isAbove, + _overflowOpen = overflowOpen, + _textDirection = textDirection, + super(); + + // The index of the last item that doesn't overflow. + int _lastIndexThatFits = -1; + + bool _isAbove; + bool get isAbove => _isAbove; + set isAbove(bool value) { + if (value == isAbove) { + return; + } + _isAbove = value; + markNeedsLayout(); + } + + bool _overflowOpen; + bool get overflowOpen => _overflowOpen; + set overflowOpen(bool value) { + if (value == overflowOpen) { + return; + } + _overflowOpen = value; + markNeedsLayout(); + } + + TextDirection _textDirection; + TextDirection get textDirection => _textDirection; + set textDirection(TextDirection value) { + if (value == textDirection) { + return; + } + _textDirection = value; + markNeedsLayout(); + } + + // Layout the necessary children, and figure out where the children first + // overflow, if at all. + void _layoutChildren() { + // When overflow is not open, the toolbar is always a specific height. + final BoxConstraints sizedConstraints = + _overflowOpen ? constraints : BoxConstraints.loose(Size(constraints.maxWidth, _kToolbarHeight)); + + int i = -1; + double width = 0.0; + visitChildren((RenderObject renderObjectChild) { + i++; + + // No need to layout children inside the overflow menu when it's closed. + // The opposite is not true. It is necessary to layout the children that + // don't overflow when the overflow menu is open in order to calculate + // _lastIndexThatFits. + if (_lastIndexThatFits != -1 && !overflowOpen) { + return; + } + + final RenderBox child = renderObjectChild as RenderBox; + child.layout(sizedConstraints.loosen(), parentUsesSize: true); + width += child.size.width; + + if (width > sizedConstraints.maxWidth && _lastIndexThatFits == -1) { + _lastIndexThatFits = i - 1; + } + }); + + // If the last child overflows, but only because of the width of the + // overflow button, then just show it and hide the overflow button. + final RenderBox navButton = firstChild!; + if (_lastIndexThatFits != -1 && + _lastIndexThatFits == childCount - 2 && + width - navButton.size.width <= sizedConstraints.maxWidth) { + _lastIndexThatFits = -1; + } + } + + // Returns true when the child should be painted, false otherwise. + bool _shouldPaintChild(RenderObject renderObjectChild, int index) { + // Paint the navButton when there is overflow. + if (renderObjectChild == firstChild) { + return _lastIndexThatFits != -1; + } + + // If there is no overflow, all children besides the navButton are painted. + if (_lastIndexThatFits == -1) { + return true; + } + + // When there is overflow, paint if the child is in the part of the menu + // that is currently open. Overflowing children are painted when the + // overflow menu is open, and the children that fit are painted when the + // overflow menu is closed. + return (index > _lastIndexThatFits) == overflowOpen; + } + + /// Horizontal layout. + Size _placeChildrenHorizontally() { + final RenderBox navButton = firstChild!; + final bool isRtl = textDirection == TextDirection.rtl; + + final List contentItems = []; + + double totalWidth = 0.0; + double maxHeight = 0.0; + + // First pass: calculate dimensions and collect items. + int i = -1; + visitChildren((RenderObject renderObjectChild) { + final RenderBox child = renderObjectChild as RenderBox; + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + i++; + + if (!_shouldPaintChild(child, i)) { + // There is no need to update children that won't be painted. + childParentData.shouldPaint = false; + } else { + childParentData.shouldPaint = true; + + totalWidth += child.size.width; + maxHeight = math.max(maxHeight, child.size.height); + + if (child != navButton) { + contentItems.add(child); + } + } + }); + + // Position items based on text direction. + double currentX = 0.0; + final bool showNavButton = _lastIndexThatFits >= 0; + + if (isRtl) { + // In RTL, we want the nav button on the left and items right-aligned. + if (showNavButton) { + final ToolbarItemsParentData navParentData = navButton.parentData! as ToolbarItemsParentData; + navParentData.offset = Offset.zero; + currentX += navButton.size.width; + } + + // Position content items from right to left. + double rightEdge = totalWidth; + for (final RenderBox item in contentItems) { + rightEdge -= item.size.width; + final ToolbarItemsParentData itemParentData = item.parentData! as ToolbarItemsParentData; + itemParentData.offset = Offset(rightEdge, 0.0); + } + } else { + // LTR: Place content items first, then nav button. + // First position all content items from left to right. + for (final RenderBox item in contentItems) { + final ToolbarItemsParentData itemParentData = item.parentData! as ToolbarItemsParentData; + itemParentData.offset = Offset(currentX, 0.0); + currentX += item.size.width; + } + + // Then place the nav button at the end. + if (showNavButton) { + final ToolbarItemsParentData navParentData = navButton.parentData! as ToolbarItemsParentData; + navParentData.offset = Offset(currentX, 0.0); + } + } + + return Size(totalWidth, maxHeight); + } + + /// Vertical layout (overflow menu). + Size _placeChildrenVertically() { + final RenderBox navButton = firstChild!; + + double currentY = 0.0; + double maxWidth = 0.0; + + final ToolbarItemsParentData navButtonParentData = navButton.parentData! as ToolbarItemsParentData; + + if (_shouldPaintChild(navButton, 0)) { + navButtonParentData.shouldPaint = true; + if (!isAbove) { + navButtonParentData.offset = Offset.zero; + currentY += navButton.size.height; + maxWidth = math.max(maxWidth, navButton.size.width); + } + } else { + navButtonParentData.shouldPaint = false; + } + + int i = -1; + visitChildren((RenderObject renderObjectChild) { + final RenderBox child = renderObjectChild as RenderBox; + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + + i++; + + // Ignore the navigation button. + if (renderObjectChild == navButton) { + return; + } + + // There is no need to update children that won't be painted. + if (!_shouldPaintChild(child, i)) { + childParentData.shouldPaint = false; + return; + } + + childParentData.shouldPaint = true; + childParentData.offset = Offset(0.0, currentY); + currentY += child.size.height; + maxWidth = math.max(maxWidth, child.size.width); + }); + + if (isAbove && navButtonParentData.shouldPaint) { + navButtonParentData.offset = Offset(0.0, currentY); + currentY += navButton.size.height; + maxWidth = math.max(maxWidth, navButton.size.width); + } + + maxWidth += 20; + + return Size(maxWidth, currentY); + } + + // Decide which children will be painted, set their shouldPaint, and set the + // offset that painted children will be placed at. + void _placeChildren() { + size = overflowOpen ? _placeChildrenVertically() : _placeChildrenHorizontally(); + } + + // Horizontally expand the children when the menu overflows so they can react to + // pointer events into their whole area. + void _resizeChildrenWhenOverflow() { + if (!overflowOpen) { + return; + } + + final RenderBox navButton = firstChild!; + int i = -1; + + visitChildren((RenderObject renderObjectChild) { + final RenderBox child = renderObjectChild as RenderBox; + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + + i++; + + // Ignore the navigation button. + if (renderObjectChild == navButton) { + return; + } + + // There is no need to update children that won't be painted. + if (!_shouldPaintChild(renderObjectChild, i)) { + childParentData.shouldPaint = false; + return; + } + + child.layout(BoxConstraints.tightFor(width: size.width), parentUsesSize: true); + }); + } + + @override + void performLayout() { + _lastIndexThatFits = -1; + if (firstChild == null) { + size = constraints.smallest; + return; + } + + _layoutChildren(); + _placeChildren(); + _resizeChildrenWhenOverflow(); + } + + @override + void paint(PaintingContext context, Offset offset) { + visitChildren((RenderObject renderObjectChild) { + final RenderBox child = renderObjectChild as RenderBox; + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + if (!childParentData.shouldPaint) { + return; + } + + context.paintChild(child, childParentData.offset + offset); + }); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! ToolbarItemsParentData) { + child.parentData = ToolbarItemsParentData(); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + RenderBox? child = lastChild; + while (child != null) { + // The x, y parameters have the top left of the node's box as the origin. + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + + // Don't hit test children aren't shown. + if (!childParentData.shouldPaint) { + child = childParentData.previousSibling; + continue; + } + + final bool isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child!.hitTest(result, position: transformed); + }, + ); + if (isHit) { + return true; + } + child = childParentData.previousSibling; + } + return false; + } + + // Visit only the children that should be painted. + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + visitChildren((RenderObject renderObjectChild) { + final RenderBox child = renderObjectChild as RenderBox; + final ToolbarItemsParentData childParentData = child.parentData! as ToolbarItemsParentData; + if (childParentData.shouldPaint) { + visitor(renderObjectChild); + } + }); + } +} + +// The Material-styled toolbar outline. Fill it with any widgets you want. No +// overflow ability. +class AndroidPopoverToolbarContainer extends StatelessWidget { + const AndroidPopoverToolbarContainer({required this.child}); + + final Widget child; + + // These colors were taken from a screenshot of a Pixel 6 emulator running + // Android API level 35. + static const Color _defaultColorLight = Color(0xFFE2E2EA); + static const Color _defaultColorDark = Color(0xFF33343A); + + static Color _getColor(ColorScheme colorScheme) { + return switch (colorScheme.brightness) { + Brightness.light => _defaultColorLight, + Brightness.dark => _defaultColorDark, + }; + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Material( + // This value was eyeballed to match the native text selection menu on + // a Pixel 6 emulator running Android API level 34. + borderRadius: const BorderRadius.all(Radius.circular(_kToolbarHeight / 2)), + clipBehavior: Clip.antiAlias, + color: _getColor(theme.colorScheme), + elevation: 1.0, + type: MaterialType.card, + child: child, + ); + } +} + +// A button styled like a Material native Android text selection overflow menu +// forward and back controls. +class _TextSelectionToolbarOverflowButton extends StatelessWidget { + const _TextSelectionToolbarOverflowButton({ + super.key, + required this.icon, + this.onPressed, + this.tooltip, + }); + + final Icon icon; + final VoidCallback? onPressed; + final String? tooltip; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.card, + color: const Color(0x00000000), + child: IconButton( + // TODO(justinmc): This should be an AnimatedIcon, but + // AnimatedIcons doesn't yet support arrow_back to more_vert. + // https://github.com/flutter/flutter/issues/51209 + icon: icon, + onPressed: onPressed, + tooltip: tooltip, + ), + ); + } +} diff --git a/super_editor/lib/src/infrastructure/flutter/build_context.dart b/super_editor/lib/src/infrastructure/flutter/build_context.dart new file mode 100644 index 0000000000..84eed59523 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/build_context.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +extension ScrollableFinder on BuildContext { + /// Finds the nearest ancestor [Scrollable] with a vertical scroll in the + /// widget tree. + ScrollableState? get findAncestorScrollableWithVerticalScroll { + final ancestorScrollable = Scrollable.maybeOf(this); + if (ancestorScrollable == null) { + return null; + } + + final direction = ancestorScrollable.axisDirection; + // If the direction is horizontal, then we are inside a widget like a TabBar + // or a horizontal ListView, so we can't use the ancestor scrollable + if (direction == AxisDirection.left || direction == AxisDirection.right) { + return null; + } + + return ancestorScrollable; + } + + /// Returns the RenderBox of the nearest ancestor [RenderAbstractViewport]. + RenderBox findViewportBox() { + // findAncestorRenderObjectOfType traverses the element tree, which is + // more dense then render object tree. So instead we traverse the + // render object tree. + var renderObject = findRenderObject(); + while (renderObject != null) { + if (renderObject is RenderAbstractViewport) { + return renderObject as RenderBox; + } + renderObject = renderObject.parent; + } + + throw StateError('No RenderAbstractViewport ancestor found'); + } +} diff --git a/super_editor/lib/src/infrastructure/flutter/cupertino_scrollbar.dart b/super_editor/lib/src/infrastructure/flutter/cupertino_scrollbar.dart new file mode 100644 index 0000000000..f82cde05cf --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/cupertino_scrollbar.dart @@ -0,0 +1,188 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/infrastructure/flutter/scrollbar.dart'; + +// All values eyeballed. +const double _kScrollbarMinLength = 36.0; +const double _kScrollbarMinOverscrollLength = 8.0; +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200); +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); +const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100); + +// Extracted from iOS 13.1 beta using Debug View Hierarchy. +const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness( + color: Color(0x59000000), + darkColor: Color(0x80FFFFFF), +); + +// This is the amount of space from the top of a vertical scrollbar to the +// top edge of the scrollable, measured when the vertical scrollbar overscrolls +// to the top. +// TODO(LongCatIsLooong): fix https://github.com/flutter/flutter/issues/32175 +const double _kScrollbarMainAxisMargin = 3.0; +const double _kScrollbarCrossAxisMargin = 3.0; + +/// A copy of Flutter [CupertinoScrollBar] with a configurable [ScrollPhysics]. +/// +/// Usually, the scrollbar uses the same [ScrollPhysics] from its [ScrollPosition]. +/// Therefore, when using [NeverScrollableScrollPhysics], the user is prevented +/// from interacting with the scrollbar. +/// +/// By using this widget, an app can use [NeverScrollableScrollPhysics], for example, +/// to prevent scrolling by drag, and still let users interact with the scrollbar. +class CupertinoScrollbarWithCustomPhysics extends RawScrollbarWithCustomPhysics { + /// Creates an iOS style scrollbar that wraps the given [child]. + /// + /// The [child] should be a source of [ScrollNotification] notifications, + /// typically a [Scrollable] widget. + const CupertinoScrollbarWithCustomPhysics({ + super.key, + required super.physics, + required super.child, + super.controller, + bool? thumbVisibility, + double super.thickness = defaultThickness, + this.thicknessWhileDragging = defaultThicknessWhileDragging, + Radius super.radius = defaultRadius, + this.radiusWhileDragging = defaultRadiusWhileDragging, + ScrollNotificationPredicate? notificationPredicate, + super.scrollbarOrientation, + }) : assert(thickness < double.infinity), + assert(thicknessWhileDragging < double.infinity), + super( + thumbVisibility: thumbVisibility ?? false, + fadeDuration: _kScrollbarFadeDuration, + timeToFade: _kScrollbarTimeToFade, + pressDuration: const Duration(milliseconds: 100), + notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate, + ); + + /// Default value for [thickness] if it's not specified in [CupertinoScrollbarWithCustomPhysics]. + static const double defaultThickness = 3; + + /// Default value for [thicknessWhileDragging] if it's not specified in + /// [CupertinoScrollbarWithCustomPhysics]. + static const double defaultThicknessWhileDragging = 8.0; + + /// Default value for [radius] if it's not specified in [CupertinoScrollbarWithCustomPhysics]. + static const Radius defaultRadius = Radius.circular(1.5); + + /// Default value for [radiusWhileDragging] if it's not specified in + /// [CupertinoScrollbarWithCustomPhysics]. + static const Radius defaultRadiusWhileDragging = Radius.circular(4.0); + + /// The thickness of the scrollbar when it's being dragged by the user. + /// + /// When the user starts dragging the scrollbar, the thickness will animate + /// from [thickness] to this value, then animate back when the user stops + /// dragging the scrollbar. + final double thicknessWhileDragging; + + /// The radius of the scrollbar edges when the scrollbar is being dragged by + /// the user. + /// + /// When the user starts dragging the scrollbar, the radius will animate + /// from [radius] to this value, then animate back when the user stops + /// dragging the scrollbar. + final Radius radiusWhileDragging; + + @override + RawScrollbarWithCustomPhysicsState createState() => _CupertinoScrollbarState(); +} + +class _CupertinoScrollbarState extends RawScrollbarWithCustomPhysicsState { + late AnimationController _thicknessAnimationController; + + double get _thickness { + return widget.thickness! + + _thicknessAnimationController.value * (widget.thicknessWhileDragging - widget.thickness!); + } + + Radius get _radius { + return Radius.lerp(widget.radius, widget.radiusWhileDragging, _thicknessAnimationController.value)!; + } + + @override + void initState() { + super.initState(); + _thicknessAnimationController = AnimationController( + vsync: this, + duration: _kScrollbarResizeDuration, + ); + _thicknessAnimationController.addListener(() { + updateScrollbarPainter(); + }); + } + + @override + void updateScrollbarPainter() { + scrollbarPainter + ..color = CupertinoDynamicColor.resolve(_kScrollbarColor, context) + ..textDirection = Directionality.of(context) + ..thickness = _thickness + ..mainAxisMargin = _kScrollbarMainAxisMargin + ..crossAxisMargin = _kScrollbarCrossAxisMargin + ..radius = _radius + ..padding = MediaQuery.paddingOf(context) + ..minLength = _kScrollbarMinLength + ..minOverscrollLength = _kScrollbarMinOverscrollLength + ..scrollbarOrientation = widget.scrollbarOrientation; + } + + double _pressStartAxisPosition = 0.0; + + // Long press event callbacks handle the gesture where the user long presses + // on the scrollbar thumb and then drags the scrollbar without releasing. + + @override + void handleThumbPressStart(Offset localPosition) { + super.handleThumbPressStart(localPosition); + final Axis? direction = getScrollbarDirection(); + if (direction == null) { + return; + } + switch (direction) { + case Axis.vertical: + _pressStartAxisPosition = localPosition.dy; + case Axis.horizontal: + _pressStartAxisPosition = localPosition.dx; + } + } + + @override + void handleThumbPress() { + if (getScrollbarDirection() == null) { + return; + } + super.handleThumbPress(); + _thicknessAnimationController.forward().then( + (_) => HapticFeedback.mediumImpact(), + ); + } + + @override + void handleThumbPressEnd(Offset localPosition, Velocity velocity) { + final Axis? direction = getScrollbarDirection(); + if (direction == null) { + return; + } + _thicknessAnimationController.reverse(); + super.handleThumbPressEnd(localPosition, velocity); + switch (direction) { + case Axis.vertical: + if (velocity.pixelsPerSecond.dy.abs() < 10 && (localPosition.dy - _pressStartAxisPosition).abs() > 0) { + HapticFeedback.mediumImpact(); + } + case Axis.horizontal: + if (velocity.pixelsPerSecond.dx.abs() < 10 && (localPosition.dx - _pressStartAxisPosition).abs() > 0) { + HapticFeedback.mediumImpact(); + } + } + } + + @override + void dispose() { + _thicknessAnimationController.dispose(); + super.dispose(); + } +} diff --git a/super_editor/lib/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart b/super_editor/lib/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart new file mode 100644 index 0000000000..4a60984370 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' hide DragGestureRecognizer; + +import 'package:super_editor/src/infrastructure/flutter/monodrag.dart'; + +/// Recognizes movement both horizontally and vertically. +/// +/// Flutter's `PanGestureRecognizer` loses the gesture arena if there +/// is a `VerticalDragGestureRecognizer` in the tree. +/// +/// This recognizer uses the same minimum distance as the `VerticalDragGestureRecognizer` +/// to accept a gesture +class EagerPanGestureRecognizer extends DragGestureRecognizer { + EagerPanGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + super.allowedButtonsFilter, + }); + + /// Allows to dynamically decide if the gesture should be accepted. + bool Function()? shouldAccept; + + @override + bool isFlingGesture(VelocityEstimate estimate, PointerDeviceKind kind) { + final minVelocity = minFlingVelocity ?? kMinFlingVelocity; + final minDistance = minFlingDistance ?? computeHitSlop(kind, gestureSettings); + return estimate.pixelsPerSecond.distanceSquared > minVelocity * minVelocity && + estimate.offset.distanceSquared > minDistance * minDistance; + } + + @override + void acceptGesture(int pointer) { + if (shouldAccept?.call() ?? true) { + super.acceptGesture(pointer); + } + } + + @override + DragEndDetails? considerFling(VelocityEstimate estimate, PointerDeviceKind kind) { + if (!isFlingGesture(estimate, kind)) { + return null; + } + final maxVelocity = maxFlingVelocity ?? kMaxFlingVelocity; + final dy = clampDouble(estimate.pixelsPerSecond.dy, -maxVelocity, maxVelocity); + return DragEndDetails( + velocity: Velocity(pixelsPerSecond: Offset(0, dy)), + primaryVelocity: dy, + globalPosition: finalPosition.global, + localPosition: finalPosition.local, + ); + } + + @override + bool hasSufficientGlobalDistanceToAccept(PointerDeviceKind pointerDeviceKind, double? deviceTouchSlop) { + // Flutter's PanGestureRecognizer uses the pan slop, which is twice bigger than the hit slop, + // to determine if the gesture should be accepted. Use the same distance used by the + // VerticalDragGestureRecognizer. + final res = globalDistanceMoved.abs() > computeHitSlop(pointerDeviceKind, gestureSettings); + if (res && shouldAccept != null) { + return shouldAccept!(); + } else { + return res; + } + } + + @override + Offset getDeltaForDetails(Offset delta) => delta; + + @override + double? getPrimaryValueFromOffset(Offset value) => null; + + @override + String get debugDescription => 'pan'; +} diff --git a/super_editor/lib/src/infrastructure/flutter/empty_box.dart b/super_editor/lib/src/infrastructure/flutter/empty_box.dart new file mode 100644 index 0000000000..789aea7785 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/empty_box.dart @@ -0,0 +1,23 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A widget that takes up all available space, but unlike `SizedBox`, this widget +/// doesn't paint anything in debug mode. +/// +/// Typically, when a Flutter app wants to take up space with a widget without +/// painting anything, a `SizedBox` used. However, when using Flutter debug +/// tools to see layout boundaries, every `SizedBox` paints itself with a gray +/// color. This is especially a problem when a `SizedBox` is displayed in an +/// overlay. To solve that problem, `EmptyBox` takes up space where a +/// `SizedBox` may have been used, but paints nothing in debug mode, allowing +/// users to see the content beneath the overlay. +class EmptyBox extends LeafRenderObjectWidget { + const EmptyBox(); + + @override + RenderEmptyBox createRenderObject(BuildContext context) { + return RenderEmptyBox(); + } +} + +class RenderEmptyBox extends RenderProxyBox {} diff --git a/super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart b/super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart new file mode 100644 index 0000000000..e9cba97949 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/flutter_scheduler.dart @@ -0,0 +1,109 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +extension Scheduler on WidgetsBinding { + /// Runs the given [action] as soon as possible, given the status of Flutter's pipeline. + /// + /// Flutter throws an error if a widget ever calls `setState()` while widget building + /// is already underway. This can happen when an [action] sends signals that might cause + /// a widget to call `setState()`. For example, setting a value on a `ValueNotifier` + /// might trigger a `ListenableBuilder` to rebuild somewhere else in the tree. As a + /// result, if code sets the value on a `ValueNotifier` during Flutter's build phase, + /// Flutter will crash. This extension helps avoid such a crash. + /// + /// When [runAsSoonAsPossible] is called *outside* of a Flutter build phase, [action] + /// is executed immediately. + /// + /// When [runAsSoonAsPossible] is called *during* a Flutter build phase, [action] is + /// executed at the end of the current frame with [addPostFrameCallback]. + void runAsSoonAsPossible(VoidCallback action, {String debugLabel = "anonymous action"}) { + schedulerLog.info("Running action as soon as possible: '$debugLabel'."); + if (schedulerPhase == SchedulerPhase.persistentCallbacks) { + // The Flutter pipeline is in the middle of a build phase. Schedule the desired + // action for the end of the current frame. + schedulerLog.info("Scheduling another frame to run '$debugLabel' because Flutter is building widgets right now."); + addPostFrameCallback((timeStamp) { + schedulerLog.info("Flutter is done building widgets. Running '$debugLabel' at the end of the frame."); + action(); + }); + } else { + // The Flutter pipeline isn't building widgets right now. Execute the action + // immediately. + schedulerLog.info("Flutter isn't building widgets right now. Running '$debugLabel' immediately."); + action(); + } + } +} + +/// Extensions on [State] that provide concise, convenient control over +/// common Flutter pipeline scheduling needs. +extension Frames on State { + /// Runs the given [stateChange] within `setState()` as early as possible. + /// + /// Given that `setState()` is called, it can't be run during Flutter's + /// build phase. If Flutter is currently in the middle of the build + /// phase, another frame is scheduled, and [stateChange] is run after the + /// current build phase completes. Otherwise, [stateChange] is run immediately. + void setStateAsSoonAsPossible(VoidCallback stateChange) { + WidgetsBinding.instance.runAsSoonAsPossible( + () { + if (!mounted) { + return; + } + + // ignore: invalid_use_of_protected_member + setState(() { + stateChange(); + }); + }, + ); + } + + /// Runs the given [work] in a post-frame callback, but only if the [State] + /// is still `mounted`. + void onNextFrame(void Function(Duration timeStamp) work) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (!mounted) { + return; + } + + // Do the work. + work(timeStamp); + }); + } + + /// Adds a post-frame callback, which then calls `setState()` to trigger + /// another build, which is useful when you discover during a build that + /// you need another build immediately. + /// + /// Discovering that you need another build during a build is typically + /// the result of what we call the "extra frame problem". Some piece of + /// information is unavailable until layout has run, which then reveals + /// that you need to adjust other widgets, resulting in the need to schedule + /// another build. Consider things like drag handles, a magnifier, or a + /// toolbar, which follow the user's selection. + /// + /// Developers should be very careful when using this method because it can + /// easily cause infinite rebuilds. It must only be called in conditionals that + /// won't be triggered on every frame. Otherwise, every frame will schedule + /// another frame and the pipeline will never go idle. + /// + /// This method may be called with, or without state changes: + /// + /// scheduleBuildAfterBuild(); + /// + /// schedulerBuildAfterBuild(() { + /// myVar1 = "Hello"; + /// myVar2 = "World"; + /// }); + /// + void scheduleBuildAfterBuild([VoidCallback? stateChange]) { + onNextFrame((_) { + // ignore: invalid_use_of_protected_member + setState(() { + stateChange?.call(); + }); + }); + } +} diff --git a/super_editor/lib/src/infrastructure/flutter/geometry.dart b/super_editor/lib/src/infrastructure/flutter/geometry.dart new file mode 100644 index 0000000000..a69fe6ef28 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/geometry.dart @@ -0,0 +1,39 @@ +import 'dart:ui'; + +extension Edges on Rect { + /// Returns a zero-width `Rect` along the left side of this rectangle. + Rect get leftEdge => Rect.fromLTWH(left, top, 0, height); + + /// Returns a zero-width `Rect` along the right side of this rectangle. + Rect get rightEdge => Rect.fromLTWH(right, top, 0, height); +} + +extension RectangleMutation on Rect { + /// Returns a copy of this `Rect`, translated by the given [offset]. + /// + /// Translation of this `Rect` means that every corner of the existing + /// `Rect` is recomputed as `(corner.dx + offset.dx, corner.dy + offset.dy)`. + Rect translateByOffset(Offset offset) => translate(offset.dx, offset.dy); + + /// Returns a copy of this `Rect` with the left edge moved [amount] to the + /// left. + /// + /// A positive [amount] moves the left edge towards the left, a negative + /// [amount] moves the left edge towards the right. + /// + /// It's the caller's responsibility to ensure that the movement of the left + /// edge doesn't result in a broken `Rect`, i.e., a left edge that's further + /// to the right than the right edge. + Rect inflateLeft(double amount) => Rect.fromLTWH(left - amount, top, width + amount, height); + + /// Returns a copy of this `Rect` with the right edge moved [amount] to the + /// right. + /// + /// A positive [amount] moves the right edge towards the right, a negative + /// [amount] moves the right edge towards the left. + /// + /// It's the caller's responsibility to ensure that the movement of the right + /// edge doesn't result in a broken `Rect`, i.e., a right edge that's further + /// to the left than the left edge. + Rect inflateRight(double amount) => Rect.fromLTWH(left, top, width + amount, height); +} diff --git a/super_editor/lib/src/infrastructure/flutter/material_scrollbar.dart b/super_editor/lib/src/infrastructure/flutter/material_scrollbar.dart new file mode 100644 index 0000000000..1029a03e9b --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/material_scrollbar.dart @@ -0,0 +1,356 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/src/infrastructure/flutter/cupertino_scrollbar.dart'; +import 'package:super_editor/src/infrastructure/flutter/scrollbar.dart'; + +const double _kScrollbarThickness = 8.0; +const double _kScrollbarThicknessWithTrack = 12.0; +const double _kScrollbarMargin = 2.0; +const double _kScrollbarMinLength = 48.0; +const Radius _kScrollbarRadius = Radius.circular(8.0); +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300); +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600); + +/// A copy of Flutter [Scrollbar] with a configurable [ScrollPhysics]. +/// +/// Usually, the scrollbar uses the same [ScrollPhysics] from its [ScrollPosition]. +/// Therefore, when using [NeverScrollableScrollPhysics], the user is prevented +/// from interacting with the scrollbar. +/// +/// By using this widget, an app can use [NeverScrollableScrollPhysics], for example, +/// to prevent scrolling by drag, and still let users interact with the scrollbar. +class ScrollbarWithCustomPhysics extends StatelessWidget { + /// Creates a Material Design scrollbar that by default will connect to the + /// closest Scrollable descendant of [child]. + /// + /// The [child] should be a source of [ScrollNotification] notifications, + /// typically a [Scrollable] widget. + /// + /// If the [controller] is null, the default behavior is to + /// enable scrollbar dragging using the [PrimaryScrollController]. + /// + /// When null, [thickness] defaults to 8.0 pixels on desktop and web, and 4.0 + /// pixels when on mobile platforms. A null [radius] will result in a default + /// of an 8.0 pixel circular radius about the corners of the scrollbar thumb, + /// except for when executing on [TargetPlatform.android], which will render the + /// thumb without a radius. + const ScrollbarWithCustomPhysics({ + super.key, + required this.physics, + required this.child, + this.controller, + this.thumbVisibility, + this.trackVisibility, + this.thickness, + this.radius, + this.notificationPredicate, + this.interactive, + this.scrollbarOrientation, + }); + + /// {@macro flutter.widgets.Scrollbar.child} + final Widget child; + + /// {@macro flutter.widgets.Scrollbar.controller} + final ScrollController? controller; + + /// {@macro flutter.widgets.Scrollbar.thumbVisibility} + /// + /// If this property is null, then [ScrollbarThemeData.thumbVisibility] of + /// [ThemeData.scrollbarTheme] is used. If that is also null, the default value + /// is false. + /// + /// If the thumb visibility is related to the scrollbar's material state, + /// use the global [ScrollbarThemeData.thumbVisibility] or override the + /// sub-tree's theme data. + final bool? thumbVisibility; + + /// {@macro flutter.widgets.Scrollbar.trackVisibility} + /// + /// If this property is null, then [ScrollbarThemeData.trackVisibility] of + /// [ThemeData.scrollbarTheme] is used. If that is also null, the default value + /// is false. + /// + /// If the track visibility is related to the scrollbar's material state, + /// use the global [ScrollbarThemeData.trackVisibility] or override the + /// sub-tree's theme data. + final bool? trackVisibility; + + /// The thickness of the scrollbar in the cross axis of the scrollable. + /// + /// If null, the default value is platform dependent. On [TargetPlatform.android], + /// the default thickness is 4.0 pixels. On [TargetPlatform.iOS], + /// [CupertinoScrollbarWithCustomPhysics.defaultThickness] is used. The remaining platforms have a + /// default thickness of 8.0 pixels. + final double? thickness; + + /// The [Radius] of the scrollbar thumb's rounded rectangle corners. + /// + /// If null, the default value is platform dependent. On [TargetPlatform.android], + /// no radius is applied to the scrollbar thumb. On [TargetPlatform.iOS], + /// [CupertinoScrollbarWithCustomPhysics.defaultRadius] is used. The remaining platforms have a + /// default [Radius.circular] of 8.0 pixels. + final Radius? radius; + + /// {@macro flutter.widgets.Scrollbar.interactive} + final bool? interactive; + + /// {@macro flutter.widgets.Scrollbar.notificationPredicate} + final ScrollNotificationPredicate? notificationPredicate; + + /// {@macro flutter.widgets.Scrollbar.scrollbarOrientation} + final ScrollbarOrientation? scrollbarOrientation; + + final ScrollPhysics physics; + + @override + Widget build(BuildContext context) { + if (Theme.of(context).platform == TargetPlatform.iOS) { + return CupertinoScrollbarWithCustomPhysics( + physics: physics, + thumbVisibility: thumbVisibility ?? false, + thickness: thickness ?? CupertinoScrollbarWithCustomPhysics.defaultThickness, + thicknessWhileDragging: thickness ?? CupertinoScrollbarWithCustomPhysics.defaultThicknessWhileDragging, + radius: radius ?? CupertinoScrollbarWithCustomPhysics.defaultRadius, + radiusWhileDragging: radius ?? CupertinoScrollbarWithCustomPhysics.defaultRadiusWhileDragging, + controller: controller, + notificationPredicate: notificationPredicate, + scrollbarOrientation: scrollbarOrientation, + child: child, + ); + } + return _MaterialScrollbar( + physics: physics, + controller: controller, + thumbVisibility: thumbVisibility, + trackVisibility: trackVisibility, + thickness: thickness, + radius: radius, + notificationPredicate: notificationPredicate, + interactive: interactive, + scrollbarOrientation: scrollbarOrientation, + child: child, + ); + } +} + +class _MaterialScrollbar extends RawScrollbarWithCustomPhysics { + const _MaterialScrollbar({ + required super.physics, + required super.child, + super.controller, + super.thumbVisibility, + super.trackVisibility, + super.thickness, + super.radius, + ScrollNotificationPredicate? notificationPredicate, + super.interactive, + super.scrollbarOrientation, + }) : super( + fadeDuration: _kScrollbarFadeDuration, + timeToFade: _kScrollbarTimeToFade, + pressDuration: Duration.zero, + notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate, + ); + + @override + _MaterialScrollbarState createState() => _MaterialScrollbarState(); +} + +class _MaterialScrollbarState extends RawScrollbarWithCustomPhysicsState<_MaterialScrollbar> { + late AnimationController _hoverAnimationController; + bool _dragIsActive = false; + bool _hoverIsActive = false; + late ColorScheme _colorScheme; + late ScrollbarThemeData _scrollbarTheme; + // On Android, scrollbars should match native appearance. + late bool _useAndroidScrollbar; + + @override + bool get showScrollbar => widget.thumbVisibility ?? _scrollbarTheme.thumbVisibility?.resolve(_states) ?? false; + + @override + bool get enableGestures => widget.interactive ?? _scrollbarTheme.interactive ?? !_useAndroidScrollbar; + + WidgetStateProperty get _trackVisibility => WidgetStateProperty.resolveWith((Set states) { + return widget.trackVisibility ?? _scrollbarTheme.trackVisibility?.resolve(states) ?? false; + }); + + Set get _states => { + if (_dragIsActive) WidgetState.dragged, + if (_hoverIsActive) WidgetState.hovered, + }; + + WidgetStateProperty get _thumbColor { + final Color onSurface = _colorScheme.onSurface; + final Brightness brightness = _colorScheme.brightness; + late Color dragColor; + late Color hoverColor; + late Color idleColor; + switch (brightness) { + case Brightness.light: + dragColor = onSurface.withValues(alpha: 0.6); + hoverColor = onSurface.withValues(alpha: 0.5); + idleColor = _useAndroidScrollbar + ? Theme.of(context).highlightColor.withValues(alpha: 1.0) + : onSurface.withValues(alpha: 0.1); + case Brightness.dark: + dragColor = onSurface.withValues(alpha: 0.75); + hoverColor = onSurface.withValues(alpha: 0.65); + idleColor = _useAndroidScrollbar + ? Theme.of(context).highlightColor.withValues(alpha: 1.0) + : onSurface.withValues(alpha: 0.3); + } + + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.dragged)) { + return _scrollbarTheme.thumbColor?.resolve(states) ?? dragColor; + } + + // If the track is visible, the thumb color hover animation is ignored and + // changes immediately. + if (_trackVisibility.resolve(states)) { + return _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor; + } + + return Color.lerp( + _scrollbarTheme.thumbColor?.resolve(states) ?? idleColor, + _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor, + _hoverAnimationController.value, + )!; + }); + } + + WidgetStateProperty get _trackColor { + final Color onSurface = _colorScheme.onSurface; + final Brightness brightness = _colorScheme.brightness; + return WidgetStateProperty.resolveWith((Set states) { + if (showScrollbar && _trackVisibility.resolve(states)) { + return _scrollbarTheme.trackColor?.resolve(states) ?? + (brightness == Brightness.light ? onSurface.withValues(alpha: 0.03) : onSurface.withValues(alpha: 0.05)); + } + return const Color(0x00000000); + }); + } + + WidgetStateProperty get _trackBorderColor { + final Color onSurface = _colorScheme.onSurface; + final Brightness brightness = _colorScheme.brightness; + return WidgetStateProperty.resolveWith((Set states) { + if (showScrollbar && _trackVisibility.resolve(states)) { + return _scrollbarTheme.trackBorderColor?.resolve(states) ?? + (brightness == Brightness.light ? onSurface.withValues(alpha: 0.1) : onSurface.withValues(alpha: 0.25)); + } + return const Color(0x00000000); + }); + } + + WidgetStateProperty get _thickness { + return WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.hovered) && _trackVisibility.resolve(states)) { + return _scrollbarTheme.thickness?.resolve(states) ?? _kScrollbarThicknessWithTrack; + } + // The default scrollbar thickness is smaller on mobile. + return widget.thickness ?? + _scrollbarTheme.thickness?.resolve(states) ?? + (_kScrollbarThickness / (_useAndroidScrollbar ? 2 : 1)); + }); + } + + @override + void initState() { + super.initState(); + _hoverAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + _hoverAnimationController.addListener(() { + updateScrollbarPainter(); + }); + } + + @override + void didChangeDependencies() { + final ThemeData theme = Theme.of(context); + _colorScheme = theme.colorScheme; + _scrollbarTheme = ScrollbarTheme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + _useAndroidScrollbar = true; + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + case TargetPlatform.windows: + _useAndroidScrollbar = false; + } + super.didChangeDependencies(); + } + + @override + void updateScrollbarPainter() { + scrollbarPainter + ..color = _thumbColor.resolve(_states) + ..trackColor = _trackColor.resolve(_states) + ..trackBorderColor = _trackBorderColor.resolve(_states) + ..textDirection = Directionality.of(context) + ..thickness = _thickness.resolve(_states) + ..radius = widget.radius ?? _scrollbarTheme.radius ?? (_useAndroidScrollbar ? null : _kScrollbarRadius) + ..crossAxisMargin = _scrollbarTheme.crossAxisMargin ?? (_useAndroidScrollbar ? 0.0 : _kScrollbarMargin) + ..mainAxisMargin = _scrollbarTheme.mainAxisMargin ?? 0.0 + ..minLength = _scrollbarTheme.minThumbLength ?? _kScrollbarMinLength + ..padding = MediaQuery.paddingOf(context) + ..scrollbarOrientation = widget.scrollbarOrientation + ..ignorePointer = !enableGestures; + } + + @override + void handleThumbPressStart(Offset localPosition) { + super.handleThumbPressStart(localPosition); + setState(() { + _dragIsActive = true; + }); + } + + @override + void handleThumbPressEnd(Offset localPosition, Velocity velocity) { + super.handleThumbPressEnd(localPosition, velocity); + setState(() { + _dragIsActive = false; + }); + } + + @override + void handleHover(PointerHoverEvent event) { + super.handleHover(event); + // Check if the position of the pointer falls over the painted scrollbar + if (isPointerOverScrollbar(event.position, event.kind, forHover: true)) { + // Pointer is hovering over the scrollbar + setState(() { + _hoverIsActive = true; + }); + _hoverAnimationController.forward(); + } else if (_hoverIsActive) { + // Pointer was, but is no longer over painted scrollbar. + setState(() { + _hoverIsActive = false; + }); + _hoverAnimationController.reverse(); + } + } + + @override + void handleHoverExit(PointerExitEvent event) { + super.handleHoverExit(event); + setState(() { + _hoverIsActive = false; + }); + _hoverAnimationController.reverse(); + } + + @override + void dispose() { + _hoverAnimationController.dispose(); + super.dispose(); + } +} diff --git a/super_editor/lib/src/infrastructure/flutter/monodrag.dart b/super_editor/lib/src/infrastructure/flutter/monodrag.dart new file mode 100644 index 0000000000..6e7bcd0ed5 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/monodrag.dart @@ -0,0 +1,806 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/scheduler.dart'; + +/// A copy of Flutter's `DragGestureRecognizer` with public abstract methods. +/// +/// Flutter's `DragGestureRecognizer` relies on private abstract methods to work. Since the +/// built-in subclasses live on the same package, they can override these methods, but we don't. +/// +/// Original file: https://github.com/flutter/flutter/blob/02aaaa0bf9096fc85c76d316fec1ce2b2890fcc3/packages/flutter/lib/src/gestures/monodrag.dart +/// +/// This class can be removed if https://github.com/flutter/flutter/issues/151446 is resolved. +abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { + /// Initialize the object. + /// + /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} + DragGestureRecognizer({ + super.debugOwner, + this.dragStartBehavior = DragStartBehavior.start, + this.multitouchDragStrategy = MultitouchDragStrategy.latestPointer, + this.velocityTrackerBuilder = _defaultBuilder, + this.onlyAcceptDragOnThreshold = false, + super.supportedDevices, + AllowedButtonsFilter? allowedButtonsFilter, + }) : super(allowedButtonsFilter: allowedButtonsFilter ?? _defaultButtonAcceptBehavior); + + static VelocityTracker _defaultBuilder(PointerEvent event) => VelocityTracker.withKind(event.kind); + + // Accept the input if, and only if, [kPrimaryButton] is pressed. + static bool _defaultButtonAcceptBehavior(int buttons) => buttons == kPrimaryButton; + + /// Configure the behavior of offsets passed to [onStart]. + /// + /// If set to [DragStartBehavior.start], the [onStart] callback will be called + /// with the position of the pointer at the time this gesture recognizer won + /// the arena. If [DragStartBehavior.down], [onStart] will be called with + /// the position of the first detected down event for the pointer. When there + /// are no other gestures competing with this gesture in the arena, there's + /// no difference in behavior between the two settings. + /// + /// For more information about the gesture arena: + /// https://flutter.dev/docs/development/ui/advanced/gestures#gesture-disambiguation + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// ## Example: + /// + /// A [HorizontalDragGestureRecognizer] and a [VerticalDragGestureRecognizer] + /// compete with each other. A finger presses down on the screen with + /// offset (500.0, 500.0), and then moves to position (510.0, 500.0) before + /// the [HorizontalDragGestureRecognizer] wins the arena. With + /// [dragStartBehavior] set to [DragStartBehavior.down], the [onStart] + /// callback will be called with position (500.0, 500.0). If it is + /// instead set to [DragStartBehavior.start], [onStart] will be called with + /// position (510.0, 500.0). + DragStartBehavior dragStartBehavior; + + /// {@template flutter.gestures.monodrag.DragGestureRecognizer.multitouchDragStrategy} + /// Configure the multi-finger drag strategy on multi-touch devices. + /// + /// If set to [MultitouchDragStrategy.latestPointer], the drag gesture recognizer + /// will only track the latest active (accepted by this recognizer) pointer, which + /// appears to be only one finger dragging. + /// + /// If set to [MultitouchDragStrategy.averageBoundaryPointers], all active + /// pointers will be tracked, and the result is computed from the boundary pointers. + /// + /// If set to [MultitouchDragStrategy.sumAllPointers], + /// all active pointers will be tracked together and the scrolling offset + /// is the sum of the offsets of all active pointers + /// {@endtemplate} + /// + /// By default, the strategy is [MultitouchDragStrategy.latestPointer]. + /// + /// See also: + /// + /// * [MultitouchDragStrategy], which defines several different drag strategies for + /// multi-finger drag. + MultitouchDragStrategy multitouchDragStrategy; + + /// A pointer has contacted the screen with a primary button and might begin + /// to move. + /// + /// The position of the pointer is provided in the callback's `details` + /// argument, which is a [DragDownDetails] object. + /// + /// See also: + /// + /// * [allowedButtonsFilter], which decides which button will be allowed. + /// * [DragDownDetails], which is passed as an argument to this callback. + GestureDragDownCallback? onDown; + + /// {@template flutter.gestures.monodrag.DragGestureRecognizer.onStart} + /// A pointer has contacted the screen with a primary button and has begun to + /// move. + /// {@endtemplate} + /// + /// The position of the pointer is provided in the callback's `details` + /// argument, which is a [DragStartDetails] object. The [dragStartBehavior] + /// determines this position. + /// + /// See also: + /// + /// * [allowedButtonsFilter], which decides which button will be allowed. + /// * [DragStartDetails], which is passed as an argument to this callback. + GestureDragStartCallback? onStart; + + /// {@template flutter.gestures.monodrag.DragGestureRecognizer.onUpdate} + /// A pointer that is in contact with the screen with a primary button and + /// moving has moved again. + /// {@endtemplate} + /// + /// The distance traveled by the pointer since the last update is provided in + /// the callback's `details` argument, which is a [DragUpdateDetails] object. + /// + /// If this gesture recognizer recognizes movement on a single axis (a + /// [VerticalDragGestureRecognizer] or [HorizontalDragGestureRecognizer]), + /// then `details` will reflect movement only on that axis and its + /// [DragUpdateDetails.primaryDelta] will be non-null. + /// If this gesture recognizer recognizes movement in all directions + /// (a [PanGestureRecognizer]), then `details` will reflect movement on + /// both axes and its [DragUpdateDetails.primaryDelta] will be null. + /// + /// See also: + /// + /// * [allowedButtonsFilter], which decides which button will be allowed. + /// * [DragUpdateDetails], which is passed as an argument to this callback. + GestureDragUpdateCallback? onUpdate; + + /// {@template flutter.gestures.monodrag.DragGestureRecognizer.onEnd} + /// A pointer that was previously in contact with the screen with a primary + /// button and moving is no longer in contact with the screen and was moving + /// at a specific velocity when it stopped contacting the screen. + /// {@endtemplate} + /// + /// The velocity is provided in the callback's `details` argument, which is a + /// [DragEndDetails] object. + /// + /// If this gesture recognizer recognizes movement on a single axis (a + /// [VerticalDragGestureRecognizer] or [HorizontalDragGestureRecognizer]), + /// then `details` will reflect movement only on that axis and its + /// [DragEndDetails.primaryVelocity] will be non-null. + /// If this gesture recognizer recognizes movement in all directions + /// (a [PanGestureRecognizer]), then `details` will reflect movement on + /// both axes and its [DragEndDetails.primaryVelocity] will be null. + /// + /// See also: + /// + /// * [allowedButtonsFilter], which decides which button will be allowed. + /// * [DragEndDetails], which is passed as an argument to this callback. + GestureDragEndCallback? onEnd; + + /// The pointer that previously triggered [onDown] did not complete. + /// + /// See also: + /// + /// * [allowedButtonsFilter], which decides which button will be allowed. + GestureDragCancelCallback? onCancel; + + /// The minimum distance an input pointer drag must have moved + /// to be considered a fling gesture. + /// + /// This value is typically compared with the distance traveled along the + /// scrolling axis. If null then [kTouchSlop] is used. + double? minFlingDistance; + + /// The minimum velocity for an input pointer drag to be considered fling. + /// + /// This value is typically compared with the magnitude of fling gesture's + /// velocity along the scrolling axis. If null then [kMinFlingVelocity] + /// is used. + double? minFlingVelocity; + + /// Fling velocity magnitudes will be clamped to this value. + /// + /// If null then [kMaxFlingVelocity] is used. + double? maxFlingVelocity; + + /// Whether the drag threshold should be met before dispatching any drag callbacks. + /// + /// The drag threshold is met when the global distance traveled by a pointer has + /// exceeded the defined threshold on the relevant axis, i.e. y-axis for the + /// [VerticalDragGestureRecognizer], x-axis for the [HorizontalDragGestureRecognizer], + /// and the entire plane for [PanGestureRecognizer]. The threshold for both + /// [VerticalDragGestureRecognizer] and [HorizontalDragGestureRecognizer] are + /// calculated by [computeHitSlop], while [computePanSlop] is used for + /// [PanGestureRecognizer]. + /// + /// If true, the drag callbacks will only be dispatched when this recognizer has + /// won the arena and the drag threshold has been met. + /// + /// If false, the drag callbacks will be dispatched immediately when this recognizer + /// has won the arena. + /// + /// This value defaults to false. + bool onlyAcceptDragOnThreshold; + + /// Determines the type of velocity estimation method to use for a potential + /// drag gesture, when a new pointer is added. + /// + /// To estimate the velocity of a gesture, [DragGestureRecognizer] calls + /// [velocityTrackerBuilder] when it starts to track a new pointer in + /// [addAllowedPointer], and add subsequent updates on the pointer to the + /// resulting velocity tracker, until the gesture recognizer stops tracking + /// the pointer. This allows you to specify a different velocity estimation + /// strategy for each allowed pointer added, by changing the type of velocity + /// tracker this [GestureVelocityTrackerBuilder] returns. + /// + /// If left unspecified the default [velocityTrackerBuilder] creates a new + /// [VelocityTracker] for every pointer added. + /// + /// See also: + /// + /// * [VelocityTracker], a velocity tracker that uses least squares estimation + /// on the 20 most recent pointer data samples. It's a well-rounded velocity + /// tracker and is used by default. + /// * [IOSScrollViewFlingVelocityTracker], a specialized velocity tracker for + /// determining the initial fling velocity for a [Scrollable] on iOS, to + /// match the native behavior on that platform. + GestureVelocityTrackerBuilder velocityTrackerBuilder; + + _DragState _state = _DragState.ready; + late OffsetPair _initialPosition; + late OffsetPair _pendingDragOffset; + OffsetPair get finalPosition => _finalPosition; + late OffsetPair _finalPosition; + Duration? _lastPendingEventTimestamp; + + /// When asserts are enabled, returns the last tracked pending event timestamp + /// for this recognizer. + /// + /// Otherwise, returns null. + /// + /// This getter is intended for use in framework unit tests. Applications must + /// not depend on its value. + @visibleForTesting + Duration? get debugLastPendingEventTimestamp { + Duration? lastPendingEventTimestamp; + assert(() { + lastPendingEventTimestamp = _lastPendingEventTimestamp; + return true; + }()); + return lastPendingEventTimestamp; + } + + // The buttons sent by `PointerDownEvent`. If a `PointerMoveEvent` comes with a + // different set of buttons, the gesture is canceled. + int? _initialButtons; + Matrix4? _lastTransform; + + /// Distance moved in the global coordinate space of the screen in drag direction. + /// + /// If drag is only allowed along a defined axis, this value may be negative to + /// differentiate the direction of the drag. + double get globalDistanceMoved => _globalDistanceMoved; + late double _globalDistanceMoved; + + /// Determines if a gesture is a fling or not based on velocity. + /// + /// A fling calls its gesture end callback with a velocity, allowing the + /// provider of the callback to respond by carrying the gesture forward with + /// inertia, for example. + bool isFlingGesture(VelocityEstimate estimate, PointerDeviceKind kind); + + /// Determines if a gesture is a fling or not, and if so its effective velocity. + /// + /// A fling calls its gesture end callback with a velocity, allowing the + /// provider of the callback to respond by carrying the gesture forward with + /// inertia, for example. + DragEndDetails? considerFling(VelocityEstimate estimate, PointerDeviceKind kind); + + Offset getDeltaForDetails(Offset delta); + double? getPrimaryValueFromOffset(Offset value); + + /// The axis (horizontal or vertical) corresponding to the primary drag direction. + /// + /// The [PanGestureRecognizer] returns null. + _DragDirection? _getPrimaryDragAxis() => null; + bool hasSufficientGlobalDistanceToAccept(PointerDeviceKind pointerDeviceKind, double? deviceTouchSlop); + bool _hasDragThresholdBeenMet = false; + + final Map _velocityTrackers = {}; + + // The move delta of each pointer before the next frame. + // + // The key is the pointer ID. It is cleared whenever a new batch of pointer events is detected. + final Map _moveDeltaBeforeFrame = {}; + + // The timestamp of all events of the current frame. + // + // On a event with a different timestamp, the event is considered a new batch. + Duration? _frameTimeStamp; + Offset _lastUpdatedDeltaForPan = Offset.zero; + + @override + bool isPointerAllowed(PointerEvent event) { + if (_initialButtons == null) { + if (onDown == null && onStart == null && onUpdate == null && onEnd == null && onCancel == null) { + return false; + } + } else { + // There can be multiple drags simultaneously. Their effects are combined. + if (event.buttons != _initialButtons) { + return false; + } + } + return super.isPointerAllowed(event as PointerDownEvent); + } + + void _addPointer(PointerEvent event) { + _velocityTrackers[event.pointer] = velocityTrackerBuilder(event); + switch (_state) { + case _DragState.ready: + _state = _DragState.possible; + _initialPosition = OffsetPair(global: event.position, local: event.localPosition); + _finalPosition = _initialPosition; + _pendingDragOffset = OffsetPair.zero; + _globalDistanceMoved = 0.0; + _lastPendingEventTimestamp = event.timeStamp; + _lastTransform = event.transform; + _checkDown(); + case _DragState.possible: + break; + case _DragState.accepted: + resolve(GestureDisposition.accepted); + } + } + + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + if (_state == _DragState.ready) { + _initialButtons = event.buttons; + } + _addPointer(event); + } + + @override + void addAllowedPointerPanZoom(PointerPanZoomStartEvent event) { + super.addAllowedPointerPanZoom(event); + startTrackingPointer(event.pointer, event.transform); + if (_state == _DragState.ready) { + _initialButtons = kPrimaryButton; + } + _addPointer(event); + } + + bool _shouldTrackMoveEvent(int pointer) { + final bool result; + switch (multitouchDragStrategy) { + case MultitouchDragStrategy.sumAllPointers: + case MultitouchDragStrategy.averageBoundaryPointers: + result = true; + case MultitouchDragStrategy.latestPointer: + result = _activePointer == null || pointer == _activePointer; + } + return result; + } + + void _recordMoveDeltaForMultitouch(int pointer, Offset localDelta) { + if (multitouchDragStrategy != MultitouchDragStrategy.averageBoundaryPointers) { + assert(_frameTimeStamp == null); + assert(_moveDeltaBeforeFrame.isEmpty); + return; + } + + assert(_frameTimeStamp == SchedulerBinding.instance.currentSystemFrameTimeStamp); + + if (_state != _DragState.accepted || localDelta == Offset.zero) { + return; + } + + if (_moveDeltaBeforeFrame.containsKey(pointer)) { + final Offset offset = _moveDeltaBeforeFrame[pointer]!; + _moveDeltaBeforeFrame[pointer] = offset + localDelta; + } else { + _moveDeltaBeforeFrame[pointer] = localDelta; + } + } + + double _getSumDelta({ + required int pointer, + required bool positive, + required _DragDirection axis, + }) { + double sum = 0.0; + + if (!_moveDeltaBeforeFrame.containsKey(pointer)) { + return sum; + } + + final Offset offset = _moveDeltaBeforeFrame[pointer]!; + if (positive) { + if (axis == _DragDirection.vertical) { + sum = max(offset.dy, 0.0); + } else { + sum = max(offset.dx, 0.0); + } + } else { + if (axis == _DragDirection.vertical) { + sum = min(offset.dy, 0.0); + } else { + sum = min(offset.dx, 0.0); + } + } + + return sum; + } + + int? _getMaxSumDeltaPointer({ + required bool positive, + required _DragDirection axis, + }) { + if (_moveDeltaBeforeFrame.isEmpty) { + return null; + } + + int? ret; + double? max; + double sum; + for (final int pointer in _moveDeltaBeforeFrame.keys) { + sum = _getSumDelta(pointer: pointer, positive: positive, axis: axis); + if (ret == null) { + ret = pointer; + max = sum; + } else { + if (positive) { + if (sum > max!) { + ret = pointer; + max = sum; + } + } else { + if (sum < max!) { + ret = pointer; + max = sum; + } + } + } + } + assert(ret != null); + return ret; + } + + Offset _resolveLocalDeltaForMultitouch(int pointer, Offset localDelta) { + if (multitouchDragStrategy != MultitouchDragStrategy.averageBoundaryPointers) { + if (_frameTimeStamp != null) { + _moveDeltaBeforeFrame.clear(); + _frameTimeStamp = null; + _lastUpdatedDeltaForPan = Offset.zero; + } + return localDelta; + } + + final Duration currentSystemFrameTimeStamp = SchedulerBinding.instance.currentSystemFrameTimeStamp; + if (_frameTimeStamp != currentSystemFrameTimeStamp) { + _moveDeltaBeforeFrame.clear(); + _lastUpdatedDeltaForPan = Offset.zero; + _frameTimeStamp = currentSystemFrameTimeStamp; + } + + assert(_frameTimeStamp == SchedulerBinding.instance.currentSystemFrameTimeStamp); + + final _DragDirection? axis = _getPrimaryDragAxis(); + + if (_state != _DragState.accepted || localDelta == Offset.zero || (_moveDeltaBeforeFrame.isEmpty && axis != null)) { + return localDelta; + } + + final double dx, dy; + if (axis == _DragDirection.horizontal) { + dx = _resolveDelta(pointer: pointer, axis: _DragDirection.horizontal, localDelta: localDelta); + assert(dx.abs() <= localDelta.dx.abs()); + dy = 0.0; + } else if (axis == _DragDirection.vertical) { + dx = 0.0; + dy = _resolveDelta(pointer: pointer, axis: _DragDirection.vertical, localDelta: localDelta); + assert(dy.abs() <= localDelta.dy.abs()); + } else { + final double averageX = _resolveDeltaForPanGesture(axis: _DragDirection.horizontal, localDelta: localDelta); + final double averageY = _resolveDeltaForPanGesture(axis: _DragDirection.vertical, localDelta: localDelta); + final Offset updatedDelta = Offset(averageX, averageY) - _lastUpdatedDeltaForPan; + _lastUpdatedDeltaForPan = Offset(averageX, averageY); + dx = updatedDelta.dx; + dy = updatedDelta.dy; + } + + return Offset(dx, dy); + } + + double _resolveDelta({ + required int pointer, + required _DragDirection axis, + required Offset localDelta, + }) { + final bool positive = axis == _DragDirection.horizontal ? localDelta.dx > 0 : localDelta.dy > 0; + final double delta = axis == _DragDirection.horizontal ? localDelta.dx : localDelta.dy; + final int? maxSumDeltaPointer = _getMaxSumDeltaPointer(positive: positive, axis: axis); + assert(maxSumDeltaPointer != null); + + if (maxSumDeltaPointer == pointer) { + return delta; + } else { + final double maxSumDelta = _getSumDelta(pointer: maxSumDeltaPointer!, positive: positive, axis: axis); + final double curPointerSumDelta = _getSumDelta(pointer: pointer, positive: positive, axis: axis); + if (positive) { + if (curPointerSumDelta + delta > maxSumDelta) { + return curPointerSumDelta + delta - maxSumDelta; + } else { + return 0.0; + } + } else { + if (curPointerSumDelta + delta < maxSumDelta) { + return curPointerSumDelta + delta - maxSumDelta; + } else { + return 0.0; + } + } + } + } + + double _resolveDeltaForPanGesture({ + required _DragDirection axis, + required Offset localDelta, + }) { + final double delta = axis == _DragDirection.horizontal ? localDelta.dx : localDelta.dy; + final int pointerCount = _acceptedActivePointers.length; + assert(pointerCount >= 1); + + double sum = delta; + for (final Offset offset in _moveDeltaBeforeFrame.values) { + if (axis == _DragDirection.horizontal) { + sum += offset.dx; + } else { + sum += offset.dy; + } + } + return sum / pointerCount; + } + + @override + void handleEvent(PointerEvent event) { + assert(_state != _DragState.ready); + if (!event.synthesized && + (event is PointerDownEvent || + event is PointerMoveEvent || + event is PointerPanZoomStartEvent || + event is PointerPanZoomUpdateEvent)) { + final Offset position = switch (event) { + PointerPanZoomStartEvent() => Offset.zero, + PointerPanZoomUpdateEvent() => event.pan, + _ => event.localPosition, + }; + _velocityTrackers[event.pointer]!.addPosition(event.timeStamp, position); + } + if (event is PointerMoveEvent && event.buttons != _initialButtons) { + _giveUpPointer(event.pointer); + return; + } + if ((event is PointerMoveEvent || event is PointerPanZoomUpdateEvent) && _shouldTrackMoveEvent(event.pointer)) { + final Offset delta = (event is PointerMoveEvent) ? event.delta : (event as PointerPanZoomUpdateEvent).panDelta; + final Offset localDelta = + (event is PointerMoveEvent) ? event.localDelta : (event as PointerPanZoomUpdateEvent).localPanDelta; + final Offset position = + (event is PointerMoveEvent) ? event.position : (event.position + (event as PointerPanZoomUpdateEvent).pan); + final Offset localPosition = (event is PointerMoveEvent) + ? event.localPosition + : (event.localPosition + (event as PointerPanZoomUpdateEvent).localPan); + _finalPosition = OffsetPair(local: localPosition, global: position); + final Offset resolvedDelta = _resolveLocalDeltaForMultitouch(event.pointer, localDelta); + switch (_state) { + case _DragState.ready || _DragState.possible: + _pendingDragOffset += OffsetPair(local: localDelta, global: delta); + _lastPendingEventTimestamp = event.timeStamp; + _lastTransform = event.transform; + final Offset movedLocally = getDeltaForDetails(localDelta); + final Matrix4? localToGlobalTransform = event.transform == null ? null : Matrix4.tryInvert(event.transform!); + _globalDistanceMoved += PointerEvent.transformDeltaViaPositions( + transform: localToGlobalTransform, + untransformedDelta: movedLocally, + untransformedEndPosition: localPosition) + .distance * + (getPrimaryValueFromOffset(movedLocally) ?? 1).sign; + if (hasSufficientGlobalDistanceToAccept(event.kind, gestureSettings?.touchSlop)) { + _hasDragThresholdBeenMet = true; + if (_acceptedActivePointers.contains(event.pointer)) { + _checkDrag(event.pointer); + } else { + resolve(GestureDisposition.accepted); + } + } + case _DragState.accepted: + _checkUpdate( + sourceTimeStamp: event.timeStamp, + delta: getDeltaForDetails(resolvedDelta), + primaryDelta: getPrimaryValueFromOffset(resolvedDelta), + globalPosition: position, + localPosition: localPosition, + ); + } + _recordMoveDeltaForMultitouch(event.pointer, localDelta); + } + if (event case PointerUpEvent() || PointerCancelEvent() || PointerPanZoomEndEvent()) { + _giveUpPointer(event.pointer); + } + } + + final List _acceptedActivePointers = []; + // This value is used when the multitouch strategy is `latestPointer`, + // it keeps track of the last accepted pointer. If this active pointer + // leave up, it will be set to the first accepted pointer. + // Refer to the implementation of Android `RecyclerView`(line 3846): + // https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-main/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/RecyclerView.java + int? _activePointer; + + @override + void acceptGesture(int pointer) { + assert(!_acceptedActivePointers.contains(pointer)); + _acceptedActivePointers.add(pointer); + _activePointer = pointer; + if (!onlyAcceptDragOnThreshold || _hasDragThresholdBeenMet) { + _checkDrag(pointer); + } + } + + @override + void rejectGesture(int pointer) { + _giveUpPointer(pointer); + } + + @override + void didStopTrackingLastPointer(int pointer) { + assert(_state != _DragState.ready); + switch (_state) { + case _DragState.ready: + break; + + case _DragState.possible: + resolve(GestureDisposition.rejected); + _checkCancel(); + + case _DragState.accepted: + _checkEnd(pointer); + } + _hasDragThresholdBeenMet = false; + _velocityTrackers.clear(); + _initialButtons = null; + _state = _DragState.ready; + } + + void _giveUpPointer(int pointer) { + stopTrackingPointer(pointer); + // If we never accepted the pointer, we reject it since we are no longer + // interested in winning the gesture arena for it. + if (!_acceptedActivePointers.remove(pointer)) { + resolvePointer(pointer, GestureDisposition.rejected); + } + + _moveDeltaBeforeFrame.remove(pointer); + if (_activePointer == pointer) { + _activePointer = _acceptedActivePointers.isNotEmpty ? _acceptedActivePointers.first : null; + } + } + + void _checkDown() { + if (onDown != null) { + final DragDownDetails details = DragDownDetails( + globalPosition: _initialPosition.global, + localPosition: _initialPosition.local, + ); + invokeCallback('onDown', () => onDown!(details)); + } + } + + void _checkDrag(int pointer) { + if (_state == _DragState.accepted) { + return; + } + _state = _DragState.accepted; + final OffsetPair delta = _pendingDragOffset; + final Duration? timestamp = _lastPendingEventTimestamp; + final Matrix4? transform = _lastTransform; + final Offset localUpdateDelta; + switch (dragStartBehavior) { + case DragStartBehavior.start: + _initialPosition = _initialPosition + delta; + localUpdateDelta = Offset.zero; + case DragStartBehavior.down: + localUpdateDelta = getDeltaForDetails(delta.local); + } + _pendingDragOffset = OffsetPair.zero; + _lastPendingEventTimestamp = null; + _lastTransform = null; + _checkStart(timestamp, pointer); + if (localUpdateDelta != Offset.zero && onUpdate != null) { + final Matrix4? localToGlobal = transform != null ? Matrix4.tryInvert(transform) : null; + final Offset correctedLocalPosition = _initialPosition.local + localUpdateDelta; + final Offset globalUpdateDelta = PointerEvent.transformDeltaViaPositions( + untransformedEndPosition: correctedLocalPosition, + untransformedDelta: localUpdateDelta, + transform: localToGlobal, + ); + final OffsetPair updateDelta = OffsetPair(local: localUpdateDelta, global: globalUpdateDelta); + final OffsetPair correctedPosition = _initialPosition + updateDelta; // Only adds delta for down behaviour + _checkUpdate( + sourceTimeStamp: timestamp, + delta: localUpdateDelta, + primaryDelta: getPrimaryValueFromOffset(localUpdateDelta), + globalPosition: correctedPosition.global, + localPosition: correctedPosition.local, + ); + } + // This acceptGesture might have been called only for one pointer, instead + // of all pointers. Resolve all pointers to `accepted`. This won't cause + // infinite recursion because an accepted pointer won't be accepted again. + resolve(GestureDisposition.accepted); + } + + void _checkStart(Duration? timestamp, int pointer) { + if (onStart != null) { + final DragStartDetails details = DragStartDetails( + sourceTimeStamp: timestamp, + globalPosition: _initialPosition.global, + localPosition: _initialPosition.local, + kind: getKindForPointer(pointer), + ); + invokeCallback('onStart', () => onStart!(details)); + } + } + + void _checkUpdate({ + Duration? sourceTimeStamp, + required Offset delta, + double? primaryDelta, + required Offset globalPosition, + Offset? localPosition, + }) { + if (onUpdate != null) { + final DragUpdateDetails details = DragUpdateDetails( + sourceTimeStamp: sourceTimeStamp, + delta: delta, + primaryDelta: primaryDelta, + globalPosition: globalPosition, + localPosition: localPosition, + ); + invokeCallback('onUpdate', () => onUpdate!(details)); + } + } + + void _checkEnd(int pointer) { + if (onEnd == null) { + return; + } + + final VelocityTracker tracker = _velocityTrackers[pointer]!; + final VelocityEstimate? estimate = tracker.getVelocityEstimate(); + + DragEndDetails? details; + final String Function() debugReport; + if (estimate == null) { + debugReport = () => 'Could not estimate velocity.'; + } else { + details = considerFling(estimate, tracker.kind); + debugReport = (details != null) + ? () => '$estimate; fling at ${details!.velocity}.' + : () => '$estimate; judged to not be a fling.'; + } + details ??= DragEndDetails( + primaryVelocity: 0.0, + globalPosition: _finalPosition.global, + localPosition: _finalPosition.local, + ); + + invokeCallback('onEnd', () => onEnd!(details!), debugReport: debugReport); + } + + void _checkCancel() { + if (onCancel != null) { + invokeCallback('onCancel', onCancel!); + } + } + + @override + void dispose() { + _velocityTrackers.clear(); + super.dispose(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty('start behavior', dragStartBehavior)); + } +} + +enum _DragState { + ready, + possible, + accepted, +} + +enum _DragDirection { + horizontal, + vertical, +} diff --git a/super_editor/lib/src/infrastructure/flutter/overlay_with_groups.dart b/super_editor/lib/src/infrastructure/flutter/overlay_with_groups.dart new file mode 100644 index 0000000000..3db27e6893 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/overlay_with_groups.dart @@ -0,0 +1,107 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; + +/// An [OverlayPortalController], which re-orders itself with all other [GroupedOverlayPortalController]s +/// such that each controller's [displayPriority] is honored by their z-indices. +/// +/// For example, regardless of when they're shown, if there three [GroupedOverlayPortalController]s +/// with priorities of `10`, `100`, and `1`, those overlays will be displayed in the order of +/// `1`, `10`, `100`. In other words, the overlay with priority of `100` appears in front of +/// the one with `10`, which appears in front of the one with `1`. Z-index re-ordering occurs +/// every time a [GroupedOverlayPortalController] is [show]n. +/// +/// Priority is based on an [OverlayGroupPriority]. There are some priority levels that are already +/// defined for common use-cases, so that those use-cases remain consistent across apps. +class GroupedOverlayPortalController extends OverlayPortalController { + static final _visibleControllers = + PriorityQueue((a, b) => a.displayPriority.compareTo(b.displayPriority)); + + static bool _isReworkingOrder = false; + + static void _show(GroupedOverlayPortalController controller) { + if (_isReworkingOrder) { + return; + } + + if (controller.isShowing) { + return; + } + + if (!_visibleControllers.contains(controller)) { + _visibleControllers.add(controller); + } + + _isReworkingOrder = true; + + // When calling `show()` on an `OverlayPortalController` that's already visible, its + // overlay becomes the top overlay in the stack. Therefore, by calling `show()` on all + // of our controllers, from low priority to high priority, we ensure the desired painting order. + for (final visiblePortal in _visibleControllers.toList()) { + visiblePortal.show(); + } + + _isReworkingOrder = false; + } + + static void _hide(GroupedOverlayPortalController controller) { + if (_isReworkingOrder) { + return; + } + + _isReworkingOrder = true; + + _visibleControllers.remove(controller); + controller.hide(); + + _isReworkingOrder = false; + } + + GroupedOverlayPortalController({ + required this.displayPriority, + super.debugLabel, + }); + + /// Relative display priority which determines the z-index of this [GroupedOverlayPortalController] + /// relative to other [GroupedOverlayPortalController]s in the app [Overlay]. + final OverlayGroupPriority displayPriority; + + @override + void show() { + if (!_isReworkingOrder) { + _show(this); + return; + } + + super.show(); + } + + @override + void hide() { + if (!_isReworkingOrder) { + _hide(this); + return; + } + + super.hide(); + } +} + +class OverlayGroupPriority implements Comparable { + /// Standard group priority for editing controls, e.g., drag handles, toolbars, + /// magnifiers. + static const editingControls = OverlayGroupPriority(10000); + + /// Standard group priority for window chrome, e.g., a toolbar mounted above the + /// software keyboard. + static const windowChrome = OverlayGroupPriority(1000000); + + const OverlayGroupPriority(this.priority); + + /// Relative priority for display z-index - higher priority means higher on the + /// z-index stack, e.g., a priority of `1000` will appear in front of a priority + /// of `10`, which will appear in front of a priority of `1`. + final int priority; + + @override + int compareTo(OverlayGroupPriority other) => priority.compareTo(other.priority); +} diff --git a/super_editor/lib/src/infrastructure/flutter/render_box.dart b/super_editor/lib/src/infrastructure/flutter/render_box.dart new file mode 100644 index 0000000000..d90e7a2cef --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/render_box.dart @@ -0,0 +1,13 @@ +import 'package:flutter/rendering.dart'; + +/// Extensions for relationships between boxes that are axis-aligned, e.g., boxes +/// that might have different offsets and scales but aren't rotated. +extension AxisAlignedBoxes on RenderBox { + /// Returns this [RenderBox]'s bounds in the global coordinate space. + Rect get globalRect => Rect.fromPoints( + localToGlobal(Offset.zero), + localToGlobal( + Offset(size.width, size.height), + ), + ); +} diff --git a/super_editor/lib/src/infrastructure/flutter/scrollbar.dart b/super_editor/lib/src/infrastructure/flutter/scrollbar.dart new file mode 100644 index 0000000000..55e58ce237 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/scrollbar.dart @@ -0,0 +1,1973 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +const double _kMinThumbExtent = 18.0; +const double _kMinInteractiveSize = 48.0; +const double _kScrollbarThickness = 6.0; +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300); +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600); + +/// A copy of Flutter [RawScrollbar] with a configurable [ScrollPhysics]. +/// +/// Usually, the scrollbar uses the same [ScrollPhysics] from its [ScrollPosition]. +/// Therefore, when using [NeverScrollableScrollPhysics], the user is prevented +/// from interacting with the scrollbar. +/// +/// By using this widget, an app can use [NeverScrollableScrollPhysics], for example, +/// to prevent scrolling by drag, and still let users interact with the scrollbar. +class RawScrollbarWithCustomPhysics extends StatefulWidget { + /// Creates a basic raw scrollbar that wraps the given [child]. + /// + /// The [child], or a descendant of the [child], should be a source of + /// [ScrollNotification] notifications, typically a [Scrollable] widget. + const RawScrollbarWithCustomPhysics({ + super.key, + required this.physics, + required this.child, + this.controller, + this.thumbVisibility, + this.shape, + this.radius, + this.thickness, + this.thumbColor, + this.minThumbLength = _kMinThumbExtent, + this.minOverscrollLength, + this.trackVisibility, + this.trackRadius, + this.trackColor, + this.trackBorderColor, + this.fadeDuration = _kScrollbarFadeDuration, + this.timeToFade = _kScrollbarTimeToFade, + this.pressDuration = Duration.zero, + this.notificationPredicate = defaultScrollNotificationPredicate, + this.interactive, + this.scrollbarOrientation, + this.mainAxisMargin = 0.0, + this.crossAxisMargin = 0.0, + this.padding, + }) : assert( + !(thumbVisibility == false && (trackVisibility ?? false)), + 'A scrollbar track cannot be drawn without a scrollbar thumb.', + ), + assert(minThumbLength >= 0), + assert(minOverscrollLength == null || minOverscrollLength <= minThumbLength), + assert(minOverscrollLength == null || minOverscrollLength >= 0), + assert(radius == null || shape == null); + + /// {@template flutter.widgets.Scrollbar.child} + /// The widget below this widget in the tree. + /// + /// The scrollbar will be stacked on top of this child. This child (and its + /// subtree) should include a source of [ScrollNotification] notifications. + /// Typically a [Scrollbar] is created on desktop platforms by a + /// [ScrollBehavior.buildScrollbar] method, in which case the child is usually + /// the one provided as an argument to that method. + /// + /// Typically a [ListView] or [CustomScrollView]. + /// {@endtemplate} + final Widget child; + + /// {@template flutter.widgets.Scrollbar.controller} + /// The [ScrollController] used to implement Scrollbar dragging. + /// + /// If nothing is passed to controller, the default behavior is to automatically + /// enable scrollbar dragging on the nearest ScrollController using + /// [PrimaryScrollController.of]. + /// + /// If a ScrollController is passed, then dragging on the scrollbar thumb will + /// update the [ScrollPosition] attached to the controller. A stateful ancestor + /// of this widget needs to manage the ScrollController and either pass it to + /// a scrollable descendant or use a PrimaryScrollController to share it. + /// + /// {@tool snippet} + /// Here is an example of using the [controller] attribute to enable + /// scrollbar dragging for multiple independent ListViews: + /// + /// ```dart + /// // (e.g. in a stateful widget) + /// + /// final ScrollController controllerOne = ScrollController(); + /// final ScrollController controllerTwo = ScrollController(); + /// + /// @override + /// Widget build(BuildContext context) { + /// return Column( + /// children: [ + /// SizedBox( + /// height: 200, + /// child: CupertinoScrollbar( + /// controller: controllerOne, + /// child: ListView.builder( + /// controller: controllerOne, + /// itemCount: 120, + /// itemBuilder: (BuildContext context, int index) => Text('item $index'), + /// ), + /// ), + /// ), + /// SizedBox( + /// height: 200, + /// child: CupertinoScrollbar( + /// controller: controllerTwo, + /// child: ListView.builder( + /// controller: controllerTwo, + /// itemCount: 120, + /// itemBuilder: (BuildContext context, int index) => Text('list 2 item $index'), + /// ), + /// ), + /// ), + /// ], + /// ); + /// } + /// ``` + /// {@end-tool} + /// {@endtemplate} + final ScrollController? controller; + + final ScrollPhysics physics; + + /// {@template flutter.widgets.Scrollbar.thumbVisibility} + /// Indicates that the scrollbar thumb should be visible, even when a scroll + /// is not underway. + /// + /// When false, the scrollbar will be shown during scrolling + /// and will fade out otherwise. + /// + /// When true, the scrollbar will always be visible and never fade out. This + /// requires that the Scrollbar can access the [ScrollController] of the + /// associated Scrollable widget. This can either be the provided [controller], + /// or the [PrimaryScrollController] of the current context. + /// + /// * When providing a controller, the same ScrollController must also be + /// provided to the associated Scrollable widget. + /// * The [PrimaryScrollController] is used by default for a [ScrollView] + /// that has not been provided a [ScrollController] and that has a + /// [ScrollView.scrollDirection] of [Axis.vertical]. This automatic + /// behavior does not apply to those with [Axis.horizontal]. To explicitly + /// use the PrimaryScrollController, set [ScrollView.primary] to true. + /// + /// Defaults to false when null. + /// + /// {@tool snippet} + /// + /// ```dart + /// // (e.g. in a stateful widget) + /// + /// final ScrollController controllerOne = ScrollController(); + /// final ScrollController controllerTwo = ScrollController(); + /// + /// @override + /// Widget build(BuildContext context) { + /// return Column( + /// children: [ + /// SizedBox( + /// height: 200, + /// child: Scrollbar( + /// thumbVisibility: true, + /// controller: controllerOne, + /// child: ListView.builder( + /// controller: controllerOne, + /// itemCount: 120, + /// itemBuilder: (BuildContext context, int index) { + /// return Text('item $index'); + /// }, + /// ), + /// ), + /// ), + /// SizedBox( + /// height: 200, + /// child: CupertinoScrollbar( + /// thumbVisibility: true, + /// controller: controllerTwo, + /// child: SingleChildScrollView( + /// controller: controllerTwo, + /// child: const SizedBox( + /// height: 2000, + /// width: 500, + /// child: Placeholder(), + /// ), + /// ), + /// ), + /// ), + /// ], + /// ); + /// } + /// ``` + /// {@end-tool} + /// + /// See also: + /// + /// * [RawScrollbarWithCustomPhysicsState.showScrollbar], an overridable getter which uses + /// this value to override the default behavior. + /// * [ScrollView.primary], which indicates whether the ScrollView is the primary + /// scroll view associated with the parent [PrimaryScrollController]. + /// * [PrimaryScrollController], which associates a [ScrollController] with + /// a subtree. + /// {@endtemplate} + /// + /// Subclass [Scrollbar] can hide and show the scrollbar thumb in response to + /// [MaterialState]s by using [ScrollbarThemeData.thumbVisibility]. + final bool? thumbVisibility; + + /// The [OutlinedBorder] of the scrollbar's thumb. + /// + /// Only one of [radius] and [shape] may be specified. For a rounded rectangle, + /// it's simplest to just specify [radius]. By default, the scrollbar thumb's + /// shape is a simple rectangle. + /// + /// If [shape] is specified, the thumb will take the shape of the passed + /// [OutlinedBorder] and fill itself with [thumbColor] (or grey if it + /// is unspecified). + /// + /// {@tool dartpad} + /// This is an example of using a [StadiumBorder] for drawing the [shape] of the + /// thumb in a [RawScrollbarWithCustomPhysics]. + /// + /// ** See code in examples/api/lib/widgets/scrollbar/raw_scrollbar.shape.0.dart ** + /// {@end-tool} + final OutlinedBorder? shape; + + /// The [Radius] of the scrollbar thumb's rounded rectangle corners. + /// + /// Scrollbar will be rectangular if [radius] is null, which is the default + /// behavior. + final Radius? radius; + + /// The thickness of the scrollbar in the cross axis of the scrollable. + /// + /// If null, will default to 6.0 pixels. + final double? thickness; + + /// The color of the scrollbar thumb. + /// + /// If null, defaults to Color(0x66BCBCBC). + final Color? thumbColor; + + /// The preferred smallest size the scrollbar thumb can shrink to when the total + /// scrollable extent is large, the current visible viewport is small, and the + /// viewport is not overscrolled. + /// + /// The size of the scrollbar's thumb may shrink to a smaller size than [minThumbLength] + /// to fit in the available paint area (e.g., when [minThumbLength] is greater + /// than [ScrollMetrics.viewportDimension] and [mainAxisMargin] combined). + /// + /// Mustn't be null and the value has to be greater or equal to + /// [minOverscrollLength], which in turn is >= 0. Defaults to 18.0. + final double minThumbLength; + + /// The preferred smallest size the scrollbar thumb can shrink to when viewport is + /// overscrolled. + /// + /// When overscrolling, the size of the scrollbar's thumb may shrink to a smaller size + /// than [minOverscrollLength] to fit in the available paint area (e.g., when + /// [minOverscrollLength] is greater than [ScrollMetrics.viewportDimension] and + /// [mainAxisMargin] combined). + /// + /// Overscrolling can be made possible by setting the `physics` property + /// of the `child` Widget to a `BouncingScrollPhysics`, which is a special + /// `ScrollPhysics` that allows overscrolling. + /// + /// The value is less than or equal to [minThumbLength] and greater than or equal to 0. + /// When null, it will default to the value of [minThumbLength]. + final double? minOverscrollLength; + + /// {@template flutter.widgets.Scrollbar.trackVisibility} + /// Indicates that the scrollbar track should be visible. + /// + /// When true, the scrollbar track will always be visible so long as the thumb + /// is visible. If the scrollbar thumb is not visible, the track will not be + /// visible either. + /// + /// Defaults to false when null. + /// {@endtemplate} + /// + /// Subclass [Scrollbar] can hide and show the scrollbar thumb in response to + /// [MaterialState]s by using [ScrollbarThemeData.trackVisibility]. + final bool? trackVisibility; + + /// The [Radius] of the scrollbar track's rounded rectangle corners. + /// + /// Scrollbar's track will be rectangular if [trackRadius] is null, which is + /// the default behavior. + final Radius? trackRadius; + + /// The color of the scrollbar track. + /// + /// The scrollbar track will only be visible when [trackVisibility] and + /// [thumbVisibility] are true. + /// + /// If null, defaults to Color(0x08000000). + final Color? trackColor; + + /// The color of the scrollbar track's border. + /// + /// The scrollbar track will only be visible when [trackVisibility] and + /// [thumbVisibility] are true. + /// + /// If null, defaults to Color(0x1a000000). + final Color? trackBorderColor; + + /// The [Duration] of the fade animation. + /// + /// Defaults to a [Duration] of 300 milliseconds. + final Duration fadeDuration; + + /// The [Duration] of time until the fade animation begins. + /// + /// Defaults to a [Duration] of 600 milliseconds. + final Duration timeToFade; + + /// The [Duration] of time that a LongPress will trigger the drag gesture of + /// the scrollbar thumb. + /// + /// Defaults to [Duration.zero]. + final Duration pressDuration; + + /// {@template flutter.widgets.Scrollbar.notificationPredicate} + /// A check that specifies whether a [ScrollNotification] should be + /// handled by this widget. + /// + /// By default, checks whether `notification.depth == 0`. That means if the + /// scrollbar is wrapped around multiple [ScrollView]s, it only responds to the + /// nearest scrollView and shows the corresponding scrollbar thumb. + /// {@endtemplate} + final ScrollNotificationPredicate notificationPredicate; + + /// {@template flutter.widgets.Scrollbar.interactive} + /// Whether the Scrollbar should be interactive and respond to dragging on the + /// thumb, or tapping in the track area. + /// + /// Does not apply to the [CupertinoScrollbar], which is always interactive to + /// match native behavior. On Android, the scrollbar is not interactive by + /// default. + /// + /// When false, the scrollbar will not respond to gesture or hover events, + /// and will allow to click through it. + /// + /// Defaults to true when null, unless on Android, which will default to false + /// when null. + /// + /// See also: + /// + /// * [RawScrollbarWithCustomPhysicsState.enableGestures], an overridable getter which uses + /// this value to override the default behavior. + /// {@endtemplate} + final bool? interactive; + + /// {@macro flutter.widgets.Scrollbar.scrollbarOrientation} + final ScrollbarOrientation? scrollbarOrientation; + + /// Distance from the scrollbar thumb's start or end to the nearest edge of + /// the viewport in logical pixels. It affects the amount of available + /// paint area. + /// + /// The scrollbar track consumes this space. + /// + /// Mustn't be null and defaults to 0. + final double mainAxisMargin; + + /// Distance from the scrollbar thumb's side to the nearest cross axis edge + /// in logical pixels. + /// + /// The scrollbar track consumes this space. + /// + /// Defaults to zero. + final double crossAxisMargin; + + /// The insets by which the scrollbar thumb and track should be padded. + /// + /// When null, the inherited [MediaQueryData.padding] is used. + /// + /// Defaults to null. + final EdgeInsets? padding; + + @override + RawScrollbarWithCustomPhysicsState createState() => + RawScrollbarWithCustomPhysicsState(); +} + +/// The state for a [RawScrollbarWithCustomPhysics] widget, also shared by the [Scrollbar] and +/// [CupertinoScrollbar] widgets. +/// +/// Controls the animation that fades a scrollbar's thumb in and out of view. +/// +/// Provides defaults gestures for dragging the scrollbar thumb and tapping on the +/// scrollbar track. +class RawScrollbarWithCustomPhysicsState extends State + with TickerProviderStateMixin { + Offset? _startDragScrollbarAxisOffset; + Offset? _lastDragUpdateOffset; + double? _startDragThumbOffset; + ScrollController? _cachedController; + Timer? _fadeoutTimer; + late AnimationController _fadeoutAnimationController; + late Animation _fadeoutOpacityAnimation; + final GlobalKey _scrollbarPainterKey = GlobalKey(); + bool _hoverIsActive = false; + bool _thumbDragging = false; + bool _isHoveringThumb = false; + + ScrollController? get _effectiveScrollController => widget.controller ?? PrimaryScrollController.maybeOf(context); + + /// Used to paint the scrollbar. + /// + /// Can be customized by subclasses to change scrollbar behavior by overriding + /// [updateScrollbarPainter]. + @protected + late final ScrollbarPainter scrollbarPainter; + + /// Overridable getter to indicate that the scrollbar should be visible, even + /// when a scroll is not underway. + /// + /// Subclasses can override this getter to make its value depend on an inherited + /// theme. + /// + /// Defaults to false when [RawScrollbarWithCustomPhysics.thumbVisibility] is null. + @protected + bool get showScrollbar => widget.thumbVisibility ?? false; + + bool get _showTrack => showScrollbar && (widget.trackVisibility ?? false); + + /// Overridable getter to indicate is gestures should be enabled on the + /// scrollbar. + /// + /// When false, the scrollbar will not respond to gesture or hover events, + /// and will allow to click through it. + /// + /// Subclasses can override this getter to make its value depend on an inherited + /// theme. + /// + /// Defaults to true when [RawScrollbarWithCustomPhysics.interactive] is null. + /// + /// See also: + /// + /// * [RawScrollbarWithCustomPhysics.interactive], which overrides the default behavior. + @protected + bool get enableGestures => widget.interactive ?? true; + + @override + void initState() { + super.initState(); + _fadeoutAnimationController = AnimationController( + vsync: this, + duration: widget.fadeDuration, + )..addStatusListener(_validateInteractions); + _fadeoutOpacityAnimation = CurvedAnimation( + parent: _fadeoutAnimationController, + curve: Curves.fastOutSlowIn, + ); + scrollbarPainter = ScrollbarPainter( + color: widget.thumbColor ?? const Color(0x66BCBCBC), + fadeoutOpacityAnimation: _fadeoutOpacityAnimation, + thickness: widget.thickness ?? _kScrollbarThickness, + radius: widget.radius, + trackRadius: widget.trackRadius, + scrollbarOrientation: widget.scrollbarOrientation, + mainAxisMargin: widget.mainAxisMargin, + shape: widget.shape, + crossAxisMargin: widget.crossAxisMargin, + minLength: widget.minThumbLength, + minOverscrollLength: widget.minOverscrollLength ?? widget.minThumbLength, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + assert(_debugScheduleCheckHasValidScrollPosition()); + } + + bool _debugScheduleCheckHasValidScrollPosition() { + if (!showScrollbar) { + return true; + } + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + assert(_debugCheckHasValidScrollPosition()); + }); + return true; + } + + void _validateInteractions(AnimationStatus status) { + if (status == AnimationStatus.dismissed) { + assert(_fadeoutOpacityAnimation.value == 0.0); + // We do not check for a valid scroll position if the scrollbar is not + // visible, because it cannot be interacted with. + } else if (_effectiveScrollController != null && enableGestures) { + // Interactive scrollbars need to be properly configured. If it is visible + // for interaction, ensure we are set up properly. + assert(_debugCheckHasValidScrollPosition()); + } + } + + bool _debugCheckHasValidScrollPosition() { + if (!mounted) { + return true; + } + final ScrollController? scrollController = _effectiveScrollController; + final bool tryPrimary = widget.controller == null; + final String controllerForError = tryPrimary ? 'PrimaryScrollController' : 'provided ScrollController'; + + String when = ''; + if (widget.thumbVisibility ?? false) { + when = 'Scrollbar.thumbVisibility is true'; + } else if (enableGestures) { + when = 'the scrollbar is interactive'; + } else { + when = 'using the Scrollbar'; + } + + assert( + scrollController != null, + 'A ScrollController is required when $when. ' + '${tryPrimary ? 'The Scrollbar was not provided a ScrollController, ' + 'and attempted to use the PrimaryScrollController, but none was found.' : ''}', + ); + assert(() { + if (!scrollController!.hasClients) { + throw FlutterError.fromParts([ + ErrorSummary( + "The Scrollbar's ScrollController has no ScrollPosition attached.", + ), + ErrorDescription( + 'A Scrollbar cannot be painted without a ScrollPosition. ', + ), + ErrorHint( + 'The Scrollbar attempted to use the $controllerForError. This ' + 'ScrollController should be associated with the ScrollView that ' + 'the Scrollbar is being applied to.' + '${tryPrimary ? 'When ScrollView.scrollDirection is Axis.vertical on mobile ' + 'platforms will automatically use the ' + 'PrimaryScrollController if the user has not provided a ' + 'ScrollController. To use the PrimaryScrollController ' + 'explicitly, set ScrollView.primary to true for the Scrollable ' + 'widget.' : 'When providing your own ScrollController, ensure both the ' + 'Scrollbar and the Scrollable widget use the same one.'}', + ), + ]); + } + return true; + }()); + assert(() { + try { + scrollController!.position; + } catch (error) { + if (scrollController == null || scrollController.positions.length <= 1) { + rethrow; + } + throw FlutterError.fromParts([ + ErrorSummary( + 'The $controllerForError is currently attached to more than one ' + 'ScrollPosition.', + ), + ErrorDescription( + 'The Scrollbar requires a single ScrollPosition in order to be painted.', + ), + ErrorHint( + 'When $when, the associated ScrollController must only have one ' + 'ScrollPosition attached.' + '${tryPrimary ? 'If a ScrollController has not been provided, the ' + 'PrimaryScrollController is used by default on mobile platforms ' + 'for ScrollViews with an Axis.vertical scroll direction. More ' + 'than one ScrollView may have tried to use the ' + 'PrimaryScrollController of the current context. ' + 'ScrollView.primary can override this behavior.' : 'The provided ScrollController must be unique to one ' + 'ScrollView widget.'}', + ), + ]); + } + return true; + }()); + return true; + } + + /// This method is responsible for configuring the [scrollbarPainter] + /// according to the [widget]'s properties and any inherited widgets the + /// painter depends on, like [Directionality] and [MediaQuery]. + /// + /// Subclasses can override to configure the [scrollbarPainter]. + @protected + void updateScrollbarPainter() { + scrollbarPainter + ..color = widget.thumbColor ?? const Color(0x66BCBCBC) + ..trackRadius = widget.trackRadius + ..trackColor = _showTrack ? widget.trackColor ?? const Color(0x08000000) : const Color(0x00000000) + ..trackBorderColor = _showTrack ? widget.trackBorderColor ?? const Color(0x1a000000) : const Color(0x00000000) + ..textDirection = Directionality.of(context) + ..thickness = widget.thickness ?? _kScrollbarThickness + ..radius = widget.radius + ..padding = widget.padding ?? MediaQuery.paddingOf(context) + ..scrollbarOrientation = widget.scrollbarOrientation + ..mainAxisMargin = widget.mainAxisMargin + ..shape = widget.shape + ..crossAxisMargin = widget.crossAxisMargin + ..minLength = widget.minThumbLength + ..minOverscrollLength = widget.minOverscrollLength ?? widget.minThumbLength + ..ignorePointer = !enableGestures; + } + + @override + void didUpdateWidget(T oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.thumbVisibility != oldWidget.thumbVisibility) { + if (widget.thumbVisibility ?? false) { + assert(_debugScheduleCheckHasValidScrollPosition()); + _fadeoutTimer?.cancel(); + _fadeoutAnimationController.animateTo(1.0); + } else { + _fadeoutAnimationController.reverse(); + } + } + } + + void _updateScrollPosition(Offset updatedOffset) { + assert(_cachedController != null); + assert(_startDragScrollbarAxisOffset != null); + assert(_lastDragUpdateOffset != null); + assert(_startDragThumbOffset != null); + + final ScrollPosition position = _cachedController!.position; + late double primaryDeltaFromDragStart; + late double primaryDeltaFromLastDragUpdate; + switch (position.axisDirection) { + case AxisDirection.up: + primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dy - updatedOffset.dy; + primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dy - updatedOffset.dy; + case AxisDirection.right: + primaryDeltaFromDragStart = updatedOffset.dx - _startDragScrollbarAxisOffset!.dx; + primaryDeltaFromLastDragUpdate = updatedOffset.dx - _lastDragUpdateOffset!.dx; + case AxisDirection.down: + primaryDeltaFromDragStart = updatedOffset.dy - _startDragScrollbarAxisOffset!.dy; + primaryDeltaFromLastDragUpdate = updatedOffset.dy - _lastDragUpdateOffset!.dy; + case AxisDirection.left: + primaryDeltaFromDragStart = _startDragScrollbarAxisOffset!.dx - updatedOffset.dx; + primaryDeltaFromLastDragUpdate = _lastDragUpdateOffset!.dx - updatedOffset.dx; + } + + // Convert primaryDelta, the amount that the scrollbar moved since the last + // time when drag started or last updated, into the coordinate space of the scroll + // position, and jump to that position. + double scrollOffsetGlobal = scrollbarPainter.getTrackToScroll(primaryDeltaFromDragStart + _startDragThumbOffset!); + if (primaryDeltaFromDragStart > 0 && scrollOffsetGlobal < position.pixels || + primaryDeltaFromDragStart < 0 && scrollOffsetGlobal > position.pixels) { + // Adjust the position value if the scrolling direction conflicts with + // the dragging direction due to scroll metrics shrink. + scrollOffsetGlobal = position.pixels + scrollbarPainter.getTrackToScroll(primaryDeltaFromLastDragUpdate); + } + if (scrollOffsetGlobal != position.pixels) { + // Ensure we don't drag into overscroll if the physics do not allow it. + final double physicsAdjustment = position.physics.applyBoundaryConditions(position, scrollOffsetGlobal); + double newPosition = scrollOffsetGlobal - physicsAdjustment; + + // The physics may allow overscroll when actually *scrolling*, but + // dragging on the scrollbar does not always allow us to enter overscroll. + switch (ScrollConfiguration.of(context).getPlatform(context)) { + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + newPosition = clampDouble(newPosition, position.minScrollExtent, position.maxScrollExtent); + case TargetPlatform.iOS: + case TargetPlatform.android: + // We can only drag the scrollbar into overscroll on mobile + // platforms, and only then if the physics allow it. + break; + } + position.jumpTo(newPosition); + } + } + + void _maybeStartFadeoutTimer() { + if (!showScrollbar) { + _fadeoutTimer?.cancel(); + _fadeoutTimer = Timer(widget.timeToFade, () { + _fadeoutAnimationController.reverse(); + _fadeoutTimer = null; + }); + } + } + + /// Returns the [Axis] of the child scroll view, or null if the + /// current scroll controller does not have any attached positions. + @protected + Axis? getScrollbarDirection() { + assert(_cachedController != null); + if (_cachedController!.hasClients) { + return _cachedController!.position.axis; + } + return null; + } + + /// Handler called when a press on the scrollbar thumb has been recognized. + /// + /// Cancels the [Timer] associated with the fade animation of the scrollbar. + @protected + @mustCallSuper + void handleThumbPress() { + assert(_debugCheckHasValidScrollPosition()); + if (getScrollbarDirection() == null) { + return; + } + _fadeoutTimer?.cancel(); + } + + /// Handler called when a long press gesture has started. + /// + /// Begins the fade out animation and initializes dragging the scrollbar thumb. + @protected + @mustCallSuper + void handleThumbPressStart(Offset localPosition) { + assert(_debugCheckHasValidScrollPosition()); + _cachedController = _effectiveScrollController; + final Axis? direction = getScrollbarDirection(); + if (direction == null) { + return; + } + _fadeoutTimer?.cancel(); + _fadeoutAnimationController.forward(); + _startDragScrollbarAxisOffset = localPosition; + _lastDragUpdateOffset = localPosition; + _startDragThumbOffset = scrollbarPainter.getThumbScrollOffset(); + _thumbDragging = true; + } + + /// Handler called when a currently active long press gesture moves. + /// + /// Updates the position of the child scrollable. + @protected + @mustCallSuper + void handleThumbPressUpdate(Offset localPosition) { + assert(_debugCheckHasValidScrollPosition()); + if (_lastDragUpdateOffset == localPosition) { + return; + } + final ScrollPosition position = _cachedController!.position; + if (!widget.physics.shouldAcceptUserOffset(position)) { + return; + } + final Axis? direction = getScrollbarDirection(); + if (direction == null) { + return; + } + _updateScrollPosition(localPosition); + _lastDragUpdateOffset = localPosition; + } + + /// Handler called when a long press has ended. + @protected + @mustCallSuper + void handleThumbPressEnd(Offset localPosition, Velocity velocity) { + assert(_debugCheckHasValidScrollPosition()); + _thumbDragging = false; + final Axis? direction = getScrollbarDirection(); + if (direction == null) { + return; + } + _maybeStartFadeoutTimer(); + _startDragScrollbarAxisOffset = null; + _lastDragUpdateOffset = null; + _startDragThumbOffset = null; + _cachedController = null; + } + + void _handleTrackTapDown(TapDownDetails details) { + // The Scrollbar should page towards the position of the tap on the track. + assert(_debugCheckHasValidScrollPosition()); + _cachedController = _effectiveScrollController; + + final ScrollPosition position = _cachedController!.position; + if (!position.physics.shouldAcceptUserOffset(position)) { + return; + } + + // Determines the scroll direction. + final AxisDirection scrollDirection; + + switch (position.axisDirection) { + case AxisDirection.up: + case AxisDirection.down: + if (details.localPosition.dy > scrollbarPainter._thumbOffset) { + scrollDirection = AxisDirection.down; + } else { + scrollDirection = AxisDirection.up; + } + case AxisDirection.left: + case AxisDirection.right: + if (details.localPosition.dx > scrollbarPainter._thumbOffset) { + scrollDirection = AxisDirection.right; + } else { + scrollDirection = AxisDirection.left; + } + } + + final ScrollableState? state = Scrollable.maybeOf(position.context.notificationContext!); + final ScrollIntent intent = ScrollIntent(direction: scrollDirection, type: ScrollIncrementType.page); + assert(state != null); + final double scrollIncrement = ScrollAction.getDirectionalIncrement(state!, intent); + + _cachedController!.position.moveTo( + _cachedController!.position.pixels + scrollIncrement, + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + ); + } + + // ScrollController takes precedence over ScrollNotification + bool _shouldUpdatePainter(Axis notificationAxis) { + final ScrollController? scrollController = _effectiveScrollController; + // Only update the painter of this scrollbar if the notification + // metrics do not conflict with the information we have from the scroll + // controller. + + // We do not have a scroll controller dictating axis. + if (scrollController == null) { + return true; + } + // Has more than one attached positions. + if (scrollController.positions.length > 1) { + return false; + } + + return + // The scroll controller is not attached to a position. + !scrollController.hasClients + // The notification matches the scroll controller's axis. + || + scrollController.position.axis == notificationAxis; + } + + bool _handleScrollMetricsNotification(ScrollMetricsNotification notification) { + if (!widget.notificationPredicate(notification.asScrollUpdate())) { + return false; + } + + if (showScrollbar) { + if (_fadeoutAnimationController.status != AnimationStatus.forward && + _fadeoutAnimationController.status != AnimationStatus.completed) { + _fadeoutAnimationController.forward(); + } + } + + final ScrollMetrics metrics = notification.metrics; + if (_shouldUpdatePainter(metrics.axis)) { + scrollbarPainter.update(metrics, metrics.axisDirection); + } + return false; + } + + bool _handleScrollNotification(ScrollNotification notification) { + if (!widget.notificationPredicate(notification)) { + return false; + } + + final ScrollMetrics metrics = notification.metrics; + if (metrics.maxScrollExtent <= metrics.minScrollExtent) { + // Hide the bar when the Scrollable widget has no space to scroll. + if (_fadeoutAnimationController.status != AnimationStatus.dismissed && + _fadeoutAnimationController.status != AnimationStatus.reverse) { + _fadeoutAnimationController.reverse(); + } + + if (_shouldUpdatePainter(metrics.axis)) { + scrollbarPainter.update(metrics, metrics.axisDirection); + } + return false; + } + + if (notification is ScrollUpdateNotification || notification is OverscrollNotification) { + // Any movements always makes the scrollbar start showing up. + if (_fadeoutAnimationController.status != AnimationStatus.forward && + _fadeoutAnimationController.status != AnimationStatus.completed) { + _fadeoutAnimationController.forward(); + } + + _fadeoutTimer?.cancel(); + + if (_shouldUpdatePainter(metrics.axis)) { + scrollbarPainter.update(metrics, metrics.axisDirection); + } + } else if (notification is ScrollEndNotification) { + if (_startDragScrollbarAxisOffset == null) { + _maybeStartFadeoutTimer(); + } + } + return false; + } + + Map get _gestures { + final Map gestures = {}; + if (_effectiveScrollController == null || !enableGestures) { + return gestures; + } + + gestures[_ThumbPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<_ThumbPressGestureRecognizer>( + () => _ThumbPressGestureRecognizer( + debugOwner: this, + customPaintKey: _scrollbarPainterKey, + duration: widget.pressDuration, + ), + (_ThumbPressGestureRecognizer instance) { + instance.onLongPress = handleThumbPress; + instance.onLongPressStart = (LongPressStartDetails details) => handleThumbPressStart(details.localPosition); + instance.onLongPressMoveUpdate = + (LongPressMoveUpdateDetails details) => handleThumbPressUpdate(details.localPosition); + instance.onLongPressEnd = + (LongPressEndDetails details) => handleThumbPressEnd(details.localPosition, details.velocity); + }, + ); + + gestures[_TrackTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<_TrackTapGestureRecognizer>( + () => _TrackTapGestureRecognizer( + debugOwner: this, + customPaintKey: _scrollbarPainterKey, + ), + (_TrackTapGestureRecognizer instance) { + instance.onTapDown = _handleTrackTapDown; + }, + ); + + return gestures; + } + + /// Returns true if the provided [Offset] is located over the track of the + /// [RawScrollbarWithCustomPhysics]. + /// + /// Excludes the [RawScrollbarWithCustomPhysics] thumb. + @protected + bool isPointerOverTrack(Offset position, PointerDeviceKind kind) { + if (_scrollbarPainterKey.currentContext == null) { + return false; + } + final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position); + return scrollbarPainter.hitTestInteractive(localOffset, kind) && + !scrollbarPainter.hitTestOnlyThumbInteractive(localOffset, kind); + } + + /// Returns true if the provided [Offset] is located over the thumb of the + /// [RawScrollbarWithCustomPhysics]. + @protected + bool isPointerOverThumb(Offset position, PointerDeviceKind kind) { + if (_scrollbarPainterKey.currentContext == null) { + return false; + } + final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position); + return scrollbarPainter.hitTestOnlyThumbInteractive(localOffset, kind); + } + + /// Returns true if the provided [Offset] is located over the track or thumb + /// of the [RawScrollbarWithCustomPhysics]. + /// + /// The hit test area for mouse hovering over the scrollbar is larger than + /// regular hit testing. This is to make it easier to interact with the + /// scrollbar and present it to the mouse for interaction based on proximity. + /// When `forHover` is true, the larger hit test area will be used. + @protected + bool isPointerOverScrollbar(Offset position, PointerDeviceKind kind, {bool forHover = false}) { + if (_scrollbarPainterKey.currentContext == null) { + return false; + } + final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position); + return scrollbarPainter.hitTestInteractive(localOffset, kind, forHover: true); + } + + /// Cancels the fade out animation so the scrollbar will remain visible for + /// interaction. + /// + /// Can be overridden by subclasses to respond to a [PointerHoverEvent]. + /// + /// Helper methods [isPointerOverScrollbar], [isPointerOverThumb], and + /// [isPointerOverTrack] can be used to determine the location of the pointer + /// relative to the painter scrollbar elements. + @protected + @mustCallSuper + void handleHover(PointerHoverEvent event) { + setState(() { + _isHoveringThumb = isPointerOverThumb(event.position, event.kind); + }); + + // Check if the position of the pointer falls over the painted scrollbar + if (isPointerOverScrollbar(event.position, event.kind, forHover: true)) { + _hoverIsActive = true; + // Bring the scrollbar back into view if it has faded or started to fade + // away. + _fadeoutAnimationController.forward(); + _fadeoutTimer?.cancel(); + } else if (_hoverIsActive) { + // Pointer is not over painted scrollbar. + _hoverIsActive = false; + _maybeStartFadeoutTimer(); + } + } + + /// Initiates the fade out animation. + /// + /// Can be overridden by subclasses to respond to a [PointerExitEvent]. + @protected + @mustCallSuper + void handleHoverExit(PointerExitEvent event) { + _hoverIsActive = false; + setState(() { + _isHoveringThumb = false; + }); + _maybeStartFadeoutTimer(); + } + + // Returns the delta that should result from applying [event] with axis and + // direction taken into account. + double _pointerSignalEventDelta(PointerScrollEvent event) { + assert(_cachedController != null); + double delta = _cachedController!.position.axis == Axis.horizontal ? event.scrollDelta.dx : event.scrollDelta.dy; + + if (axisDirectionIsReversed(_cachedController!.position.axisDirection)) { + delta *= -1; + } + return delta; + } + + // Returns the offset that should result from applying [event] to the current + // position, taking min/max scroll extent into account. + double _targetScrollOffsetForPointerScroll(double delta) { + assert(_cachedController != null); + return math.min( + math.max(_cachedController!.position.pixels + delta, _cachedController!.position.minScrollExtent), + _cachedController!.position.maxScrollExtent, + ); + } + + void _handlePointerScroll(PointerEvent event) { + assert(event is PointerScrollEvent); + _cachedController = _effectiveScrollController; + final double delta = _pointerSignalEventDelta(event as PointerScrollEvent); + final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta); + if (delta != 0.0 && targetScrollOffset != _cachedController!.position.pixels) { + _cachedController!.position.pointerScroll(delta); + } + } + + void _receivedPointerSignal(PointerSignalEvent event) { + _cachedController = _effectiveScrollController; + // Only try to scroll if the bar absorb the hit test. + if ((scrollbarPainter.hitTest(event.localPosition) ?? false) && + _cachedController != null && + _cachedController!.hasClients && + (!_thumbDragging || kIsWeb)) { + final ScrollPosition position = _cachedController!.position; + if (event is PointerScrollEvent) { + if (!position.physics.shouldAcceptUserOffset(position)) { + return; + } + final double delta = _pointerSignalEventDelta(event); + final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta); + if (delta != 0.0 && targetScrollOffset != position.pixels) { + GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll); + } + } else if (event is PointerScrollInertiaCancelEvent) { + position.jumpTo(position.pixels); + // Don't use the pointer signal resolver, all hit-tested scrollables should stop. + } + } + } + + @override + void dispose() { + _fadeoutAnimationController.dispose(); + _fadeoutTimer?.cancel(); + scrollbarPainter.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + updateScrollbarPainter(); + + return NotificationListener( + onNotification: _handleScrollMetricsNotification, + child: NotificationListener( + onNotification: _handleScrollNotification, + child: RepaintBoundary( + child: Listener( + onPointerSignal: _receivedPointerSignal, + child: RawGestureDetector( + gestures: _gestures, + child: MouseRegion( + cursor: _isHoveringThumb ? SystemMouseCursors.basic : MouseCursor.defer, + onExit: (PointerExitEvent event) { + switch (event.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + if (enableGestures) { + handleHoverExit(event); + } + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + case PointerDeviceKind.touch: + break; + } + }, + onHover: (PointerHoverEvent event) { + switch (event.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + if (enableGestures) { + handleHover(event); + } + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + case PointerDeviceKind.touch: + break; + } + }, + child: CustomPaint( + key: _scrollbarPainterKey, + foregroundPainter: scrollbarPainter, + child: RepaintBoundary(child: widget.child), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Paints a scrollbar's track and thumb. +/// +/// The size of the scrollbar along its scroll direction is typically +/// proportional to the percentage of content completely visible on screen, +/// as long as its size isn't less than [minLength] and it isn't overscrolling. +/// +/// Unlike [CustomPainter]s that subclasses [CustomPainter] and only repaint +/// when [shouldRepaint] returns true (which requires this [CustomPainter] to +/// be rebuilt), this painter has the added optimization of repainting and not +/// rebuilding when: +/// +/// * the scroll position changes; and +/// * when the scrollbar fades away. +/// +/// Calling [update] with the new [ScrollMetrics] will repaint the new scrollbar +/// position. +/// +/// Updating the value on the provided [fadeoutOpacityAnimation] will repaint +/// with the new opacity. +/// +/// You must call [dispose] on this [ScrollbarPainter] when it's no longer used. +/// +/// See also: +/// +/// * [Scrollbar] for a widget showing a scrollbar around a [Scrollable] in the +/// Material Design style. +/// * [CupertinoScrollbar] for a widget showing a scrollbar around a +/// [Scrollable] in the iOS style. +class ScrollbarPainter extends ChangeNotifier implements CustomPainter { + /// Creates a scrollbar with customizations given by construction arguments. + ScrollbarPainter({ + required Color color, + required this.fadeoutOpacityAnimation, + Color trackColor = const Color(0x00000000), + Color trackBorderColor = const Color(0x00000000), + TextDirection? textDirection, + double thickness = _kScrollbarThickness, + EdgeInsets padding = EdgeInsets.zero, + double mainAxisMargin = 0.0, + double crossAxisMargin = 0.0, + Radius? radius, + Radius? trackRadius, + OutlinedBorder? shape, + double minLength = _kMinThumbExtent, + double? minOverscrollLength, + ScrollbarOrientation? scrollbarOrientation, + bool ignorePointer = false, + }) : assert(radius == null || shape == null), + assert(minLength >= 0), + assert(minOverscrollLength == null || minOverscrollLength <= minLength), + assert(minOverscrollLength == null || minOverscrollLength >= 0), + assert(padding.isNonNegative), + _color = color, + _textDirection = textDirection, + _thickness = thickness, + _radius = radius, + _shape = shape, + _padding = padding, + _mainAxisMargin = mainAxisMargin, + _crossAxisMargin = crossAxisMargin, + _minLength = minLength, + _trackColor = trackColor, + _trackBorderColor = trackBorderColor, + _trackRadius = trackRadius, + _scrollbarOrientation = scrollbarOrientation, + _minOverscrollLength = minOverscrollLength ?? minLength, + _ignorePointer = ignorePointer { + fadeoutOpacityAnimation.addListener(notifyListeners); + } + + /// [Color] of the thumb. Mustn't be null. + Color get color => _color; + Color _color; + set color(Color value) { + if (color == value) { + return; + } + + _color = value; + notifyListeners(); + } + + /// [Color] of the track. Mustn't be null. + Color get trackColor => _trackColor; + Color _trackColor; + set trackColor(Color value) { + if (trackColor == value) { + return; + } + + _trackColor = value; + notifyListeners(); + } + + /// [Color] of the track border. Mustn't be null. + Color get trackBorderColor => _trackBorderColor; + Color _trackBorderColor; + set trackBorderColor(Color value) { + if (trackBorderColor == value) { + return; + } + + _trackBorderColor = value; + notifyListeners(); + } + + /// [Radius] of corners of the Scrollbar's track. + /// + /// Scrollbar's track will be rectangular if [trackRadius] is null. + Radius? get trackRadius => _trackRadius; + Radius? _trackRadius; + set trackRadius(Radius? value) { + if (trackRadius == value) { + return; + } + + _trackRadius = value; + notifyListeners(); + } + + /// [TextDirection] of the [BuildContext] which dictates the side of the + /// screen the scrollbar appears in (the trailing side). Must be set prior to + /// calling paint. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + assert(value != null); + if (textDirection == value) { + return; + } + + _textDirection = value; + notifyListeners(); + } + + /// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null. + double get thickness => _thickness; + double _thickness; + set thickness(double value) { + if (thickness == value) { + return; + } + + _thickness = value; + notifyListeners(); + } + + /// An opacity [Animation] that dictates the opacity of the thumb. + /// Changes in value of this [Listenable] will automatically trigger repaints. + /// Mustn't be null. + final Animation fadeoutOpacityAnimation; + + /// Distance from the scrollbar thumb's start and end to the edge of the + /// viewport in logical pixels. It affects the amount of available paint area. + /// + /// The scrollbar track consumes this space. + /// + /// Mustn't be null and defaults to 0. + double get mainAxisMargin => _mainAxisMargin; + double _mainAxisMargin; + set mainAxisMargin(double value) { + if (mainAxisMargin == value) { + return; + } + + _mainAxisMargin = value; + notifyListeners(); + } + + /// Distance from the scrollbar thumb to the nearest cross axis edge + /// in logical pixels. + /// + /// The scrollbar track consumes this space. + /// + /// Defaults to zero. + double get crossAxisMargin => _crossAxisMargin; + double _crossAxisMargin; + set crossAxisMargin(double value) { + if (crossAxisMargin == value) { + return; + } + + _crossAxisMargin = value; + notifyListeners(); + } + + /// [Radius] of corners if the scrollbar should have rounded corners. + /// + /// Scrollbar will be rectangular if [radius] is null. + Radius? get radius => _radius; + Radius? _radius; + set radius(Radius? value) { + assert(shape == null || value == null); + if (radius == value) { + return; + } + + _radius = value; + notifyListeners(); + } + + /// The [OutlinedBorder] of the scrollbar's thumb. + /// + /// Only one of [radius] and [shape] may be specified. For a rounded rectangle, + /// it's simplest to just specify [radius]. By default, the scrollbar thumb's + /// shape is a simple rectangle. + /// + /// If [shape] is specified, the thumb will take the shape of the passed + /// [OutlinedBorder] and fill itself with [color] (or grey if it + /// is unspecified). + /// + OutlinedBorder? get shape => _shape; + OutlinedBorder? _shape; + set shape(OutlinedBorder? value) { + assert(radius == null || value == null); + if (shape == value) { + return; + } + + _shape = value; + notifyListeners(); + } + + /// The amount of space by which to inset the scrollbar's start and end, as + /// well as its side to the nearest edge, in logical pixels. + /// + /// This is typically set to the current [MediaQueryData.padding] to avoid + /// partial obstructions such as display notches. If you only want additional + /// margins around the scrollbar, see [mainAxisMargin]. + /// + /// Defaults to [EdgeInsets.zero]. Offsets from all four directions must be + /// greater than or equal to zero. + EdgeInsets get padding => _padding; + EdgeInsets _padding; + set padding(EdgeInsets value) { + if (padding == value) { + return; + } + + _padding = value; + notifyListeners(); + } + + /// The preferred smallest size the scrollbar thumb can shrink to when the total + /// scrollable extent is large, the current visible viewport is small, and the + /// viewport is not overscrolled. + /// + /// The size of the scrollbar may shrink to a smaller size than [minLength] to + /// fit in the available paint area. E.g., when [minLength] is + /// `double.infinity`, it will not be respected if + /// [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. + /// + /// Mustn't be null and the value has to be greater or equal to + /// [minOverscrollLength], which in turn is >= 0. Defaults to 18.0. + double get minLength => _minLength; + double _minLength; + set minLength(double value) { + if (minLength == value) { + return; + } + + _minLength = value; + notifyListeners(); + } + + /// The preferred smallest size the scrollbar thumb can shrink to when viewport is + /// overscrolled. + /// + /// When overscrolling, the size of the scrollbar may shrink to a smaller size + /// than [minOverscrollLength] to fit in the available paint area. E.g., when + /// [minOverscrollLength] is `double.infinity`, it will not be respected if + /// the [ScrollMetrics.viewportDimension] and [mainAxisMargin] are finite. + /// + /// The value is less than or equal to [minLength] and greater than or equal to 0. + /// When null, it will default to the value of [minLength]. + double get minOverscrollLength => _minOverscrollLength; + double _minOverscrollLength; + set minOverscrollLength(double value) { + if (minOverscrollLength == value) { + return; + } + + _minOverscrollLength = value; + notifyListeners(); + } + + /// {@template flutter.widgets.Scrollbar.scrollbarOrientation} + /// Dictates the orientation of the scrollbar. + /// + /// [ScrollbarOrientation.top] places the scrollbar on top of the screen. + /// [ScrollbarOrientation.bottom] places the scrollbar on the bottom of the screen. + /// [ScrollbarOrientation.left] places the scrollbar on the left of the screen. + /// [ScrollbarOrientation.right] places the scrollbar on the right of the screen. + /// + /// [ScrollbarOrientation.top] and [ScrollbarOrientation.bottom] can only be + /// used with a vertical scroll. + /// [ScrollbarOrientation.left] and [ScrollbarOrientation.right] can only be + /// used with a horizontal scroll. + /// + /// For a vertical scroll the orientation defaults to + /// [ScrollbarOrientation.right] for [TextDirection.ltr] and + /// [ScrollbarOrientation.left] for [TextDirection.rtl]. + /// For a horizontal scroll the orientation defaults to [ScrollbarOrientation.bottom]. + /// {@endtemplate} + ScrollbarOrientation? get scrollbarOrientation => _scrollbarOrientation; + ScrollbarOrientation? _scrollbarOrientation; + set scrollbarOrientation(ScrollbarOrientation? value) { + if (scrollbarOrientation == value) { + return; + } + + _scrollbarOrientation = value; + notifyListeners(); + } + + /// Whether the painter will be ignored during hit testing. + bool get ignorePointer => _ignorePointer; + bool _ignorePointer; + set ignorePointer(bool value) { + if (ignorePointer == value) { + return; + } + + _ignorePointer = value; + notifyListeners(); + } + + // - Scrollbar Details + + Rect? _trackRect; + // The full painted length of the track + double get _trackExtent => _lastMetrics!.viewportDimension - _totalTrackMainAxisOffsets; + // The full length of the track that the thumb can travel + double get _traversableTrackExtent => _trackExtent - (2 * mainAxisMargin); + // Track Offsets + // The track is offset by only padding. + double get _totalTrackMainAxisOffsets => _isVertical ? padding.vertical : padding.horizontal; + double get _leadingTrackMainAxisOffset { + switch (_resolvedOrientation) { + case ScrollbarOrientation.left: + case ScrollbarOrientation.right: + return padding.top; + case ScrollbarOrientation.top: + case ScrollbarOrientation.bottom: + return padding.left; + } + } + + Rect? _thumbRect; + // The current scroll position + _leadingThumbMainAxisOffset + late double _thumbOffset; + // The fraction visible in relation to the traversable length of the track. + late double _thumbExtent; + // Thumb Offsets + // The thumb is offset by padding and margins. + double get _leadingThumbMainAxisOffset { + switch (_resolvedOrientation) { + case ScrollbarOrientation.left: + case ScrollbarOrientation.right: + return padding.top + mainAxisMargin; + case ScrollbarOrientation.top: + case ScrollbarOrientation.bottom: + return padding.left + mainAxisMargin; + } + } + + void _setThumbExtent() { + // Thumb extent reflects fraction of content visible, as long as this + // isn't less than the absolute minimum size. + // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0 + final double fractionVisible = clampDouble( + (_lastMetrics!.extentInside - _totalTrackMainAxisOffsets) / (_totalContentExtent - _totalTrackMainAxisOffsets), + 0.0, + 1.0, + ); + + final double thumbExtent = math.max( + math.min(_traversableTrackExtent, minOverscrollLength), + _traversableTrackExtent * fractionVisible, + ); + + final double fractionOverscrolled = 1.0 - _lastMetrics!.extentInside / _lastMetrics!.viewportDimension; + final double safeMinLength = math.min(minLength, _traversableTrackExtent); + final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0) + // Thumb extent is no smaller than minLength if scrolling normally. + ? safeMinLength + // User is overscrolling. Thumb extent can be less than minLength + // but no smaller than minOverscrollLength. We can't use the + // fractionVisible to produce intermediate values between minLength and + // minOverscrollLength when the user is transitioning from regular + // scrolling to overscrolling, so we instead use the percentage of the + // content that is still in the viewport to determine the size of the + // thumb. iOS behavior appears to have the thumb reach its minimum size + // with ~20% of overscroll. We map the percentage of minLength from + // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce + // values for the thumb that range between minLength and the smallest + // possible value, minOverscrollLength. + : safeMinLength * (1.0 - clampDouble(fractionOverscrolled, 0.0, 0.2) / 0.2); + + // The `thumbExtent` should be no greater than `trackSize`, otherwise + // the scrollbar may scroll towards the wrong direction. + _thumbExtent = clampDouble(thumbExtent, newMinLength, _traversableTrackExtent); + } + + // - Scrollable Details + + ScrollMetrics? _lastMetrics; + bool get _lastMetricsAreScrollable => _lastMetrics!.minScrollExtent != _lastMetrics!.maxScrollExtent; + AxisDirection? _lastAxisDirection; + + bool get _isVertical => _lastAxisDirection == AxisDirection.down || _lastAxisDirection == AxisDirection.up; + bool get _isReversed => _lastAxisDirection == AxisDirection.up || _lastAxisDirection == AxisDirection.left; + // The amount of scroll distance before and after the current position. + double get _beforeExtent => _isReversed ? _lastMetrics!.extentAfter : _lastMetrics!.extentBefore; + double get _afterExtent => _isReversed ? _lastMetrics!.extentBefore : _lastMetrics!.extentAfter; + + // The total size of the scrollable content. + double get _totalContentExtent { + return _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent + _lastMetrics!.viewportDimension; + } + + ScrollbarOrientation get _resolvedOrientation { + if (scrollbarOrientation == null) { + if (_isVertical) { + return textDirection == TextDirection.ltr ? ScrollbarOrientation.right : ScrollbarOrientation.left; + } + return ScrollbarOrientation.bottom; + } + return scrollbarOrientation!; + } + + void _debugAssertIsValidOrientation(ScrollbarOrientation orientation) { + assert(() { + bool isVerticalOrientation(ScrollbarOrientation orientation) => + orientation == ScrollbarOrientation.left || orientation == ScrollbarOrientation.right; + return (_isVertical && isVerticalOrientation(orientation)) || + (!_isVertical && !isVerticalOrientation(orientation)); + }(), + 'The given ScrollbarOrientation: $orientation is incompatible with the ' + 'current AxisDirection: $_lastAxisDirection.'); + } + + // - Updating + + /// Update with new [ScrollMetrics]. If the metrics change, the scrollbar will + /// show and redraw itself based on these new metrics. + /// + /// The scrollbar will remain on screen. + void update( + ScrollMetrics metrics, + AxisDirection axisDirection, + ) { + if (_lastMetrics != null && + _lastMetrics!.extentBefore == metrics.extentBefore && + _lastMetrics!.extentInside == metrics.extentInside && + _lastMetrics!.extentAfter == metrics.extentAfter && + _lastAxisDirection == axisDirection) { + return; + } + + final ScrollMetrics? oldMetrics = _lastMetrics; + _lastMetrics = metrics; + _lastAxisDirection = axisDirection; + + bool needPaint(ScrollMetrics? metrics) => metrics != null && metrics.maxScrollExtent > metrics.minScrollExtent; + if (!needPaint(oldMetrics) && !needPaint(metrics)) { + return; + } + notifyListeners(); + } + + /// Update and redraw with new scrollbar thickness and radius. + void updateThickness(double nextThickness, Radius nextRadius) { + thickness = nextThickness; + radius = nextRadius; + } + + // - Painting + + Paint get _paintThumb { + return Paint()..color = color.withValues(alpha: color.a * fadeoutOpacityAnimation.value); + } + + Paint _paintTrack({bool isBorder = false}) { + if (isBorder) { + return Paint() + ..color = trackBorderColor.withValues(alpha: trackBorderColor.a * fadeoutOpacityAnimation.value) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + } + return Paint()..color = trackColor.withValues(alpha: trackColor.a * fadeoutOpacityAnimation.value); + } + + void _paintScrollbar(Canvas canvas, Size size) { + assert( + textDirection != null, + 'A TextDirection must be provided before a Scrollbar can be painted.', + ); + + final double x, y; + final Size thumbSize, trackSize; + final Offset trackOffset, borderStart, borderEnd; + _debugAssertIsValidOrientation(_resolvedOrientation); + switch (_resolvedOrientation) { + case ScrollbarOrientation.left: + thumbSize = Size(thickness, _thumbExtent); + trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); + x = crossAxisMargin + padding.left; + y = _thumbOffset; + trackOffset = Offset(x - crossAxisMargin, _leadingTrackMainAxisOffset); + borderStart = trackOffset + Offset(trackSize.width, 0.0); + borderEnd = Offset(trackOffset.dx + trackSize.width, trackOffset.dy + _trackExtent); + case ScrollbarOrientation.right: + thumbSize = Size(thickness, _thumbExtent); + trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); + x = size.width - thickness - crossAxisMargin - padding.right; + y = _thumbOffset; + trackOffset = Offset(x - crossAxisMargin, _leadingTrackMainAxisOffset); + borderStart = trackOffset; + borderEnd = Offset(trackOffset.dx, trackOffset.dy + _trackExtent); + case ScrollbarOrientation.top: + thumbSize = Size(_thumbExtent, thickness); + trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); + x = _thumbOffset; + y = crossAxisMargin + padding.top; + trackOffset = Offset(_leadingTrackMainAxisOffset, y - crossAxisMargin); + borderStart = trackOffset + Offset(0.0, trackSize.height); + borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy + trackSize.height); + case ScrollbarOrientation.bottom: + thumbSize = Size(_thumbExtent, thickness); + trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); + x = _thumbOffset; + y = size.height - thickness - crossAxisMargin - padding.bottom; + trackOffset = Offset(_leadingTrackMainAxisOffset, y - crossAxisMargin); + borderStart = trackOffset; + borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy); + } + + // Whether we paint or not, calculating these rects allows us to hit test + // when the scrollbar is transparent. + _trackRect = trackOffset & trackSize; + _thumbRect = Offset(x, y) & thumbSize; + + // Paint if the opacity dictates visibility + if (fadeoutOpacityAnimation.value != 0.0) { + // Track + if (trackRadius == null) { + canvas.drawRect(_trackRect!, _paintTrack()); + } else { + canvas.drawRRect(RRect.fromRectAndRadius(_trackRect!, trackRadius!), _paintTrack()); + } + // Track Border + canvas.drawLine(borderStart, borderEnd, _paintTrack(isBorder: true)); + if (radius != null) { + // Rounded rect thumb + canvas.drawRRect(RRect.fromRectAndRadius(_thumbRect!, radius!), _paintThumb); + return; + } + if (shape == null) { + // Square thumb + canvas.drawRect(_thumbRect!, _paintThumb); + return; + } + // Custom-shaped thumb + final Path outerPath = shape!.getOuterPath(_thumbRect!); + canvas.drawPath(outerPath, _paintThumb); + shape!.paint(canvas, _thumbRect!); + } + } + + @override + void paint(Canvas canvas, Size size) { + if (_lastAxisDirection == null || + _lastMetrics == null || + _lastMetrics!.maxScrollExtent <= _lastMetrics!.minScrollExtent) { + return; + } + // Skip painting if there's not enough space. + if (_traversableTrackExtent <= 0) { + return; + } + // Do not paint a scrollbar if the scroll view is infinitely long. + // TODO(Piinks): Special handling for infinite scroll views, + // https://github.com/flutter/flutter/issues/41434 + if (_lastMetrics!.maxScrollExtent.isInfinite) { + return; + } + + _setThumbExtent(); + final double thumbPositionOffset = _getScrollToTrack(_lastMetrics!, _thumbExtent); + _thumbOffset = thumbPositionOffset + _leadingThumbMainAxisOffset; + + return _paintScrollbar(canvas, size); + } + + // - Scroll Position Conversion + + /// Convert between a thumb track position and the corresponding scroll + /// position. + /// + /// The `thumbOffsetLocal` argument is a position in the thumb track. + double getTrackToScroll(double thumbOffsetLocal) { + final double scrollableExtent = _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; + final double thumbMovableExtent = _traversableTrackExtent - _thumbExtent; + + return scrollableExtent * thumbOffsetLocal / thumbMovableExtent; + } + + /// The thumb's corresponding scroll offset in the track. + double getThumbScrollOffset() { + final double scrollableExtent = _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; + + final double fractionPast = + (scrollableExtent > 0) ? clampDouble(_lastMetrics!.pixels / scrollableExtent, 0.0, 1.0) : 0; + + return fractionPast * (_traversableTrackExtent - _thumbExtent); + } + + // Converts between a scroll position and the corresponding position in the + // thumb track. + double _getScrollToTrack(ScrollMetrics metrics, double thumbExtent) { + final double scrollableExtent = metrics.maxScrollExtent - metrics.minScrollExtent; + + final double fractionPast = (scrollableExtent > 0) + ? clampDouble((metrics.pixels - metrics.minScrollExtent) / scrollableExtent, 0.0, 1.0) + : 0; + + return (_isReversed ? 1 - fractionPast : fractionPast) * (_traversableTrackExtent - thumbExtent); + } + + // - Hit Testing + + @override + bool? hitTest(Offset? position) { + // There is nothing painted to hit. + if (_thumbRect == null) { + return null; + } + + // Interaction disabled. + if (ignorePointer + // The thumb is not able to be hit when transparent. + || + fadeoutOpacityAnimation.value == 0.0 + // Not scrollable + || + !_lastMetricsAreScrollable) { + return false; + } + + return _trackRect!.contains(position!); + } + + /// Same as hitTest, but includes some padding when the [PointerEvent] is + /// caused by [PointerDeviceKind.touch] to make sure that the region + /// isn't too small to be interacted with by the user. + /// + /// The hit test area for hovering with [PointerDeviceKind.mouse] over the + /// scrollbar also uses this extra padding. This is to make it easier to + /// interact with the scrollbar by presenting it to the mouse for interaction + /// based on proximity. When `forHover` is true, the larger hit test area will + /// be used. + bool hitTestInteractive(Offset position, PointerDeviceKind kind, {bool forHover = false}) { + if (_trackRect == null) { + // We have not computed the scrollbar position yet. + return false; + } + if (ignorePointer) { + return false; + } + + if (!_lastMetricsAreScrollable) { + return false; + } + + final Rect interactiveRect = _trackRect!; + final Rect paddedRect = interactiveRect.expandToInclude( + Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), + ); + + // The scrollbar is not able to be hit when transparent - except when + // hovering with a mouse. This should bring the scrollbar into view so the + // mouse can interact with it. + if (fadeoutOpacityAnimation.value == 0.0) { + if (forHover && kind == PointerDeviceKind.mouse) { + return paddedRect.contains(position); + } + return false; + } + + switch (kind) { + case PointerDeviceKind.touch: + case PointerDeviceKind.trackpad: + return paddedRect.contains(position); + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + return interactiveRect.contains(position); + } + } + + /// Same as hitTestInteractive, but excludes the track portion of the scrollbar. + /// Used to evaluate interactions with only the scrollbar thumb. + bool hitTestOnlyThumbInteractive(Offset position, PointerDeviceKind kind) { + if (_thumbRect == null) { + return false; + } + if (ignorePointer) { + return false; + } + // The thumb is not able to be hit when transparent. + if (fadeoutOpacityAnimation.value == 0.0) { + return false; + } + + if (!_lastMetricsAreScrollable) { + return false; + } + + switch (kind) { + case PointerDeviceKind.touch: + case PointerDeviceKind.trackpad: + final Rect touchThumbRect = _thumbRect!.expandToInclude( + Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2), + ); + return touchThumbRect.contains(position); + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + return _thumbRect!.contains(position); + } + } + + @override + bool shouldRepaint(ScrollbarPainter oldDelegate) { + // Should repaint if any properties changed. + return color != oldDelegate.color || + trackColor != oldDelegate.trackColor || + trackBorderColor != oldDelegate.trackBorderColor || + textDirection != oldDelegate.textDirection || + thickness != oldDelegate.thickness || + fadeoutOpacityAnimation != oldDelegate.fadeoutOpacityAnimation || + mainAxisMargin != oldDelegate.mainAxisMargin || + crossAxisMargin != oldDelegate.crossAxisMargin || + radius != oldDelegate.radius || + trackRadius != oldDelegate.trackRadius || + shape != oldDelegate.shape || + padding != oldDelegate.padding || + minLength != oldDelegate.minLength || + minOverscrollLength != oldDelegate.minOverscrollLength || + scrollbarOrientation != oldDelegate.scrollbarOrientation || + ignorePointer != oldDelegate.ignorePointer; + } + + @override + bool shouldRebuildSemantics(CustomPainter oldDelegate) => false; + + @override + SemanticsBuilderCallback? get semanticsBuilder => null; + + @override + String toString() => describeIdentity(this); + + @override + void dispose() { + fadeoutOpacityAnimation.removeListener(notifyListeners); + super.dispose(); + } +} + +// A long press gesture detector that only responds to events on the scrollbar's +// thumb and ignores everything else. +class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer { + _ThumbPressGestureRecognizer({ + required Object super.debugOwner, + required GlobalKey customPaintKey, + required super.duration, + }) : _customPaintKey = customPaintKey; + + final GlobalKey _customPaintKey; + + @override + bool isPointerAllowed(PointerDownEvent event) { + if (!_hitTestInteractive(_customPaintKey, event.position, event.kind)) { + return false; + } + return super.isPointerAllowed(event); + } + + bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset, PointerDeviceKind kind) { + if (customPaintKey.currentContext == null) { + return false; + } + final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint; + final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter; + final Offset localOffset = _getLocalOffset(customPaintKey, offset); + return painter.hitTestOnlyThumbInteractive(localOffset, kind); + } +} + +// A tap gesture detector that only responds to events on the scrollbar's +// track and ignores everything else, including the thumb. +class _TrackTapGestureRecognizer extends TapGestureRecognizer { + _TrackTapGestureRecognizer({ + required super.debugOwner, + required GlobalKey customPaintKey, + }) : _customPaintKey = customPaintKey; + + final GlobalKey _customPaintKey; + + @override + bool isPointerAllowed(PointerDownEvent event) { + if (!_hitTestInteractive(_customPaintKey, event.position, event.kind)) { + return false; + } + return super.isPointerAllowed(event); + } + + bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset, PointerDeviceKind kind) { + if (customPaintKey.currentContext == null) { + return false; + } + final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint; + final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter; + final Offset localOffset = _getLocalOffset(customPaintKey, offset); + // We only receive track taps that are not on the thumb. + return painter.hitTestInteractive(localOffset, kind) && !painter.hitTestOnlyThumbInteractive(localOffset, kind); + } +} + +Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) { + final RenderBox renderBox = scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox; + return renderBox.globalToLocal(position); +} diff --git a/super_editor/lib/src/infrastructure/flutter/text_input_configuration.dart b/super_editor/lib/src/infrastructure/flutter/text_input_configuration.dart new file mode 100644 index 0000000000..3308793ac2 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/text_input_configuration.dart @@ -0,0 +1,44 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/services.dart'; + +extension TextInputConfigurationEquivalency on TextInputConfiguration { + /// Whether this [TextInputConfiguration] is equivalent to [other]. + /// + /// Two [TextInputConfiguration]s are considered to be equal + /// if all properties are equal. + bool isEquivalentTo(TextInputConfiguration other) { + return inputType == other.inputType && + readOnly == other.readOnly && + obscureText == other.obscureText && + autocorrect == other.autocorrect && + autofillConfiguration.isEquivalentTo(other.autofillConfiguration) && + smartDashesType == other.smartDashesType && + smartQuotesType == other.smartQuotesType && + enableSuggestions == other.enableSuggestions && + enableInteractiveSelection == other.enableInteractiveSelection && + actionLabel == other.actionLabel && + inputAction == other.inputAction && + textCapitalization == other.textCapitalization && + keyboardAppearance == other.keyboardAppearance && + enableIMEPersonalizedLearning == other.enableIMEPersonalizedLearning && + enableDeltaModel == other.enableDeltaModel && + const DeepCollectionEquality().equals(allowedMimeTypes, other.allowedMimeTypes); + } +} + +extension AutofillConfigurationEquivalency on AutofillConfiguration { + /// Whether this [AutofillConfiguration] is equivalent to [other]. + /// + /// Two [AutofillConfiguration]s are considered to be equal + /// if all properties are equal. + /// + /// The [currentEditingValue] isn't considered in the comparison. + /// Otherwise, whenever the user changes the text or selection + /// would result in two configurations being unequal. + bool isEquivalentTo(AutofillConfiguration other) { + return enabled == other.enabled && + uniqueIdentifier == other.uniqueIdentifier && + const DeepCollectionEquality().equals(autofillHints, other.autofillHints) && + hintText == other.hintText; + } +} diff --git a/super_editor/lib/src/infrastructure/flutter/text_selection.dart b/super_editor/lib/src/infrastructure/flutter/text_selection.dart new file mode 100644 index 0000000000..93a51d4990 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter/text_selection.dart @@ -0,0 +1,10 @@ +import 'package:flutter/painting.dart'; + +extension Bounds on TextSelection { + /// Returns `true` if this [TextSelection] has the same bounds as the + /// [other] [TextSelection], regardless of selection direction, i.e., + /// affinity. + bool hasSameBoundsAs(TextSelection other) { + return start == other.start && end == other.end; + } +} diff --git a/super_editor/lib/src/infrastructure/focus.dart b/super_editor/lib/src/infrastructure/focus.dart deleted file mode 100644 index efa60cfaf8..0000000000 --- a/super_editor/lib/src/infrastructure/focus.dart +++ /dev/null @@ -1,640 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; - -/// Widget that responds to keyboard events for a given [focusNode] without -/// necessarily re-parenting the [focusNode]. -/// -/// The [focusNode] is only re-parented if its parent is `null`. -/// -/// The traditional [Focus] widget provides an `onKey` property, but that widget -/// automatically re-parents the [FocusNode] based on the structure of the widget -/// tree. Re-parenting is a problem in some situations, e.g., a popover toolbar -/// that appears while editing a document. The toolbar and the document are on -/// different branches of the widget tree, but they need to share focus. That shared -/// focus is impossible when the [Focus] widget forces re-parenting. The -/// [NonReparentingFocus] widget provides an [onKey] property without re-parenting the -/// given [focusNode]. -class NonReparentingFocus extends StatefulWidget { - const NonReparentingFocus({ - Key? key, - required this.focusNode, - this.onKey, - required this.child, - }) : super(key: key); - - /// The [FocusNode] that sends key events to [onKey]. - final FocusNode focusNode; - - /// The callback invoked whenever [focusNode] receives key events. - final FocusOnKeyCallback? onKey; - - /// The child of this widget. - final Widget child; - - @override - State createState() => _NonReparentingFocusState(); -} - -class _NonReparentingFocusState extends State { - late FocusAttachment _keyboardFocusAttachment; - - @override - void initState() { - super.initState(); - _keyboardFocusAttachment = widget.focusNode.attach(context, onKey: _onKey); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _reparentIfMissingParent(); - } - - @override - void didUpdateWidget(NonReparentingFocus oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.focusNode != oldWidget.focusNode) { - _keyboardFocusAttachment.detach(); - _keyboardFocusAttachment = widget.focusNode.attach(context, onKey: widget.onKey); - _reparentIfMissingParent(); - } - } - - @override - void dispose() { - _keyboardFocusAttachment.detach(); - super.dispose(); - } - - void _reparentIfMissingParent() { - if (widget.focusNode.parent == null) { - _keyboardFocusAttachment.reparent(); - } - } - - KeyEventResult _onKey(FocusNode focusNode, RawKeyEvent event) { - return widget.onKey?.call(focusNode, event) ?? KeyEventResult.ignored; - } - - @override - Widget build(BuildContext context) { - _reparentIfMissingParent(); - - return widget.child; - } -} - -/// Copy of [Focus] that configures its [FocusNode] with the -/// given [parentNode], rather than automatically re-parenting based -/// on the widget tree structure. -/// -/// One reason to attach a [FocusNode] to a parent [FocusNode] that -/// isn't an ancestor in the widget tree is an editor with a popover -/// toolbar that contains a text field for entering a link URL. With -/// this widget, the editor's [FocusNode] can become the parent of the -/// toolbar's [FocusNode], which allows the editor to remain "focused" -/// while the user types a URL into the toolbar's text field, which has -/// "primary focus". -/// -/// The only changes made to [Focus] are the parts related to re-parenting. -/// As a result, there may be [Focus] behaviors that don't work. We don't -/// want to invest the time to re-write all the standard [Focus] tests. -/// Hopefully, Flutter #106923 will result in changes to [Focus] that make -/// [FocusWithCustomParent] superfluous, and we can remove it. -class FocusWithCustomParent extends StatefulWidget { - const FocusWithCustomParent({ - Key? key, - this.focusNode, - this.parentFocusNode, - this.autofocus = false, - this.onFocusChange, - FocusOnKeyEventCallback? onKeyEvent, - FocusOnKeyCallback? onKey, - bool? canRequestFocus, - bool? skipTraversal, - bool? descendantsAreFocusable, - bool? descendantsAreTraversable, - this.includeSemantics = true, - String? debugLabel, - required this.child, - }) : _onKeyEvent = onKeyEvent, - _onKey = onKey, - _canRequestFocus = canRequestFocus, - _skipTraversal = skipTraversal, - _descendantsAreFocusable = descendantsAreFocusable, - _descendantsAreTraversable = descendantsAreTraversable, - _debugLabel = debugLabel, - super(key: key); - - // Indicates whether the widget's focusNode attributes should have priority - // when then widget is updated. - bool get _usingExternalFocus => false; - - /// The child widget of this [FocusWithCustomParent]. - /// - /// {@macro flutter.widgets.ProxyWidget.child} - final Widget child; - - /// {@template flutter.widgets.Focus.focusNode} - /// An optional focus node to use as the focus node for this widget. - /// - /// If one is not supplied, then one will be automatically allocated, owned, - /// and managed by this widget. The widget will be focusable even if a - /// [focusNode] is not supplied. If supplied, the given `focusNode` will be - /// _hosted_ by this widget, but not owned. See [FocusNode] for more - /// information on what being hosted and/or owned implies. - /// - /// Supplying a focus node is sometimes useful if an ancestor to this widget - /// wants to control when this widget has the focus. The owner will be - /// responsible for calling [FocusNode.dispose] on the focus node when it is - /// done with it, but this widget will attach/detach and reparent the node - /// when needed. - /// {@endtemplate} - /// - /// A non-null [focusNode] must be supplied if using the - /// [Focus.withExternalFocusNode] constructor is used. - final FocusNode? focusNode; - - /// The [FocusNode] that's installed as the parent of [focusNode], regardless - /// of the location of [parentFocusNode] in the widget tree. - final FocusNode? parentFocusNode; - - /// {@template flutter.widgets.Focus.autofocus} - /// True if this widget will be selected as the initial focus when no other - /// node in its scope is currently focused. - /// - /// Ideally, there is only one widget with autofocus set in each [FocusScope]. - /// If there is more than one widget with autofocus set, then the first one - /// added to the tree will get focus. - /// - /// Must not be null. Defaults to false. - /// {@endtemplate} - final bool autofocus; - - /// Handler called when the focus changes. - /// - /// Called with true if this widget's node gains focus, and false if it loses - /// focus. - final ValueChanged? onFocusChange; - - /// A handler for keys that are pressed when this object or one of its - /// children has focus. - /// - /// Key events are first given to the [FocusNode] that has primary focus, and - /// if its [onKeyEvent] method returns [KeyEventResult.ignored], then they are - /// given to each ancestor node up the focus hierarchy in turn. If an event - /// reaches the root of the hierarchy, it is discarded. - /// - /// This is not the way to get text input in the manner of a text field: it - /// leaves out support for input method editors, and doesn't support soft - /// keyboards in general. For text input, consider [TextField], - /// [EditableText], or [CupertinoTextField] instead, which do support these - /// things. - FocusOnKeyEventCallback? get onKeyEvent => _onKeyEvent ?? focusNode?.onKeyEvent; - final FocusOnKeyEventCallback? _onKeyEvent; - - /// A handler for keys that are pressed when this object or one of its - /// children has focus. - /// - /// This is a legacy API based on [RawKeyEvent] and will be deprecated in the - /// future. Prefer [onKeyEvent] instead. - /// - /// Key events are first given to the [FocusNode] that has primary focus, and - /// if its [onKey] method return false, then they are given to each ancestor - /// node up the focus hierarchy in turn. If an event reaches the root of the - /// hierarchy, it is discarded. - /// - /// This is not the way to get text input in the manner of a text field: it - /// leaves out support for input method editors, and doesn't support soft - /// keyboards in general. For text input, consider [TextField], - /// [EditableText], or [CupertinoTextField] instead, which do support these - /// things. - FocusOnKeyCallback? get onKey => _onKey ?? focusNode?.onKey; - final FocusOnKeyCallback? _onKey; - - /// {@template flutter.widgets.Focus.canRequestFocus} - /// If true, this widget may request the primary focus. - /// - /// Defaults to true. Set to false if you want the [FocusNode] this widget - /// manages to do nothing when [FocusNode.requestFocus] is called on it. Does - /// not affect the children of this node, and [FocusNode.hasFocus] can still - /// return true if this node is the ancestor of the primary focus. - /// - /// This is different than [FocusWithCustomParent.skipTraversal] because [FocusWithCustomParent.skipTraversal] - /// still allows the widget to be focused, just not traversed to. - /// - /// Setting [FocusNode.canRequestFocus] to false implies that the widget will - /// also be skipped for traversal purposes. - /// - /// See also: - /// - /// * [FocusTraversalGroup], a widget that sets the traversal policy for its - /// descendants. - /// * [FocusTraversalPolicy], a class that can be extended to describe a - /// traversal policy. - /// {@endtemplate} - bool get canRequestFocus => _canRequestFocus ?? focusNode?.canRequestFocus ?? true; - final bool? _canRequestFocus; - - /// Sets the [FocusNode.skipTraversal] flag on the focus node so that it won't - /// be visited by the [FocusTraversalPolicy]. - /// - /// This is sometimes useful if a [FocusWithCustomParent] widget should receive key events as - /// part of the focus chain, but shouldn't be accessible via focus traversal. - /// - /// This is different from [FocusNode.canRequestFocus] because it only implies - /// that the widget can't be reached via traversal, not that it can't be - /// focused. It may still be focused explicitly. - bool get skipTraversal => _skipTraversal ?? focusNode?.skipTraversal ?? false; - final bool? _skipTraversal; - - /// {@template flutter.widgets.Focus.descendantsAreFocusable} - /// If false, will make this widget's descendants unfocusable. - /// - /// Defaults to true. Does not affect focusability of this node (just its - /// descendants): for that, use [FocusNode.canRequestFocus]. - /// - /// If any descendants are focused when this is set to false, they will be - /// unfocused. When `descendantsAreFocusable` is set to true again, they will - /// not be refocused, although they will be able to accept focus again. - /// - /// Does not affect the value of [FocusNode.canRequestFocus] on the - /// descendants. - /// - /// If a descendant node loses focus when this value is changed, the focus - /// will move to the scope enclosing this node. - /// - /// See also: - /// - /// * [ExcludeFocus], a widget that uses this property to conditionally - /// exclude focus for a subtree. - /// * [descendantsAreTraversable], which makes this widget's descendants - /// untraversable. - /// * [ExcludeFocusTraversal], a widget that conditionally excludes focus - /// traversal for a subtree. - /// * [FocusTraversalGroup], a widget used to group together and configure the - /// focus traversal policy for a widget subtree that has a - /// `descendantsAreFocusable` parameter to conditionally block focus for a - /// subtree. - /// {@endtemplate} - bool get descendantsAreFocusable => _descendantsAreFocusable ?? focusNode?.descendantsAreFocusable ?? true; - final bool? _descendantsAreFocusable; - - /// {@template flutter.widgets.Focus.descendantsAreTraversable} - /// If false, will make this widget's descendants untraversable. - /// - /// Defaults to true. Does not affect traversablility of this node (just its - /// descendants): for that, use [FocusNode.skipTraversal]. - /// - /// Does not affect the value of [FocusNode.skipTraversal] on the - /// descendants. Does not affect focusability of the descendants. - /// - /// See also: - /// - /// * [ExcludeFocusTraversal], a widget that uses this property to - /// conditionally exclude focus traversal for a subtree. - /// * [descendantsAreFocusable], which makes this widget's descendants - /// unfocusable. - /// * [ExcludeFocus], a widget that conditionally excludes focus for a subtree. - /// * [FocusTraversalGroup], a widget used to group together and configure the - /// focus traversal policy for a widget subtree that has a - /// `descendantsAreFocusable` parameter to conditionally block focus for a - /// subtree. - /// {@endtemplate} - bool get descendantsAreTraversable => _descendantsAreTraversable ?? focusNode?.descendantsAreTraversable ?? true; - final bool? _descendantsAreTraversable; - - /// {@template flutter.widgets.Focus.includeSemantics} - /// Include semantics information in this widget. - /// - /// If true, this widget will include a [Semantics] node that indicates the - /// [SemanticsProperties.focusable] and [SemanticsProperties.focused] - /// properties. - /// - /// It is not typical to set this to false, as that can affect the semantics - /// information available to accessibility systems. - /// - /// Must not be null, defaults to true. - /// {@endtemplate} - final bool includeSemantics; - - /// A debug label for this widget. - /// - /// Not used for anything except to be printed in the diagnostic output from - /// [toString] or [toStringDeep]. - /// - /// To get a string with the entire tree, call [debugDescribeFocusTree]. To - /// print it to the console call [debugDumpFocusTree]. - /// - /// Defaults to null. - String? get debugLabel => _debugLabel ?? focusNode?.debugLabel; - final String? _debugLabel; - - /// Returns the [focusNode] of the [FocusWithCustomParent] that most tightly encloses the - /// given [BuildContext]. - /// - /// If no [FocusWithCustomParent] node is found before reaching the nearest [FocusScope] - /// widget, or there is no [FocusWithCustomParent] widget in scope, then this method will - /// throw an exception. - /// - /// The `context` and `scopeOk` arguments must not be null. - /// - /// Calling this function creates a dependency that will rebuild the given - /// context when the focus changes. - /// - /// See also: - /// - /// * [maybeOf], which is similar to this function, but will return null - /// instead of throwing if it doesn't find a [Focus] node. - static FocusNode of(BuildContext context, {bool scopeOk = false}) { - final _FocusMarker? marker = context.dependOnInheritedWidgetOfExactType<_FocusMarker>(); - final FocusNode? node = marker?.notifier; - assert(() { - if (node == null) { - throw FlutterError( - 'Focus.of() was called with a context that does not contain a Focus widget.\n' - 'No Focus widget ancestor could be found starting from the context that was passed to ' - 'Focus.of(). This can happen because you are using a widget that looks for a Focus ' - 'ancestor, and do not have a Focus widget descendant in the nearest FocusScope.\n' - 'The context used was:\n' - ' $context', - ); - } - return true; - }()); - assert(() { - if (!scopeOk && node is FocusScopeNode) { - throw FlutterError( - 'Focus.of() was called with a context that does not contain a Focus between the given ' - 'context and the nearest FocusScope widget.\n' - 'No Focus ancestor could be found starting from the context that was passed to ' - 'Focus.of() to the point where it found the nearest FocusScope widget. This can happen ' - 'because you are using a widget that looks for a Focus ancestor, and do not have a ' - 'Focus widget ancestor in the current FocusScope.\n' - 'The context used was:\n' - ' $context', - ); - } - return true; - }()); - return node!; - } - - /// Returns the [focusNode] of the [FocusWithCustomParent] that most tightly encloses the - /// given [BuildContext]. - /// - /// If no [FocusWithCustomParent] node is found before reaching the nearest [FocusScope] - /// widget, or there is no [FocusWithCustomParent] widget in scope, then this method will - /// return null. - /// - /// The `context` and `scopeOk` arguments must not be null. - /// - /// Calling this function creates a dependency that will rebuild the given - /// context when the focus changes. - /// - /// See also: - /// - /// * [of], which is similar to this function, but will throw an exception if - /// it doesn't find a [Focus] node instead of returning null. - static FocusNode? maybeOf(BuildContext context, {bool scopeOk = false}) { - final _FocusMarker? marker = context.dependOnInheritedWidgetOfExactType<_FocusMarker>(); - final FocusNode? node = marker?.notifier; - if (node == null) { - return null; - } - if (!scopeOk && node is FocusScopeNode) { - return null; - } - return node; - } - - /// Returns true if the nearest enclosing [FocusWithCustomParent] widget's node is focused. - /// - /// A convenience method to allow build methods to write: - /// `Focus.isAt(context)` to get whether or not the nearest [FocusWithCustomParent] above them - /// in the widget hierarchy currently has the input focus. - /// - /// Returns false if no [FocusWithCustomParent] widget is found before reaching the nearest - /// [FocusScope], or if the root of the focus tree is reached without finding - /// a [FocusWithCustomParent] widget. - /// - /// Calling this function creates a dependency that will rebuild the given - /// context when the focus changes. - static bool isAt(BuildContext context) => FocusWithCustomParent.maybeOf(context)?.hasFocus ?? false; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(StringProperty('debugLabel', debugLabel, defaultValue: null)); - properties.add(FlagProperty('autofocus', value: autofocus, ifTrue: 'AUTOFOCUS', defaultValue: false)); - properties - .add(FlagProperty('canRequestFocus', value: canRequestFocus, ifFalse: 'NOT FOCUSABLE', defaultValue: false)); - properties.add(FlagProperty('descendantsAreFocusable', - value: descendantsAreFocusable, ifFalse: 'DESCENDANTS UNFOCUSABLE', defaultValue: true)); - properties.add(FlagProperty('descendantsAreTraversable', - value: descendantsAreTraversable, ifFalse: 'DESCENDANTS UNTRAVERSABLE', defaultValue: true)); - properties.add(DiagnosticsProperty('focusNode', focusNode, defaultValue: null)); - } - - @override - State createState() => _FocusWithCustomParentState(); -} - -class _FocusWithCustomParentState extends State { - FocusNode? _internalNode; - FocusNode get focusNode => widget.focusNode ?? _internalNode!; - late bool _hadPrimaryFocus; - late bool _couldRequestFocus; - late bool _descendantsWereFocusable; - late bool _descendantsWereTraversable; - bool _didAutofocus = false; - late FocusAttachment _focusAttachment; - - @override - void initState() { - super.initState(); - _initNode(); - } - - void _initNode() { - if (widget.focusNode == null) { - // Only create a new node if the widget doesn't have one. - // This calls a function instead of just allocating in place because - // _createNode is overridden in _FocusScopeState. - _internalNode ??= _createNode(); - } - focusNode.descendantsAreFocusable = widget.descendantsAreFocusable; - focusNode.descendantsAreTraversable = widget.descendantsAreTraversable; - focusNode.skipTraversal = widget.skipTraversal; - - if (widget._canRequestFocus != null) { - focusNode.canRequestFocus = widget._canRequestFocus!; - } - _couldRequestFocus = focusNode.canRequestFocus; - _descendantsWereFocusable = focusNode.descendantsAreFocusable; - _descendantsWereTraversable = focusNode.descendantsAreTraversable; - _hadPrimaryFocus = focusNode.hasPrimaryFocus; - _focusAttachment = focusNode.attach(context, onKeyEvent: widget.onKeyEvent, onKey: widget.onKey) - ..reparent(parent: widget.parentFocusNode); - - // Add listener even if the _internalNode existed before, since it should - // not be listening now if we're re-using a previous one because it should - // have already removed its listener. - focusNode.addListener(_handleFocusChanged); - } - - FocusNode _createNode() { - return FocusNode( - debugLabel: widget.debugLabel, - canRequestFocus: widget.canRequestFocus, - descendantsAreFocusable: widget.descendantsAreFocusable, - descendantsAreTraversable: widget.descendantsAreTraversable, - skipTraversal: widget.skipTraversal, - ); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _focusAttachment.reparent(parent: widget.parentFocusNode); - _handleAutofocus(); - } - - @override - void didUpdateWidget(FocusWithCustomParent oldWidget) { - super.didUpdateWidget(oldWidget); - assert(() { - // Only update the debug label in debug builds. - if (oldWidget.focusNode == widget.focusNode && - !widget._usingExternalFocus && - oldWidget.debugLabel != widget.debugLabel) { - focusNode.debugLabel = widget.debugLabel; - } - return true; - }()); - - if (oldWidget.focusNode == widget.focusNode) { - if (!widget._usingExternalFocus) { - if (widget.onKey != focusNode.onKey) { - focusNode.onKey = widget.onKey; - } - if (widget.onKeyEvent != focusNode.onKeyEvent) { - focusNode.onKeyEvent = widget.onKeyEvent; - } - - focusNode.skipTraversal = widget.skipTraversal; - - if (widget._canRequestFocus != null) { - focusNode.canRequestFocus = widget._canRequestFocus!; - } - focusNode.descendantsAreFocusable = widget.descendantsAreFocusable; - focusNode.descendantsAreTraversable = widget.descendantsAreTraversable; - } - } else { - _focusAttachment.detach(); - oldWidget.focusNode?.removeListener(_handleFocusChanged); - _initNode(); - } - - if (widget.parentFocusNode != oldWidget.parentFocusNode) { - _focusAttachment.reparent(parent: widget.parentFocusNode); - } - - if (oldWidget.autofocus != widget.autofocus) { - _handleAutofocus(); - } - } - - @override - void deactivate() { - super.deactivate(); - // The focus node's location in the tree is no longer valid here. But - // we can't unfocus or remove the node from the tree because if the widget - // is moved to a different part of the tree (via global key) it should - // retain its focus state. That's why we temporarily park it on the root - // focus node (via reparent) until it either gets moved to a different part - // of the tree (via didChangeDependencies) or until it is disposed. - _focusAttachment.reparent(); - _didAutofocus = false; - } - - @override - void dispose() { - // Regardless of the node owner, we need to remove it from the tree and stop - // listening to it. - focusNode.removeListener(_handleFocusChanged); - _focusAttachment.detach(); - - // Don't manage the lifetime of external nodes given to the widget, just the - // internal node. - _internalNode?.dispose(); - super.dispose(); - } - - void _handleAutofocus() { - if (!_didAutofocus && widget.autofocus) { - FocusScope.of(context).autofocus(focusNode); - _didAutofocus = true; - } - } - - void _handleFocusChanged() { - final bool hasPrimaryFocus = focusNode.hasPrimaryFocus; - final bool canRequestFocus = focusNode.canRequestFocus; - final bool descendantsAreFocusable = focusNode.descendantsAreFocusable; - final bool descendantsAreTraversable = focusNode.descendantsAreTraversable; - widget.onFocusChange?.call(focusNode.hasFocus); - // Check the cached states that matter here, and call setState if they have - // changed. - if (_hadPrimaryFocus != hasPrimaryFocus) { - setState(() { - _hadPrimaryFocus = hasPrimaryFocus; - }); - } - if (_couldRequestFocus != canRequestFocus) { - setState(() { - _couldRequestFocus = canRequestFocus; - }); - } - if (_descendantsWereFocusable != descendantsAreFocusable) { - setState(() { - _descendantsWereFocusable = descendantsAreFocusable; - }); - } - if (_descendantsWereTraversable != descendantsAreTraversable) { - setState(() { - _descendantsWereTraversable = descendantsAreTraversable; - }); - } - } - - @override - Widget build(BuildContext context) { - if (widget.parentFocusNode == null) { - _focusAttachment.reparent(); - } - - Widget child = widget.child; - if (widget.includeSemantics) { - child = Semantics( - focusable: _couldRequestFocus, - focused: _hadPrimaryFocus, - child: widget.child, - ); - } - return _FocusMarker( - node: focusNode, - child: child, - ); - } -} - -// The InheritedWidget marker for Focus and FocusScope. -class _FocusMarker extends InheritedNotifier { - const _FocusMarker({ - Key? key, - required FocusNode node, - required Widget child, - }) : super(key: key, notifier: node, child: child); -} diff --git a/super_editor/lib/src/infrastructure/ime_input_owner.dart b/super_editor/lib/src/infrastructure/ime_input_owner.dart index 3aaa8bafc3..4028bbe88f 100644 --- a/super_editor/lib/src/infrastructure/ime_input_owner.dart +++ b/super_editor/lib/src/infrastructure/ime_input_owner.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; /// A widget that internally accepts IME input. @@ -9,5 +10,6 @@ import 'package:flutter/services.dart'; /// This interface hides those details and ensures that the [DeltaTextInputClient] is available, by contract, /// from whichever class implements this interface. abstract class ImeInputOwner { + @visibleForTesting DeltaTextInputClient get imeClient; } diff --git a/super_editor/lib/src/infrastructure/raw_key_event_extensions.dart b/super_editor/lib/src/infrastructure/key_event_extensions.dart similarity index 85% rename from super_editor/lib/src/infrastructure/raw_key_event_extensions.dart rename to super_editor/lib/src/infrastructure/key_event_extensions.dart index 0b5664e83a..b0e22299db 100644 --- a/super_editor/lib/src/infrastructure/raw_key_event_extensions.dart +++ b/super_editor/lib/src/infrastructure/key_event_extensions.dart @@ -1,6 +1,6 @@ import 'package:flutter/services.dart'; -extension IsArrowKeyExtension on RawKeyEvent { +extension IsArrowKeyExtension on KeyEvent { bool get isArrowKeyPressed => logicalKey == LogicalKeyboardKey.arrowUp || logicalKey == LogicalKeyboardKey.arrowDown || diff --git a/super_editor/lib/src/infrastructure/keyboard.dart b/super_editor/lib/src/infrastructure/keyboard.dart index c2c833f338..478067723c 100644 --- a/super_editor/lib/src/infrastructure/keyboard.dart +++ b/super_editor/lib/src/infrastructure/keyboard.dart @@ -2,6 +2,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; + +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; enum ExecutionInstruction { /// The handler has no relation to the key event and @@ -30,10 +33,10 @@ enum ExecutionInstruction { haltExecution, } -extension PrimaryShortcutKey on RawKeyEvent { +extension PrimaryShortcutKey on KeyEvent { bool get isPrimaryShortcutKeyPressed => - (defaultTargetPlatform == TargetPlatform.macOS && isMetaPressed) || - (defaultTargetPlatform != TargetPlatform.macOS && isControlPressed); + (CurrentPlatform.isApple && HardwareKeyboard.instance.isMetaPressed) || + (!CurrentPlatform.isApple && HardwareKeyboard.instance.isControlPressed); } /// Whether the given [character] should be ignored when it's received within diff --git a/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart new file mode 100644 index 0000000000..af29ea525f --- /dev/null +++ b/super_editor/lib/src/infrastructure/keyboard_panel_scaffold.dart @@ -0,0 +1,1423 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:super_editor/src/default_editor/document_ime/document_input_ime.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_keyboard/super_keyboard.dart'; + +/// Scaffold that displays the given [contentBuilder], while also (optionally) displaying +/// a toolbar docked to the top of the software keyboard, and/or a panel that appears +/// instead of the software keyboard. +/// +/// A typical use case for the keyboard panel is a chat application switching between the +/// software keyboard and an emoji panel. +/// +/// To correctly use this scaffold, you must place a [KeyboardScaffoldSafeArea] higher in +/// the widget tree to adjust the padding so that the content is above the keyboard panel +/// and software keyboard. The [KeyboardScaffoldSafeArea] can go anywhere higher in the tree, +/// so long as the [KeyboardScaffoldSafeArea] takes up the entire screen. +/// +/// The widget returned by [toolbarBuilder] is positioned above the keyboard panel, when +/// visible, or above the software keyboard, when visible. If neither the keyboard panel nor +/// the software keyboard are visible, the widget is positioned at the bottom of the screen. +/// +/// The widget returned by [keyboardPanelBuilder] is positioned at the bottom of the screen, +/// with its height constrained to be equal to the software keyboard height. +/// +/// The widget returned by [contentBuilder] is positioned above the above-keyboard panel, +/// using all the remaining height. +/// +/// Use the [controller] to show/hide the keyboard panel and software keyboard. +/// +/// If there is a [Scaffold] in your widget tree, it must have `resizeToAvoidBottomInset` +/// set to `false`, otherwise we can't get the software keyboard height to size the keyboard +/// panel. If `resizeToAvoidBottomInset` is set to `true`, the panel won't be displayed. +class KeyboardPanelScaffold extends StatefulWidget { + const KeyboardPanelScaffold({ + super.key, + required this.controller, + required this.isImeConnected, + required this.toolbarBuilder, + required this.keyboardPanelBuilder, + this.fallbackPanelHeight = 340, + required this.contentBuilder, + this.bypassMediaQuery = false, + }); + + /// Controls the visibility of the keyboard toolbar, keyboard panel, and software keyboard. + final KeyboardPanelController controller; + + /// A [ValueListenable] that should notify this [KeyboardPanelScaffold] when the IME connects + /// and disconnects. + /// + /// This signal is used to automatically close any open panel when the IME disconnects. + final ValueListenable isImeConnected; + + /// Builds the toolbar that's docked to the top of the software keyboard area. + final Widget Function(BuildContext context, PanelType? openPanel) toolbarBuilder; + + /// Builds the keyboard panel that's displayed in place of the software keyboard. + final Widget Function(BuildContext context, PanelType? openPanel) keyboardPanelBuilder; + + /// The height of the keyboard panel in situations where no software keyboard is + /// present, e.g., on a tablet when using a physical keyboard, or when using a floating + /// software keyboard. + final double fallbackPanelHeight; + + /// Builds the regular widget subtree beneath this widget. + /// + /// This is the content that this widget "wraps". Sometimes this content might be + /// a whole screen of content, or other times this content might be a single widget + /// like a text field or an editor. + final Widget Function(BuildContext context, PanelType? openPanel) contentBuilder; + + /// When determining the height of the keyboard, whether to bypass Flutter's `MediaQuery` + /// and solely rely on `SuperKeyboard` reporting, or whether the scaffold should use a + /// combination of both. + /// + /// This option was added after a client app had an Android lifecycle bug, which caused + /// Flutter's `MediaQuery` to report the wrong bottom insets. Apps should start with this + /// value as `false` and only change it to `true` if the app runs into problems with + /// `MediaQuery`. + final bool bypassMediaQuery; + + @override + State> createState() => _KeyboardPanelScaffoldState(); +} + +class _KeyboardPanelScaffoldState extends State> + with SingleTickerProviderStateMixin + implements KeyboardPanelScaffoldDelegate { + /// Whether we've run at least one didChangeDependencies, which is initially + /// used to check for existing bottom insets. + bool _didInitializeViewInsets = false; + + /// The best guess of the height of the fully open software keyboard. + /// + /// The OS doesn't report this info. We observe the bottom insets and retain + /// the tallest value that we see. + /// + /// Note: There may be situations in which an "open" keyboard corresponds to + /// multiple possible heights. For example, on an iPad, iOS reports an "open" + /// keyboard when the software keyboard is up, as well as when the small "minimized" + /// keyboard toolbar is visible. The minimized version is only 69 pixels tall. + double _bestGuessMaxKeyboardHeight = 0.0; + + /// The height of the software keyboard at this moment. + double _currentKeyboardHeight = 0.0; + + /// The height of the visible panel at this moment. + late final AnimationController _panelHeightController; + late Animation _panelHeight; + + /// The currently visible panel. + PanelType? _activePanel; + + /// The current bottom spacing, which might be equal to a panel height, or the + /// current keyboard height, or it might be an intermediate spacing as we switch + /// between a panel and keyboard. + final _currentBottomSpacing = ValueNotifier(0.0); + + /// Shows/hides the [OverlayPortal] containing the keyboard panel and above-keyboard panel. + final OverlayPortalController _overlayPortalController = OverlayPortalController(); + + bool get _wantsToShowToolbar => + widget.controller.toolbarVisibility == KeyboardToolbarVisibility.visible || + (widget.controller.toolbarVisibility == KeyboardToolbarVisibility.auto && + (widget.isImeConnected.value || wantsToShowKeyboardPanel)); + + final _toolbarKey = GlobalKey(); + + SoftwareKeyboardController? _softwareKeyboardController; + + KeyboardScaffoldSafeAreaMutator? _ancestorSafeArea; + + @override + void initState() { + super.initState(); + assert(() { + final scaffold = Scaffold.maybeOf(context); + if (scaffold != null && scaffold.widget.resizeToAvoidBottomInset != false) { + throw FlutterError( + 'KeyboardPanelScaffold is placed inside a Scaffold with resizeToAvoidBottomInset set to true.\n' + 'This will produce incorrect results. Set resizeToAvoidBottomInset to false.', + ); + } + return true; + }()); + + _panelHeightController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + ) + ..addStatusListener(_onPanelAnimationChange) + ..addListener(_onPanelHeightChange); + _updateMaxPanelHeight(); + + widget.controller.attach(this); + + widget.isImeConnected.addListener(_onImeConnectionChange); + + SuperKeyboard.instance.mobileGeometry.addListener(_onKeyboardGeometryChange); + + onNextFrame((_) { + // Wait until next frame to show overlay portal because it can't handle `show()` + // during a build operation. + _overlayPortalController.show(); + + // Do initial safe area report to our ancestor keyboard safe area widget, + // after we've added our UI to the overlay portal. + _updateSafeArea(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _ancestorSafeArea = KeyboardScaffoldSafeAreaScope.maybeOf(context); + if (!_didInitializeViewInsets) { + _didInitializeViewInsets = true; + _bestGuessMaxKeyboardHeight = _getCurrentKeyboardHeight(); + widget.controller.debugBestGuessKeyboardHeight.value = _bestGuessMaxKeyboardHeight; + _updateMaxPanelHeight(); + } + + _updateKeyboardHeightForCurrentViewInsets(); + } + + @override + void didUpdateWidget(KeyboardPanelScaffold oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller.detach(); + widget.controller.attach(this); + } + + if (widget.isImeConnected != oldWidget.isImeConnected) { + oldWidget.isImeConnected.removeListener(_onImeConnectionChange); + widget.isImeConnected.addListener(_onImeConnectionChange); + } + + if (widget.fallbackPanelHeight != oldWidget.fallbackPanelHeight) { + _updateMaxPanelHeight(); + } + } + + @override + void reassemble() { + super.reassemble(); + + // In case we made a code change during development that impacts the + // visibility of the toolbar. Re-calculate the ancestor keyboard safe area. + _updateSafeArea(); + } + + @override + void dispose() { + _ancestorSafeArea?.geometry = const KeyboardSafeAreaGeometry(); + + SuperKeyboard.instance.mobileGeometry.removeListener(_onKeyboardGeometryChange); + + widget.isImeConnected.removeListener(_onImeConnectionChange); + + widget.controller.detach(); + + _panelHeightController.removeListener(_onPanelHeightChange); + _panelHeightController.dispose(); + + if (_overlayPortalController.isShowing) { + // WARNING: We can only call `hide()` if `isShowing` is `true`. If we blindly + // call `hide()` then we'll get a z-index error reported. Flutter should clean + // that up internally, but until then (written Oct 14, 2024) we guard it here. + _overlayPortalController.hide(); + } + + _listeners.clear(); + + super.dispose(); + } + + void _onKeyboardGeometryChange() { + // Note: The following post frame callback shouldn't be necessary. + // We should be able to look up our ancestor MediaQuery immediately. + // However, it was found when writing tests that at the end of a test + // the order in which Flutter disposes of widgets was resulting in an + // attempt to access a disposed MediaQuery. I think this is probably a + // bug in Flutter somewhere. To work around it, we do the update at the + // end of the current frame, and we check that we're still mounted. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + + _updateKeyboardHeightForCurrentViewInsets(); + }); + } + + void _updateMaxPanelHeight() { + _panelHeight = Tween( + begin: 0.0, + end: _bestGuessMaxKeyboardHeight > 100 ? _bestGuessMaxKeyboardHeight : widget.fallbackPanelHeight, + ) // + .chain(CurveTween(curve: Curves.easeInOut)) + .animate(_panelHeightController); + } + + void _onPanelAnimationChange(AnimationStatus status) { + if (!mounted) { + // Should never happen because the ticker is tied to the widget tree, + // but this is defensive for situations we haven't considered. + return; + } + + if (status == AnimationStatus.dismissed) { + setState(() { + _activePanel = null; + }); + } + } + + void _onPanelHeightChange() { + _updateSafeArea(); + _currentBottomSpacing.value = max(_panelHeight.value, _currentKeyboardHeight); + } + + @override + void onAttached(SoftwareKeyboardController softwareKeyboardController) { + _softwareKeyboardController = softwareKeyboardController; + } + + @override + void onDetached() { + _softwareKeyboardController = null; + } + + void _onImeConnectionChange() { + final isImeConnected = widget.isImeConnected.value; + if (isImeConnected) { + setState(() { + // Rebuild because we may need to show the toolbar now that the IME + // is connected. + }); + + return; + } + + // The IME isn't connected. Ensure the panel is closed. + widget.controller.closeKeyboardAndPanel(); + } + + /// Whether the toolbar should be displayed, anchored to the top of the keyboard area. + @override + KeyboardToolbarVisibility get toolbarVisibility => _toolbarVisibility; + KeyboardToolbarVisibility _toolbarVisibility = KeyboardToolbarVisibility.auto; + @override + set toolbarVisibility(KeyboardToolbarVisibility value) { + if (value == _toolbarVisibility) { + return; + } + + _toolbarVisibility = value; + switch (value) { + case KeyboardToolbarVisibility.visible: + showToolbar(); + case KeyboardToolbarVisibility.hidden: + hideToolbar(); + case KeyboardToolbarVisibility.auto: + _wantsToShowSoftwareKeyboard || _wantsToShowKeyboardPanel // + ? showToolbar() + : hideToolbar(); + } + } + + bool _isToolbarVisible = false; + + /// Shows the toolbar, if it's hidden, or hides the toolbar, if it's visible. + @override + void toggleToolbar() { + if (_isToolbarVisible) { + hideToolbar(); + } else { + showToolbar(); + } + } + + /// Shows the toolbar that's mounted to the top of the keyboard area. + @override + void showToolbar() { + setState(() { + _toolbarVisibility = KeyboardToolbarVisibility.visible; + _isToolbarVisible = true; + }); + } + + /// Hides the toolbar that's mounted to the top of the keyboard area. + @override + void hideToolbar() { + setState(() { + _toolbarVisibility = KeyboardToolbarVisibility.hidden; + _isToolbarVisible = false; + }); + } + + /// Whether the software keyboard should be displayed, instead of the keyboard panel. + bool get wantsToShowSoftwareKeyboard => _wantsToShowSoftwareKeyboard; + bool _wantsToShowSoftwareKeyboard = false; + + @override + bool get isSoftwareKeyboardOpen => _wantsToShowSoftwareKeyboard; + + /// Shows the software keyboard, if it's hidden. + @override + void showSoftwareKeyboard() { + setState(() { + _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = true; + _softwareKeyboardController!.open(viewId: View.of(context).viewId); + + if (_panelHeightController.value == 1.0 && + SuperKeyboard.instance.mobileGeometry.value.keyboardState != KeyboardState.open) { + // If the user called hideKeyboardPanel() just before calling this method then the panel + // will animate down even though we don't want it to. It's currently still at 100% so we've + // caught it in time to stop it from animating down. + _panelHeightController.stop(); + } + + // Notify delegate listeners. + notifyListeners(); + }); + } + + /// Hides (doesn't close) the software keyboard, if it's open. + @override + void hideSoftwareKeyboard() { + setState(() { + _wantsToShowSoftwareKeyboard = false; + _softwareKeyboardController!.hide(); + + // Notify delegate listeners. + notifyListeners(); + }); + + _maybeAnimatePanelClosed(); + } + + /// Whether a keyboard panel should be displayed instead of the software keyboard. + bool get wantsToShowKeyboardPanel => _wantsToShowKeyboardPanel; + bool _wantsToShowKeyboardPanel = false; + + @override + bool get isKeyboardPanelOpen => _wantsToShowKeyboardPanel; + + @override + PanelType? get openPanel => _activePanel; + + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the + /// software keyboard, if it's open. + @override + void showKeyboardPanel(PanelType panel) { + setState(() { + _wantsToShowKeyboardPanel = true; + _wantsToShowSoftwareKeyboard = false; + _activePanel = panel; + + if (SuperKeyboard.instance.mobileGeometry.value.keyboardState == KeyboardState.open) { + // The keyboard is fully open. We'd like for the panel to immediately + // appear behind the keyboard as it closes, so that we don't have a + // bunch of jumping around for the widgets mounted to the top of the + // keyboard. + _panelHeightController.value = 1.0; + } else { + _panelHeightController.forward(); + } + + _softwareKeyboardController!.hide(); + + // Notify delegate listeners. + notifyListeners(); + }); + } + + /// Hides the keyboard panel, if it's open. + @override + void hideKeyboardPanel() { + setState(() { + // Close panel. + _wantsToShowKeyboardPanel = false; + + if (!_wantsToShowSoftwareKeyboard) { + // We don't want the panel or the keyboard, so animate the panel down. + // The active panel will be null'ed out when the animation is complete. + _panelHeightController.reverse(); + } else { + // We want the keyboard to replace the panel. Wait for keyboard to + // raise before closing the panel. This is handled elsewhere. + } + + _activePanel = null; + _panelHeightController.reverse(); + + // Open the keyboard. + _softwareKeyboardController!.open(viewId: View.of(context).viewId); + + // Notify delegate listeners. + notifyListeners(); + }); + } + + /// Closes the software keyboard if it's open, or closes the keyboard panel if + /// it's open, and fully closes the keyboard (IME) connection. + @override + void closeKeyboardAndPanel() { + setState(() { + _wantsToShowKeyboardPanel = false; + _wantsToShowSoftwareKeyboard = false; + + _softwareKeyboardController!.close(); + + if (_panelHeightController.isDismissed) { + // The height animation is already at zero, so reversing it won't trigger + // the dismissal callback. Therefore, we need to null about the active panel, ourselves. + _activePanel = null; + } else { + // Note: The _activePanel will be null'ed out when the reverse is complete. + _panelHeightController.reverse(); + } + + // Notify delegate listeners. + notifyListeners(); + }); + } + + void _maybeAnimatePanelClosed() { + if (_wantsToShowKeyboardPanel || _wantsToShowSoftwareKeyboard || _currentKeyboardHeight != 0.0) { + return; + } + + // The user wants to close both the software keyboard and the keyboard panel, + // but the software keyboard is already closed. Animate the keyboard panel height + // down to zero. + _panelHeightController.reverse(); + } + + /// Updates our local cache of the current bottom window insets, which we assume reflects + /// the current software keyboard height. + void _updateKeyboardHeightForCurrentViewInsets() { + final newBottomInset = _getCurrentKeyboardHeight(); + + switch (SuperKeyboard.instance.mobileGeometry.value.keyboardState) { + case KeyboardState.open: + if (newBottomInset >= _bestGuessMaxKeyboardHeight) { + // Note: On iOS "open" doesn't necessarily mean fully open. I've found + // that rapidly opening and closing the keyboard results in an "open" + // message despite the fact that the keyboard didn't make it all the + // way up. + _bestGuessMaxKeyboardHeight = newBottomInset; + widget.controller.debugBestGuessKeyboardHeight.value = _bestGuessMaxKeyboardHeight; + } + + if (_wantsToShowSoftwareKeyboard) { + // Now that the keyboard is fully open, and we want to show the keyboard, + // ensure that any previously visible panel is gone. We only want to do + // this if the keyboard fully opens. Otherwise, this state probably + // represents a rapid toggle between the keyboard and a panel. In that case, + // leave the panel alone. + _panelHeightController.value = 0; + _wantsToShowKeyboardPanel = false; + _activePanel = null; + } + + _updateMaxPanelHeight(); + + // Notify delegate listeners. + notifyListeners(); + + break; + case KeyboardState.closed: + if (!wantsToShowKeyboardPanel) { + // Now that the keyboard is fully closed, and we don't want a panel, close the panel + // in case it happens to be open. + _panelHeightController.reverse(); + } + + // It was found on the iPad simulator that it was possible to close the minimized keyboard, + // receive a message that the keyboard was closed, but still have bottom insets that reported + // the height of the minimized keyboard. To hack around that, we explicitly set the keyboard + // height to zero, when closed. + if (newBottomInset > 0) { + _currentKeyboardHeight = 0.0; + onNextFrame((_) => _updateSafeArea()); + break; + } + + if (newBottomInset != _currentKeyboardHeight) { + // Update the safe area to account for the new height value. + onNextFrame((_) => _updateSafeArea()); + } + break; + case KeyboardState.opening: + // The keyboard is changing size. Update our safe area. + onNextFrame((_) => _updateSafeArea()); + break; + case KeyboardState.closing: + if (!wantsToShowKeyboardPanel && !wantsToShowSoftwareKeyboard) { + // The keyboard is collapsing and we don't want the keyboard panel to be visible. + // Follow the keyboard back down. + // + // Note: A given platform may not report changing keyboard heights while closing. + // For example, at the time of writing this, iOS doesn't report keyboard height + // when opening and closing. In that case, this controller will remain at `1.0` + // until the keyboard is fully closed. + _panelHeightController + ..stop() + ..value = newBottomInset / _bestGuessMaxKeyboardHeight; + } + + // The keyboard is changing size. Update our safe area. + onNextFrame((_) => _updateSafeArea()); + break; + case null: + // no-op + } + + _currentKeyboardHeight = newBottomInset; + _currentBottomSpacing.value = max(_panelHeight.value, _currentKeyboardHeight); + + setState(() { + // Re-build with the various property changes we made above. + }); + } + + final _listeners = {}; + + @override + bool get hasListeners => _listeners.isNotEmpty; + + @override + void addListener(VoidCallback listener) => _listeners.add(listener); + + @override + void removeListener(VoidCallback listener) => _listeners.remove(listener); + + @override + void notifyListeners() { + for (final listener in _listeners) { + listener(); + } + } + + /// Update the bottom insets of the enclosing [KeyboardScaffoldSafeArea]. + void _updateSafeArea() { + if (_ancestorSafeArea == null) { + return; + } + + final bottomPadding = _wantsToShowKeyboardPanel // + ? 0.0 + : _wantsToShowSoftwareKeyboard // + ? 0.0 + : _getCurrentBottomPadding(); + + final toolbarSize = (_toolbarKey.currentContext?.findRenderObject() as RenderBox?)?.size; + final bottomInsets = _currentBottomSpacing.value + (toolbarSize?.height ?? 0); + + _ancestorSafeArea!.geometry = _ancestorSafeArea!.geometry.copyWith( + bottomInsets: bottomInsets, + bottomPadding: bottomPadding, + ); + } + + double _getCurrentKeyboardHeight() { + if (widget.bypassMediaQuery) { + return SuperKeyboard.instance.mobileGeometry.value.keyboardHeight ?? MediaQuery.viewInsetsOf(context).bottom; + } + + // Note: One reason that it's still important to use MediaQuery bottom insets on iOS instead + // of deferring to SuperKeyboard is that, at the time of writing this (May, 2025), SuperKeyboard + // hasn't implemented any heuristics to estimate keyboard transition insets, nor does iOS report + // those insets. + // TODO: We should lookup how Flutter's MediaQuery estimates the keyboard insets when the keyboard + // is closing, and replicate that in SuperKeyboard so we can defer to SuperKeyboard all the time. + return MediaQuery.viewInsetsOf(context).bottom; + } + + double _getCurrentBottomPadding() { + if (widget.bypassMediaQuery) { + return SuperKeyboard.instance.mobileGeometry.value.bottomPadding ?? MediaQuery.paddingOf(context).bottom; + } + + return MediaQuery.paddingOf(context).bottom; + } + + @override + Widget build(BuildContext context) { + final shouldShowKeyboardPanel = wantsToShowKeyboardPanel || + // If the panel height is greater than zero, we're probably animating it away. + // Show it until the animation is done. + _panelHeightController.value > 0 || + // The keyboard panel should be kept visible while the software keyboard is expanding + // and the keyboard panel was previously visible. Otherwise, there will be an empty + // region between the top of the software keyboard and the bottom of the above-keyboard panel. + (wantsToShowSoftwareKeyboard && + SuperKeyboard.instance.mobileGeometry.value.keyboardState != KeyboardState.open); + + assert(() { + keyboardPanelLog.fine(''' +Building keyboard scaffold + - keyboard state: ${SuperKeyboard.instance.mobileGeometry.value.keyboardState} + - wants to show toolbar? $_wantsToShowToolbar + - wants to show software keyboard? $wantsToShowSoftwareKeyboard + - best-guess keyboard height: $_bestGuessMaxKeyboardHeight + - current keyboard height: $_currentKeyboardHeight + - wants to show keyboard panel? $wantsToShowKeyboardPanel + - should show keyboard panel? $shouldShowKeyboardPanel + - active panel: $_activePanel + - current panel animation progress: ${_panelHeightController.value}, animation height: ${_panelHeight.value} + - current bottom spacing: ${_currentBottomSpacing.value}'''); + + return true; + }()); + + return OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: (context) { + return ValueListenableBuilder( + valueListenable: _currentBottomSpacing, + builder: (context, currentHeight, child) { + onNextFrame((_) { + // Ensure that our latest keyboard height/panel height calculations are + // accounted for in the ancestor safe area after this layout pass. + _updateSafeArea(); + }); + + if (!_wantsToShowToolbar && !shouldShowKeyboardPanel) { + return const SizedBox.shrink(); + } + + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_wantsToShowToolbar) + KeyedSubtree( + key: _toolbarKey, + child: widget.toolbarBuilder( + context, + _activePanel, + ), + ), + // Spacer that pushes the toolbar up above the current bottom spacing, + // whether that's the software keyboard, or a panel. + AnimatedBuilder( + animation: _currentBottomSpacing, + builder: (context, child) { + return SizedBox( + height: _currentBottomSpacing.value, + child: child, + ); + }, + // In the case that we want to display a panel, display it here, + // in the current bottom space below the toolbar. + child: shouldShowKeyboardPanel ? widget.keyboardPanelBuilder(context, _activePanel) : null, + ), + ], + ), + ); + }, + ); + }, + child: widget.contentBuilder( + context, + _activePanel, + ), + ); + } +} + +/// Shows and hides the keyboard panel and software keyboard. +class KeyboardPanelController { + KeyboardPanelController( + this.softwareKeyboardController, + ); + + void dispose() { + detach(); + } + + final SoftwareKeyboardController softwareKeyboardController; + + KeyboardPanelScaffoldDelegate? _delegate; + + /// Whether this controller is currently attached to a delegate that + /// knows how to show a toolbar, and open/close the software keyboard + /// and keyboard panel. + bool get hasDelegate => _delegate != null; + + /// Attaches this controller to a delegate that knows how to show a toolbar, open and + /// close the software keyboard, and the keyboard panel. + void attach(KeyboardPanelScaffoldDelegate delegate) { + editorImeLog.finer("[KeyboardPanelController] - Attaching to delegate: $delegate"); + _delegate = delegate; + _delegate!.onAttached(softwareKeyboardController); + } + + /// Detaches this controller from its delegate. + /// + /// This controller can't open or close the software keyboard, or keyboard panel, while + /// detached from a delegate that knows how to make that happen. + void detach() { + editorImeLog.finer("[KeyboardPanelController] - Detaching from delegate: $_delegate"); + _delegate?.onDetached(); + _delegate = null; + } + + /// Whether the toolbar should be displayed, anchored to the top of the keyboard area. + KeyboardToolbarVisibility get toolbarVisibility => _delegate?.toolbarVisibility ?? KeyboardToolbarVisibility.hidden; + set toolbarVisibility(KeyboardToolbarVisibility value) => _delegate?.toolbarVisibility = value; + + /// Shows the toolbar, if it's hidden, or hides the toolbar, if it's visible. + void toggleToolbar() => _delegate?.toggleToolbar(); + + /// Shows the toolbar that's mounted to the top of the keyboard area. + void showToolbar() => _delegate?.showToolbar(); + + /// Hides the toolbar that's mounted to the top of the keyboard area. + void hideToolbar() => _delegate?.hideToolbar(); + + /// Whether the delegate currently wants a keyboard panel to be open. + /// + /// This is expressed as "want" because the keyboard panel has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isSoftwareKeyboardOpen => _delegate?.isKeyboardPanelOpen ?? false; + + /// Shows the software keyboard, if it's hidden. + void showSoftwareKeyboard() { + _delegate?.showSoftwareKeyboard(); + } + + /// Hides (doesn't close) the software keyboard, if it's open. + void hideSoftwareKeyboard() { + _delegate?.hideSoftwareKeyboard(); + } + + /// Whether the delegate currently wants a keyboard panel to be open. + /// + /// This is expressed as "want" because the keyboard panel has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isKeyboardPanelOpen => _delegate?.isKeyboardPanelOpen ?? false; + + PanelType? get openPanel => _delegate?.openPanel; + + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the + /// software keyboard, if it's open. + void showKeyboardPanel(PanelType panel) => _delegate?.showKeyboardPanel(panel); + + /// Hides the keyboard panel, if it's open. + void hideKeyboardPanel() { + _delegate?.hideKeyboardPanel(); + } + + /// Closes the software keyboard if it's open, or closes the keyboard panel if + /// it's open, and fully closes the keyboard (IME) connection. + void closeKeyboardAndPanel() { + _delegate?.closeKeyboardAndPanel(); + } + + /// The height that we believe the keyboard occupies. + /// + /// This is a debug value and should only be used for logging. + final debugBestGuessKeyboardHeight = ValueNotifier(null); +} + +abstract interface class KeyboardPanelScaffoldDelegate implements ChangeNotifier { + /// Called on this delegate by the [KeyboardPanelController] when the controller + /// attaches to the delegate. + /// + /// [onAttached] is used to pass critical dependencies from the controller to + /// the delegate. + void onAttached(SoftwareKeyboardController softwareKeyboardController); + + /// Called on this delegate by the [KeyboardPanelController] when the controller + /// detaches from the delegate. + /// + /// Implementers should release any resources created/stored in [onAttached]. + void onDetached(); + + /// The visibility policy for the toolbar that's docked to the top of the software keyboard. + KeyboardToolbarVisibility get toolbarVisibility; + set toolbarVisibility(KeyboardToolbarVisibility value); + + /// Shows the toolbar, if it's hidden, or hides the toolbar, if it's visible. + void toggleToolbar(); + + /// Shows the toolbar that's mounted to the top of the keyboard area. + void showToolbar(); + + /// Hides the toolbar that's mounted to the top of the keyboard area. + void hideToolbar(); + + /// Whether this delegate currently wants the software keyboard to be open. + /// + /// This is expressed as "want" because the keyboard has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isSoftwareKeyboardOpen; + + /// Shows the software keyboard, if it's hidden. + void showSoftwareKeyboard(); + + /// Hides (doesn't close) the software keyboard, if it's open. + void hideSoftwareKeyboard(); + + /// Whether this delegate currently wants a keyboard panel to be open. + /// + /// This is expressed as "want" because the keyboard panel has transitory states, + /// like opening and closing. Therefore, this property doesn't reflect actual + /// visibility. + bool get isKeyboardPanelOpen; + + PanelType? get openPanel; + + /// Shows the keyboard panel, if it's closed, and hides (doesn't close) the + /// software keyboard, if it's open. + void showKeyboardPanel(PanelType panel); + + /// Hides the keyboard panel, if it's open. + void hideKeyboardPanel(); + + /// Closes the software keyboard if it's open, or closes the keyboard panel if + /// it's open, and fully closes the keyboard (IME) connection. + void closeKeyboardAndPanel(); +} + +enum KeyboardToolbarVisibility { + /// The toolbar should be hidden. + hidden, + + /// The toolbar should be visible. + visible, + + /// The toolbar should be visible only when the software keyboard is open, + /// or the keyboard panel is open. + auto, +} + +/// Stores and provides keyboard scaffold safe area info to its subtree, which can +/// coordinate safe areas between different branches of the subtree. +/// +/// You can think of this widget like a [KeyboardScaffoldSafeArea] that doesn't +/// apply any insets - this widget only stores insets and publishes them to descendants. +/// +/// ### Example +/// A screen with bottom mounted navigation tabs, a conversation list, and a +/// bottom mounted message editor. +/// +/// ``` +/// App +/// |-- Column +/// |-- Stack +/// |-- Chat message list +/// |-- Bottom mounted message editor +/// |-- Bottom nav tabs +/// ``` +/// +/// When the message editor opens a panel, e.g., emoji panel, the chat message list +/// needs to add space equal to the height of the panel. This is done by wrapping the +/// chat message list with a [KeyboardScaffoldSafeArea]. However, the safe area is set +/// by the bottom mounted message editor, which sits in a different subtree. +/// +/// One might try to solve this problem as follows: +/// +/// ``` +/// (DON'T DO THIS) +/// App +/// |-- KeyboardScaffoldSafeArea +/// |-- Column +/// |-- Stack +/// |-- KeyboardScaffoldSafeArea +/// |-- Chat message list +/// |-- KeyboardScaffoldSafeArea +/// |-- Bottom mounted message editor +/// |-- Bottom name tabs +/// ``` +/// +/// This approach successfully shares safe area knowledge between the bottom +/// mounted editor, and the chat message list. HOWEVER, it also pushes the bottom +/// name tabs up above the keyboard, too. But this isn't desired. The tabs should +/// stay at the bottom of the screen. +/// +/// Instead, use a [KeyboardScaffoldSafeAreaScope] to share the inset information +/// without applying it. +/// +/// ``` +/// (CORRECT) +/// App +/// |-- KeyboardScaffoldSafeAreaScope +/// |-- Column +/// |-- Stack +/// |-- KeyboardScaffoldSafeArea +/// |-- Chat message list +/// |-- KeyboardScaffoldSafeArea +/// |-- Bottom mounted message editor +/// |-- Bottom name tabs +/// ``` +class KeyboardScaffoldSafeAreaScope extends StatefulWidget { + static KeyboardScaffoldSafeAreaMutator of(BuildContext context) { + return maybeOf(context)!; + } + + static KeyboardScaffoldSafeAreaMutator? maybeOf(BuildContext context) { + context.dependOnInheritedWidgetOfExactType<_InheritedKeyboardScaffoldSafeArea>(); + return context.findAncestorStateOfType<_KeyboardScaffoldSafeAreaScopeState>(); + } + + const KeyboardScaffoldSafeAreaScope({ + super.key, + this.debugLabel = "UNNAMED", + this.bypassMediaQuery = false, + required this.child, + }); + + /// A label associated with this widget that can be helpful when debugging + /// unexpected safe areas throughout a scope. + final String debugLabel; + + /// When determining the height of the keyboard, whether to bypass Flutter's `MediaQuery` + /// and solely rely on `SuperKeyboard` reporting, or whether the scaffold should use a + /// combination of both. + /// + /// This option was added after a client app had an Android lifecycle bug, which caused + /// Flutter's `MediaQuery` to report the wrong bottom insets. Apps should start with this + /// value as `false` and only change it to `true` if the app runs into problems with + /// `MediaQuery`. + final bool bypassMediaQuery; + + final Widget child; + + @override + State createState() => _KeyboardScaffoldSafeAreaScopeState(); +} + +class _KeyboardScaffoldSafeAreaScopeState extends State + implements KeyboardScaffoldSafeAreaMutator { + KeyboardSafeAreaGeometry? _keyboardSafeAreaData; + + KeyboardScaffoldSafeAreaMutator? _ancestorSafeArea; + bool _isSafeAreaFromMediaQuery = false; + bool _isSafeAreaFromAncestor = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // Initialize the keyboard insets and padding. + // + // First, it's possible that this safe area scope sits beneath another safe area. In that + // case, we defer to the ancestor safe area. This makes it possible to create a keyboard + // safe area in one subtree, and communicate that safe area to another subtree, by + // sharing an ancestor. + // + // Second, if there's no existing ancestor KeyboardScaffoldSafeArea, then defer to whatever + // MediaQuery reports. We only do this for the very first frame because we don't yet + // know what our values should be (because that's reported by descendants in the tree). + _ancestorSafeArea = KeyboardScaffoldSafeAreaScope.maybeOf(context); + + if (_keyboardSafeAreaData == null) { + // This is the first call to didChangeDependencies. Initialize our safe area. + _keyboardSafeAreaData = KeyboardSafeAreaGeometry( + bottomInsets: _ancestorSafeArea?.geometry.bottomInsets ?? MediaQuery.viewInsetsOf(context).bottom, + bottomPadding: _ancestorSafeArea?.geometry.bottomPadding ?? MediaQuery.paddingOf(context).bottom, + ); + + // We track whether our safe area is from MediaQuery (instead of an another KeyboardSafeAreaGeometry). + // We do this in case the MediaQuery value changes when we don't have any descendant + // KeyboardPanelScaffold. + // + // For example, you're on Screen 1 with the keyboard up. You navigate to Screen 2, which closes the keyboard. When + // Screen 2 first pumps, it sees that the keyboard is up, so it configures a keyboard safe area. But the keyboard + // immediately closes. Screen 2 is then stuck with a keyboard safe area that never goes away. + // + // By tracking when our safe area comes from MediaQuery, we can continue to honor changing + // MediaQuery values until a descendant explicitly sets our `geometry`. + _isSafeAreaFromMediaQuery = _ancestorSafeArea == null; + _isSafeAreaFromAncestor = _ancestorSafeArea != null; + } + + if (_isSafeAreaFromMediaQuery) { + // Our current safe area came from MediaQuery, not a descendant. Therefore, + // we want to continue blindly honoring the MediaQuery. + _keyboardSafeAreaData = KeyboardSafeAreaGeometry( + bottomInsets: _getCurrentKeyboardHeight(), + bottomPadding: _getCurrentBottomPadding(), + ); + } else if (_isSafeAreaFromAncestor) { + if (_ancestorSafeArea != null) { + // Our previous safe area was inherited from an ancestor scope. Those insets + // may have changed. Update our records. + _keyboardSafeAreaData = _ancestorSafeArea!.geometry; + } else { + // Our previous safe area was inherited from an ancestor scope, but now that + // scope is gone. Reset back to the regular MediaQuery safe area. + _keyboardSafeAreaData = KeyboardSafeAreaGeometry( + bottomInsets: _getCurrentKeyboardHeight(), + bottomPadding: _getCurrentBottomPadding(), + ); + _isSafeAreaFromMediaQuery = true; + _isSafeAreaFromAncestor = false; + } + } + } + + @override + KeyboardSafeAreaGeometry get geometry => _keyboardSafeAreaData!; + + @override + set geometry(KeyboardSafeAreaGeometry geometry) { + _isSafeAreaFromMediaQuery = false; + if (geometry == _keyboardSafeAreaData) { + return; + } + + // Propagate this geometry to any ancestor keyboard safe areas. + _ancestorSafeArea?.geometry = geometry; + + setStateAsSoonAsPossible(() { + _keyboardSafeAreaData = geometry; + }); + } + + double _getCurrentKeyboardHeight() { + if (widget.bypassMediaQuery) { + return SuperKeyboard.instance.mobileGeometry.value.keyboardHeight ?? MediaQuery.viewInsetsOf(context).bottom; + } + + return MediaQuery.viewInsetsOf(context).bottom; + } + + double _getCurrentBottomPadding() { + if (widget.bypassMediaQuery) { + return SuperKeyboard.instance.mobileGeometry.value.bottomPadding ?? MediaQuery.paddingOf(context).bottom; + } + + return MediaQuery.paddingOf(context).bottom; + } + + @override + String get debugLabel => widget.debugLabel; + + @override + List get debugLabelPath => [ + if (_ancestorSafeArea != null) // + ..._ancestorSafeArea!.debugLabelPath, + debugLabel, + ]; + + @override + Widget build(BuildContext context) { + if (_ancestorSafeArea != null) { + // An ancestor safe area was already applied to our subtree. + return widget.child; + } + + return _InheritedKeyboardScaffoldSafeArea( + keyboardSafeAreaData: _keyboardSafeAreaData!, + child: widget.child, + ); + } +} + +/// Applies padding to the bottom of the child to avoid the software keyboard and +/// the above-keyboard toolbar. +/// +/// [KeyboardScaffoldSafeArea] is separate from [KeyboardPanelScaffold] because any +/// widget might want to wrap itself with a [KeyboardPanelScaffold], but the +/// [KeyboardScaffoldSafeArea] needs to be added somewhere in the widget tree that +/// controls the size of the whole screen. +/// +/// For example, imagine a social app, like Twitter, that has a text field at the +/// top of the screen to write a post, followed by a social feed below it. The +/// text field would wrap itself with a [KeyboardPanelScaffold] to add a toolbar +/// to the keyboard, but the [KeyboardScaffoldSafeArea] would need to go higher +/// up the widget tree to surround the whole screen. +/// +/// The padding in [KeyboardScaffoldSafeArea] is set by a descendant [KeyboardPanelScaffold] +/// in the widget tree. +class KeyboardScaffoldSafeArea extends StatefulWidget { + static _KeyboardScaffoldSafeAreaState? _maybeOf(BuildContext context) { + context.dependOnInheritedWidgetOfExactType<_InheritedKeyboardScaffoldSafeArea>(); + return context.findAncestorStateOfType<_KeyboardScaffoldSafeAreaState>(); + } + + const KeyboardScaffoldSafeArea({ + super.key, + this.debugLabel = "UNNAMED", + required this.child, + }); + + /// A label associated with this widget that can be helpful when debugging + /// unexpected safe areas throughout a scope. + final String debugLabel; + + final Widget child; + + @override + State createState() => _KeyboardScaffoldSafeAreaState(); +} + +class _KeyboardScaffoldSafeAreaState extends State { + final _myBoxKey = GlobalKey(); + + KeyboardScaffoldSafeAreaMutator? _ancestorSafeAreaScope; + _KeyboardScaffoldSafeAreaState? _ancestorSafeArea; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // We don't care about the MediaQuery, but if there are widgets below + // us that change with the MediaQuery, e.g., a SafeArea with bottom + // padding, then those changes will effect us. We need to re-run our + // build() to re-calculate our bottom spacing. Without adding this + // dependency on MediaQuery, our content can end up ~20px above or + // below where it should be. + MediaQuery.maybeOf(context); + + _ancestorSafeAreaScope = KeyboardScaffoldSafeAreaScope.maybeOf(context); + _ancestorSafeArea = KeyboardScaffoldSafeArea._maybeOf(context); + } + + @override + Widget build(BuildContext context) { + return KeyboardScaffoldSafeAreaScope( + key: _myBoxKey, + debugLabel: "internal scope", + child: Builder(builder: (safeAreaContext) { + if (_ancestorSafeArea != null) { + // An ancestor safe area was already applied to our subtree. + return widget.child; + } + + final bottomInsets = _chooseBottomInsets(safeAreaContext); + return Padding( + padding: EdgeInsets.only(bottom: bottomInsets), + // ^ We inject `bottomInsets` to push content above the keyboard. However, we don't + // inject the `bottomPadding` because that would take away styling opportunities + // from the user. Consider a chat message editor at the bottom of the screen. That + // chat editor should push its content up above the bottom notch, but the chat editor + // itself should still extend its background to the bottom of the screen. If we + // enforce bottom padding here, then the whole chat editor would get pushed up and + // and leave an ugly visual gap between the editor and the bottom of the screen. + child: widget.child, + ); + }), + ); + } + + double _chooseBottomInsets(BuildContext safeAreaContext) { + // There's no ancestor KeyboardScaffoldSafeArea, but there might be an ancestor + // KeyboardScaffoldSafeAreaScope, whose insets we should use. + final inheritedGeometry = _ancestorSafeAreaScope?.geometry; + + // Either use the ancestor geometry, or use our own. + final keyboardSafeArea = inheritedGeometry ?? KeyboardScaffoldSafeAreaScope.of(safeAreaContext).geometry; + + // Get the current keyboard safe area bottom insets, and then adjust that + // value based on our global bottom y-value. When this widget appears at + // the very bottom of the screen, this adjustment will be zero (no change), + // but when this widget sits somewhere above the bottom of the screen, we + // need to account for that extra space between us and the keyboard that's + // coming up from the bottom of the screen. + var bottomInsets = keyboardSafeArea.bottomInsets; + if (_myBoxKey.currentContext != null && _myBoxKey.currentContext!.findRenderObject() != null) { + final myBox = _myBoxKey.currentContext!.findRenderObject() as RenderBox; + + late final double myGlobalBottom; + try { + myGlobalBottom = myBox.localToGlobal(Offset(0, myBox.size.height)).dy; + } catch (exception) { + // It was found in a client app that there can be situations where at + // this moment some render object in the ancestor chain isn't yet laid + // out. This results in an exception. The best we can do is return zero. + if (isLogActive(keyboardPanelLog)) { + keyboardPanelLog.warning( + "KeyboardScaffoldSafeArea (${widget.debugLabel}) - Tried to measure our global bottom offset on the screen but caused an exception, likely due to an ancestor not yet being laid out.\nException: $exception\nStacktrace:\n${StackTrace.current}", + ); + } + return 0; + } + + if (myGlobalBottom.isNaN) { + // We've found in a client app that under some unknown circumstances we get NaN + // from localToGlobal(). We're not sure why. In that case, log a warning and return zero. + keyboardPanelLog.warning( + "KeyboardScaffoldSafeArea (${widget.debugLabel}) - Tried to measure our global bottom offset on the screen but received NaN from localToGlobal(). If you're able to consistently reproduce this problem, please report it to Super Editor with the repro steps.", + ); + return 0; + } + if (myGlobalBottom.isNegative) { + // We haven't seen negative values here, but if we ever did receive one then our + // Padding widget would blow up. Return zero to be base. + keyboardPanelLog.warning( + "KeyboardScaffoldSafeArea (${widget.debugLabel}) - Tried to measure our global bottom offset on the screen but received a negative y-value from localToGlobal(). If you're able to consistently reproduce this problem, please report it to Super Editor with the repro steps.", + ); + return 0; + } + + final screenHeight = MediaQuery.sizeOf(safeAreaContext).height; + if (myGlobalBottom > screenHeight) { + // The content is below the bottom of the screen. This can happen, for example, when a + // page animates in/out, such as a bottom sheet. While the content is at all below the + // bottom of the screen, apply the `bottomInsets` without any adjustment. When the + // content is fully onscreen, we can adjust it with `spaceBelowMe`. + return bottomInsets; + } + + final spaceBelowMe = MediaQuery.sizeOf(safeAreaContext).height - myGlobalBottom; + + // The bottom insets are measured from the bottom of the screen. But we might not + // be sitting at the bottom of the screen. There might be some space beneath us. + // In that case, we don't need to push as far up. Remove the space below us from + // the bottom insets. + bottomInsets = max(bottomInsets - spaceBelowMe, 0); + } else { + // This is our first widget build and we need to adjust our insets + // after initial layout. + // + // Note: We have a frame of lag because our inset spacing is based on other + // layout results. As a result, if the content below us animates a height + // change, such as a widget in a `SafeArea` where bottom `padding` animates + // up/down, our content will jitter as it plays catchup one frame behind. + // + // The only solution I can think of that might truly solve this is to use + // a Leader and Follower in some way. That way positioning occurs as late + // as possible. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + + setState(() { + // Re-run build. + }); + }); + } + + return bottomInsets; + } +} + +abstract interface class KeyboardScaffoldSafeAreaMutator { + KeyboardSafeAreaGeometry get geometry; + set geometry(KeyboardSafeAreaGeometry geometry); + + /// A label for this [KeyboardScaffoldSafeAreaMutator], which might be + /// useful for debugging multiple mutators within a scope. + String get debugLabel; + + /// A path of [debugLabel]s, beginning with the highest level ancestor, + /// and ending with this mutator's [debugLabel]. + /// + /// This path is useful when debugging a scope with multiple safe areas. + List get debugLabelPath; +} + +class _InheritedKeyboardScaffoldSafeArea extends InheritedWidget { + const _InheritedKeyboardScaffoldSafeArea({ + required this.keyboardSafeAreaData, + required super.child, + }); + + final KeyboardSafeAreaGeometry keyboardSafeAreaData; + + @override + bool updateShouldNotify(covariant _InheritedKeyboardScaffoldSafeArea oldWidget) { + return oldWidget.keyboardSafeAreaData != keyboardSafeAreaData; + } +} + +/// Insets applied by a [KeyboardPanelScaffold] to an ancestor [KeyboardScaffoldSafeArea] +/// to deal with the presence or absence of the software keyboard. +class KeyboardSafeAreaGeometry { + const KeyboardSafeAreaGeometry({ + this.bottomInsets = 0, + this.bottomPadding = 0, + }); + + /// The space taken up by the keyboard or a keyboard panel. + final double bottomInsets; + + /// The space taken up by the bottom notch of the OS, but only when the IME + /// connection is closed. + /// + /// This property is our version of `MediaQuery.paddingOf(context).bottom`. + /// The standard `MediaQuery` value can't be used because the rules for when + /// to apply the bottom padding is different in an app that shows keyboard + /// panels. + /// + /// There are 3 possible visual states that are relevant to the bottom notch + /// padding: + /// + /// 1. Regular UI - no keyboard visible, no keyboard panel visible. + /// 2. Keyboard open. + /// 3. Keyboard panel open (keyboard closed). + /// + /// When displaying regular UI (#1), content should be pushed up above the + /// bottom notch so that it's clearly visible, and interactable. `SafeArea` + /// does this for you by default. But we can't use `SafeArea` because the + /// rules for when to apply bottom notch padding is different when showing + /// keyboard panels. Therefore, users of this scaffold must apply this + /// padding themselves. This property follows the rules needed for expected + /// behavior when showing keyboard panels. Use this property instead of + /// `SafeArea` and instead of `MediaQuery.paddingOf(context).bottom`. + /// + /// When displaying the keyboard (#2), the OS consumes its own notch height, + /// so no additional padding is needed. If you push above the keyboard, then + /// you automatically push above the notch. `SafeArea` does this automatically + /// but we can't use `SafeArea` because the padding rules for the keyboard + /// panel are different. + /// + /// The major difference we need to handle is when a keyboard panel is open (#3). + /// This is the situation that Flutter doesn't handle correctly, because Flutter + /// doesn't have a concept of keyboard panels. In the case of a keyboard panel, + /// the keyboard is closed, but we don't want to push the content up above the + /// notch. This is because the keyboard panel, itself, covers the notch. It's + /// the same situation as when the keyboard is up, except the keyboard is closed + /// and a keyboard panel is up. In this situation, we want bottom padding to + /// be zero, instead of bottom padding that pushes above the notch. + /// + /// By blindly applying this padding to your content, you will get the desired + /// bottom padding at the relevant time. + final double bottomPadding; + + @override + String toString() => "[KeyboardSafeAreaGeometry] - bottom insets: $bottomInsets, bottom padding: $bottomPadding"; + + KeyboardSafeAreaGeometry copyWith({ + double? bottomInsets, + double? bottomPadding, + }) { + return KeyboardSafeAreaGeometry( + bottomInsets: bottomInsets ?? this.bottomInsets, + bottomPadding: bottomPadding ?? this.bottomPadding, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is KeyboardSafeAreaGeometry && + runtimeType == other.runtimeType && + bottomInsets == other.bottomInsets && + bottomPadding == other.bottomPadding; + + @override + int get hashCode => bottomInsets.hashCode ^ bottomPadding.hashCode; +} diff --git a/super_editor/lib/src/infrastructure/links.dart b/super_editor/lib/src/infrastructure/links.dart new file mode 100644 index 0000000000..7ccc4069dc --- /dev/null +++ b/super_editor/lib/src/infrastructure/links.dart @@ -0,0 +1,21 @@ +import 'package:flutter/cupertino.dart'; +import 'package:url_launcher/url_launcher.dart' as url_plugin; + +/// Launches URLs, with support for testing overrides. +class UrlLauncher { + static UrlLauncher get instance { + _instance ??= UrlLauncher(); + return _instance!; + } + + static UrlLauncher? _instance; + + @visibleForTesting + static set instance(UrlLauncher? newInstance) { + _instance = newInstance ?? UrlLauncher(); + } + + Future launchUrl(Uri url) { + return url_plugin.launchUrl(url); + } +} diff --git a/super_editor/lib/src/infrastructure/_listenable_builder.dart b/super_editor/lib/src/infrastructure/multi_listenable_builder.dart similarity index 72% rename from super_editor/lib/src/infrastructure/_listenable_builder.dart rename to super_editor/lib/src/infrastructure/multi_listenable_builder.dart index 5368522b16..ef88a9ba6c 100644 --- a/super_editor/lib/src/infrastructure/_listenable_builder.dart +++ b/super_editor/lib/src/infrastructure/multi_listenable_builder.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/widgets.dart' hide ListenableBuilder; /// Builder that runs every time one of the given [listenables] changes. class MultiListenableBuilder extends StatefulWidget { @@ -12,7 +12,7 @@ class MultiListenableBuilder extends StatefulWidget { final WidgetBuilder builder; @override - _MultiListenableBuilderState createState() => _MultiListenableBuilderState(); + State createState() => _MultiListenableBuilderState(); } class _MultiListenableBuilderState extends State { @@ -66,24 +66,3 @@ class _MultiListenableBuilderState extends State { return widget.builder(context); } } - -/// Widget that rebuilds its `builder` every time the given -/// `listenable` changes. -class ListenableBuilder extends StatelessWidget { - const ListenableBuilder({ - Key? key, - required this.listenable, - required this.builder, - }) : super(key: key); - - final Listenable listenable; - final WidgetBuilder builder; - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: listenable, - builder: (context, _) => builder(context), - ); - } -} diff --git a/super_editor/lib/src/infrastructure/multi_tap_gesture.dart b/super_editor/lib/src/infrastructure/multi_tap_gesture.dart index fa9f587473..42446edc7f 100644 --- a/super_editor/lib/src/infrastructure/multi_tap_gesture.dart +++ b/super_editor/lib/src/infrastructure/multi_tap_gesture.dart @@ -24,7 +24,7 @@ class TapSequenceGestureRecognizer extends GestureRecognizer { TapSequenceGestureRecognizer({ Object? debugOwner, Set? supportedDevices, - final this.reportPrecedingGestures = true, + this.reportPrecedingGestures = true, }) : super(debugOwner: debugOwner, supportedDevices: supportedDevices); /// If `true`, reports the gestures that lead up to the final @@ -153,6 +153,7 @@ class TapSequenceGestureRecognizer extends GestureRecognizer { void _handleEvent(PointerEvent event) { final _TapTracker tracker = _trackers[event.pointer]!; if (event is PointerUpEvent) { + // print("_handleEvent() - pointer up - _firstTap: $_firstTap, _secondTap: $_secondTap"); if (_firstTap == null) { _registerFirstTap(event, tracker); } else if (_secondTap == null) { @@ -179,7 +180,7 @@ class TapSequenceGestureRecognizer extends GestureRecognizer { if (tracker == null && _firstTap != null && _firstTap!.pointer == pointer) { tracker = _firstTap; } - // If tracker is still null, check if this is the first tap tracker + // If tracker is still null, check if this is the second tap tracker if (tracker == null && _secondTap != null && _secondTap!.pointer == pointer) { tracker = _secondTap; } @@ -190,17 +191,46 @@ class TapSequenceGestureRecognizer extends GestureRecognizer { } void _reject(_TapTracker tracker) { + if (!_trackers.containsKey(tracker.pointer)) { + return; + } + _trackers.remove(tracker.pointer); tracker.entry.resolve(GestureDisposition.rejected); _freezeTracker(tracker); + + if (_firstTap == null && _firstTapDownDetails != null) { + // The user tapped down and then another recognizer won the arena. For example, in an app with both a + // TapSequenceGestureRecognizer and a HorizontalDragGestureRecognizer, when the user taps down and + // then drags horizontally, the onTapDown event is fired, and after that the HorizontalDragGestureRecognizer + // declares itself as the winner. Invoke onTapCancel to cancel the gesture. + _notifyListenersOfCancellation(); + if (_trackers.isEmpty) { + _reset(); + } + return; + } + + if (tracker == _secondTap) { + // A double tap was registered and we were defeated on the gesture arena after that. Reset + // to clean up the tap trackers. + _reset(); + return; + } + + if (tracker == _firstTap) { + // A tap up was registered and we were defeated on the gesture arena after that. Reset + // to clean up the tap trackers. + _reset(); + return; + } + if (_firstTap != null || _secondTap != null) { - if (tracker == _firstTap || tracker == _secondTap) { + // We have a single or double tap registered, but the tracker isn't related to any of them. + // It's not clear what this situation means. + _notifyListenersOfCancellation(); + if (_trackers.isEmpty) { _reset(); - } else { - _checkCancel(); - if (_trackers.isEmpty) { - _reset(); - } } } } @@ -249,7 +279,7 @@ class TapSequenceGestureRecognizer extends GestureRecognizer { _stopTapTimer(); if (_secondTap != null) { if (_trackers.isNotEmpty) { - _checkCancel(); + _notifyListenersOfCancellation(); } // Note, order is important below in order for the resolve -> reject logic // to work properly. @@ -260,7 +290,7 @@ class TapSequenceGestureRecognizer extends GestureRecognizer { } if (_firstTap != null) { if (_trackers.isNotEmpty) { - _checkCancel(); + _notifyListenersOfCancellation(); } // Note, order is important below in order for the resolve -> reject logic // to work properly. @@ -270,6 +300,8 @@ class TapSequenceGestureRecognizer extends GestureRecognizer { GestureBinding.instance.gestureArena.release(tracker.pointer); } _clearTrackers(); + + _firstTapDownDetails = null; } void _registerFirstTap(PointerEvent event, _TapTracker tracker) { @@ -366,15 +398,26 @@ class TapSequenceGestureRecognizer extends GestureRecognizer { } } - void _checkCancel() { - if (_firstTap == null && onTapCancel != null) { - invokeCallback('onTapCancel', onTapCancel!); + void _notifyListenersOfCancellation() { + if (_secondTap != null) { + if (onTripleTapCancel != null) { + invokeCallback('onTripleTapCancel', onTripleTapCancel!); + } + return; } - if (_firstTap != null && _secondTap == null && onDoubleTapCancel != null) { - invokeCallback('onDoubleTapCancel', onDoubleTapCancel!); + + if (_firstTap != null) { + if (onDoubleTapCancel != null) { + invokeCallback('onDoubleTapCancel', onDoubleTapCancel!); + } + return; } - if (_secondTap != null && onTripleTapCancel != null) { - invokeCallback('onTripleTapCancel', onTripleTapCancel!); + + if (_firstTapDownDetails != null) { + if (onTapCancel != null) { + invokeCallback('onTapCancel', onTapCancel!); + } + return; } } diff --git a/super_editor/lib/src/infrastructure/pausable_value_notifier.dart b/super_editor/lib/src/infrastructure/pausable_value_notifier.dart new file mode 100644 index 0000000000..3d3871a6f7 --- /dev/null +++ b/super_editor/lib/src/infrastructure/pausable_value_notifier.dart @@ -0,0 +1,51 @@ +import 'package:flutter/foundation.dart'; + +/// A [ValueNotifier], which allows clients to pause new value notifications. +/// +/// When paused, the [value] property returns whatever the value was when this +/// [PausableValueNotifier] was paused, regardless of the latest values that was +/// set on this [PausableValueNotifier]. +/// +/// Pausing notifications is useful when a series of changes might occur in +/// rapid succession, and only the final value is relevant to listeners. +/// +/// For example, consider a user's selection in an editor. A single user interaction +/// might result in any number of commands and reactions executing, which might alter +/// the user's selection multiple times within a single frame of execution. None of +/// these intermediate selection values are relevant to the rest of the editor. In fact, +/// if the rest of the editor was notified of these transient selection values, then the +/// rest of the editor might do things that it shouldn't do, and cause the editor to +/// enter an inconsistent state. +/// +/// Instead, a [PausableValueNotifier] lets the editor pipeline disable notifications +/// as it runs the pipeline, and then re-enable notifications when all commands and +/// reactions are done executing. +class PausableValueNotifier extends ValueNotifier { + PausableValueNotifier(T value) : super(value); + + bool _isPaused = false; + + late T _currentValueDuringPause; + + @override + T get value => _isPaused ? _currentValueDuringPause : super.value; + + @override + set value(T newValue) { + if (_isPaused) { + _currentValueDuringPause = newValue; + } else { + super.value = newValue; + } + } + + void pauseNotifications() { + _isPaused = true; + _currentValueDuringPause = super.value; + } + + void resumeNotifications() { + _isPaused = false; + super.value = _currentValueDuringPause; + } +} diff --git a/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart b/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart index 30b98c69ad..c82e8b6637 100644 --- a/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart +++ b/super_editor/lib/src/infrastructure/platforms/android/android_document_controls.dart @@ -1,17 +1,483 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/document_gestures_touch_android.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; +import 'package:super_text_layout/super_text_layout.dart'; +/// A document layer that positions a leader widget around the user's selection, +/// as a focal point for an Android-style toolbar display. +/// +/// By default, the toolbar focal point [LeaderLink] is obtained from an ancestor +/// [SuperEditorAndroidControlsScope]. +class AndroidToolbarFocalPointDocumentLayer extends DocumentLayoutLayerStatefulWidget { + const AndroidToolbarFocalPointDocumentLayer({ + Key? key, + required this.document, + required this.selection, + required this.toolbarFocalPointLink, + this.showDebugLeaderBounds = false, + }) : super(key: key); + + /// The editor's [Document], which is used to find the start and end of + /// the user's expanded selection. + final Document document; + + /// The current user's selection within a document. + final ValueListenable selection; + + /// The [LeaderLink], which is attached to the toolbar focal point bounds. + /// + /// By default, this [LeaderLink] is obtained from an ancestor [SuperEditorAndroidControlsScope]. + /// If [toolbarFocalPointLink] is non-null, it's used instead of the ancestor value. + final LeaderLink toolbarFocalPointLink; + + /// Whether to paint colorful bounds around the leader widgets, for debugging purposes. + final bool showDebugLeaderBounds; + + @override + DocumentLayoutLayerState createState() => + _AndroidToolbarFocalPointDocumentLayerState(); +} + +class _AndroidToolbarFocalPointDocumentLayerState + extends DocumentLayoutLayerState { + @override + void initState() { + super.initState(); + + widget.selection.addListener(_onSelectionChange); + } + + @override + void didUpdateWidget(AndroidToolbarFocalPointDocumentLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + } + } + + @override + void dispose() { + widget.selection.removeListener(_onSelectionChange); + + super.dispose(); + } + + void _onSelectionChange() { + // Re-calculate the selection visual bounds by running setState(). + setStateAsSoonAsPossible(() { + // The selection bounds, and Leader build, will take place in methods that + // run in response to setState(). + }); + } + + @override + Rect? computeLayoutDataWithDocumentLayout( + BuildContext contentLayersContext, BuildContext documentContext, DocumentLayout documentLayout) { + final documentSelection = widget.selection.value; + if (documentSelection == null) { + return null; + } + + final selectedComponent = documentLayout.getComponentByNodeId(widget.selection.value!.extent.nodeId); + if (selectedComponent == null) { + // Assume that we're in a momentary transitive state where the document layout + // just gained or lost a component. We expect this method to run again in a moment + // to correct for this. + return null; + } + + return documentLayout.getRectForSelection( + documentSelection.base, + documentSelection.extent, + ); + } + + @override + Widget doBuild(BuildContext context, Rect? expandedSelectionBounds) { + if (expandedSelectionBounds == null) { + return const SizedBox(); + } + + return IgnorePointer( + child: Stack( + children: [ + Positioned.fromRect( + rect: expandedSelectionBounds, + child: Leader( + link: widget.toolbarFocalPointLink, + child: widget.showDebugLeaderBounds + ? DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + width: 4, + color: const Color(0xFFFF00FF), + ), + ), + ) + : null, + ), + ), + ], + ), + ); + } +} + +// TODO: should we de-dup AndroidHandlesDocumentLayer with the iOS version? It's mostly the same. + +/// A document layer that displays an Android-style caret, and positions [Leader]s for the Android +/// collapsed and expanded drag handles. +/// +/// This layer positions and paints the caret directly, rather than using `Leader`s and `Follower`s, +/// because its position is based on the document layout, rather than the user's gesture behavior. +class AndroidHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { + const AndroidHandlesDocumentLayer({ + super.key, + required this.document, + required this.documentLayout, + required this.selection, + required this.changeSelection, + this.caretWidth = 2, + this.caretColor, + this.showDebugPaint = false, + }); + + final Document document; + + final DocumentLayout documentLayout; + + final ValueListenable selection; + + final void Function(DocumentSelection?, SelectionChangeType, String selectionReason) changeSelection; + + final double caretWidth; + + /// Color used to render the Android-style caret (not handles), by default the color + /// is retrieved from the root [SuperEditorAndroidControlsController]. + final Color? caretColor; + + final bool showDebugPaint; + + @override + DocumentLayoutLayerState createState() => + AndroidControlsDocumentLayerState(); +} + +@visibleForTesting +class AndroidControlsDocumentLayerState + extends DocumentLayoutLayerState + with SingleTickerProviderStateMixin { + late BlinkController _caretBlinkController; + + SuperEditorAndroidControlsController? _controlsController; + + DocumentSelection? _previousSelection; + + @override + void initState() { + super.initState(); + _caretBlinkController = BlinkController.withTimer(); + + _previousSelection = widget.selection.value; + widget.selection.addListener(_onSelectionChange); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (_controlsController != null) { + _controlsController!.shouldCaretBlink.removeListener(_onBlinkModeChange); + _controlsController!.caretJumpToOpaqueSignal.removeListener(_caretJumpToOpaque); + _controlsController!.shouldShowCollapsedHandle.removeListener(_onShouldShowCollapsedHandleChange); + _controlsController!.areSelectionHandlesAllowed.removeListener(_onSelectionHandlesAllowedChange); + } + + _controlsController = SuperEditorAndroidControlsScope.rootOf(context); + _controlsController!.shouldCaretBlink.addListener(_onBlinkModeChange); + _controlsController!.caretJumpToOpaqueSignal.addListener(_caretJumpToOpaque); + _controlsController!.areSelectionHandlesAllowed.addListener(_onSelectionHandlesAllowedChange); + + /// Listen for changes about whether we want to show the collapsed handle + /// or whether we want to show expanded handles for a selection. We listen to + /// this because there are some situations where the desired handle type is + /// ambiguous, such as when when the user drags an expanded handle such that + /// the selection collapses. In that case, the selection is collapsed but we want + /// to show the expanded handle. This signal clarifies which one we want. + _controlsController!.shouldShowCollapsedHandle.addListener(_onShouldShowCollapsedHandleChange); + _onBlinkModeChange(); + } + + @override + void didUpdateWidget(AndroidHandlesDocumentLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + } + } + + @override + void dispose() { + widget.selection.removeListener(_onSelectionChange); + _controlsController?.shouldCaretBlink.removeListener(_onBlinkModeChange); + _controlsController!.shouldShowCollapsedHandle.removeListener(_onShouldShowCollapsedHandleChange); + _controlsController!.areSelectionHandlesAllowed.removeListener(_onSelectionHandlesAllowedChange); + + _caretBlinkController.dispose(); + super.dispose(); + } + + @visibleForTesting + Rect? get caret => layoutData?.caret; + + @visibleForTesting + Color get caretColor => widget.caretColor ?? _controlsController?.controlsColor ?? Theme.of(context).primaryColor; + + @visibleForTesting + bool get isCaretDisplayed => layoutData?.caret != null; + + @visibleForTesting + bool get isCaretVisible => _caretBlinkController.opacity == 1.0 && isCaretDisplayed; + + @visibleForTesting + Duration get caretFlashPeriod => _caretBlinkController.flashPeriod; + + @visibleForTesting + bool get isUpstreamHandleDisplayed => layoutData?.upstream != null; + + @visibleForTesting + bool get isDownstreamHandleDisplayed => layoutData?.downstream != null; + + void _onSelectionChange() { + final newSelection = widget.selection.value; + if (newSelection != null && newSelection.isCollapsed) { + // Check for caret movement, because the caret should jump to opaque whenever it moves. + // This can happen when the user taps to move the caret, or when the user presses keyboard + // key sot move the caret. + if (_previousSelection != null && + _previousSelection!.isCollapsed && + !_previousSelection!.extent.isEquivalentTo(newSelection.extent)) { + // The caret moved from one place to another. + _controlsController!.jumpCaretToOpaque(); + } + // Else, the selection went from null to non-null, or from caret to expanded. In these other + // cases, other areas of the system will ensure that the caret jumps to opaque. + } + _previousSelection = newSelection; + + setState(() { + // Schedule a new layout computation because the caret and/or handles need to move. + }); + } + + void _onBlinkModeChange() { + if (_controlsController!.shouldCaretBlink.value) { + _caretBlinkController.startBlinking(); + } else { + _caretBlinkController.stopBlinking(); + } + } + + void _caretJumpToOpaque() { + _caretBlinkController.jumpToOpaque(); + } + + void _onShouldShowCollapsedHandleChange() { + // The controller went from wanting a collapsed handle to wanting expanded handles, + // or vis-a-versa. This signal is relevant to us because of an ambiguous handle situation. + // The user might drag an expanded handle such that the selection is collapsed, in which + // case we still want to show an expanded handle. Similarly, if the user then releases that + // expanded handle, we should switch to a collapsed handle for the same selection. This + // method tells us that the desired handle type has changed. Re-run layout and build to + // ensure that we're showing the correct handle. + setState(() { + // + }); + } + + void _onSelectionHandlesAllowedChange() { + setState(() { + // The controller went from allowing selection handles to disallowing them, or vis-a-versa. + // Rebuild this widget to show/hide the handles. + }); + } + + @override + DocumentSelectionLayout? computeLayoutDataWithDocumentLayout( + BuildContext contentLayersContext, BuildContext documentContext, DocumentLayout documentLayout) { + final selection = widget.selection.value; + if (selection == null) { + return null; + } + + if (!_controlsController!.areSelectionHandlesAllowed.value) { + // We don't want to show any selection handles. + return null; + } + + if (selection.isCollapsed && !_controlsController!.shouldShowExpandedHandles.value) { + Rect caretRect = documentLayout.getEdgeForPosition(selection.extent)!; + + // Default caret width used by the Android caret. + const caretWidth = 2; + + // Use the content's RenderBox instead of the layer's RenderBox to get the layer's width. + // + // ContentLayers works in four steps: + // + // 1. The content is built. + // 2. The content is laid out. + // 3. The layers are built. + // 4. The layers are laid out. + // + // The computeLayoutData method is called during the layer's build, which means that the + // layer's RenderBox is outdated, because it wasn't laid out yet for the current frame. + // Use the content's RenderBox, which was already laid out for the current frame. + final contentBox = documentContext.findRenderObject(); + if (contentBox != null) { + if (contentBox is RenderSliver && contentBox.hasSize && caretRect.left + caretWidth >= contentBox.size.width) { + // Adjust the caret position to make it entirely visible because it's currently placed + // partially or entirely outside of the layers' bounds. This can happen for downstream selections + // of block components that take all the available width. + caretRect = Rect.fromLTWH( + contentBox.size.width - caretWidth, + caretRect.top, + caretRect.width, + caretRect.height, + ); + } else if (contentBox is RenderBox && + contentBox.hasSize && + caretRect.left + caretWidth >= contentBox.size.width) { + // Adjust the caret position to make it entirely visible because it's currently placed + // partially or entirely outside of the layers' bounds. This can happen for downstream selections + // of block components that take all the available width. + caretRect = Rect.fromLTWH( + contentBox.size.width - caretWidth, + caretRect.top, + caretRect.width, + caretRect.height, + ); + } + } + + return DocumentSelectionLayout( + caret: caretRect, + ); + } else { + return DocumentSelectionLayout( + upstream: documentLayout.getRectForPosition( + widget.document.selectUpstreamPosition(selection.base, selection.extent), + )!, + downstream: documentLayout.getRectForPosition( + widget.document.selectDownstreamPosition(selection.base, selection.extent), + )!, + expandedSelectionBounds: documentLayout.getRectForSelection( + selection.base, + selection.extent, + ), + ); + } + } + + @override + Widget doBuild(BuildContext context, DocumentSelectionLayout? layoutData) { + return IgnorePointer( + child: SizedBox.expand( + child: layoutData != null // + ? _buildHandles(layoutData) + : const SizedBox(), + ), + ); + } + + Widget _buildHandles(DocumentSelectionLayout layoutData) { + if (widget.selection.value == null) { + editorGesturesLog.finer("Not building overlay handles because there's no selection."); + return const SizedBox.shrink(); + } + + return Stack( + children: [ + if (layoutData.caret != null) // + _buildCaret(caret: layoutData.caret!), + if (layoutData.upstream != null && layoutData.downstream != null) + ..._buildExpandedHandleLeaders( + upstream: layoutData.upstream!, + downstream: layoutData.downstream!, + ), + ], + ); + } + + Widget _buildCaret({ + required Rect caret, + }) { + return Positioned( + left: caret.left, + top: caret.top, + height: caret.height, + width: widget.caretWidth, + child: Leader( + link: _controlsController!.collapsedHandleFocalPoint, + child: ListenableBuilder( + listenable: _caretBlinkController, + builder: (context, child) { + return ColoredBox( + key: DocumentKeys.caret, + color: caretColor.withValues(alpha: _caretBlinkController.opacity), + ); + }, + ), + ), + ); + } + + List _buildExpandedHandleLeaders({ + required Rect upstream, + required Rect downstream, + }) { + return [ + Positioned.fromRect( + rect: upstream, + child: Leader(link: _controlsController!.upstreamHandleFocalPoint), + ), + Positioned.fromRect( + rect: downstream, + child: Leader(link: _controlsController!.downstreamHandleFocalPoint), + ), + ]; + } +} + +// TODO: Can we get rid of this controller after migrating to compositional approach /// Controls the display of drag handles, a magnifier, and a /// floating toolbar, assuming Android-style behavior for the /// handles. -class AndroidDocumentGestureEditingController extends MagnifierAndToolbarController { +class AndroidDocumentGestureEditingController extends GestureEditingController { AndroidDocumentGestureEditingController({ - required LayerLink documentLayoutLink, - required LayerLink magnifierFocalPointLink, - }) : _documentLayoutLink = documentLayoutLink, - super(magnifierFocalPointLink: magnifierFocalPointLink); + required super.selectionLinks, + required super.magnifierFocalPointLink, + required super.overlayController, + }); @override void dispose() { @@ -19,16 +485,6 @@ class AndroidDocumentGestureEditingController extends MagnifierAndToolbarControl super.dispose(); } - /// Layer link that's aligned to the top-left corner of the document layout. - /// - /// Some of the offsets reported by this controller are based on the - /// document layout coordinate space. Therefore, to honor those offsets on - /// the screen, this `LayerLink` should be used to align the controls with - /// the document layout before applying the offset that sits within the - /// document layout. - LayerLink get documentLayoutLink => _documentLayoutLink; - final LayerLink _documentLayoutLink; - /// Whether or not a caret should be displayed. bool get hasCaret => caretTop != null; @@ -78,8 +534,21 @@ class AndroidDocumentGestureEditingController extends MagnifierAndToolbarControl notifyListeners(); } + void allowHandles() => _allowedToShowHandles = true; + + void disallowHandles() => _allowedToShowHandles = false; + + /// Whether or not the overlay is allowed to show handles, regardless of selection. + /// + /// When this is `false`, the handles should be hidden, even if there's a selection, + /// and the handles have valid visual offsets. + /// + /// When this is `true`, the handles MAY be shown, assuming all other necessary + /// conditions are met, e.g., there's a selection. + bool _allowedToShowHandles = true; + /// Whether a collapsed handle should be displayed. - bool get shouldDisplayCollapsedHandle => _collapsedHandleOffset != null; + bool get shouldDisplayCollapsedHandle => _allowedToShowHandles && _collapsedHandleOffset != null; /// The offset of the collapsed handle focal point, within the coordinate space /// of the document layout, or `null` if no collapsed handle should be displayed. @@ -93,7 +562,8 @@ class AndroidDocumentGestureEditingController extends MagnifierAndToolbarControl } /// Whether the expanded handles (base + extent) should be displayed. - bool get shouldDisplayExpandedHandles => _upstreamHandleOffset != null && _downstreamHandleOffset != null; + bool get shouldDisplayExpandedHandles => + _allowedToShowHandles && _upstreamHandleOffset != null && _downstreamHandleOffset != null; /// The offset of the upstream handle focal point, within the coordinate space /// of the document layout, or `null` if no upstream handle should be displayed. diff --git a/super_editor/lib/src/infrastructure/platforms/android/colors.dart b/super_editor/lib/src/infrastructure/platforms/android/colors.dart new file mode 100644 index 0000000000..44dc0fb487 --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/android/colors.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +const androidToolbarDarkBackgroundColor = Color(0xFF424242); +const androidToolbarLightBackgroundColor = Colors.white; diff --git a/super_editor/lib/src/infrastructure/platforms/android/drag_handle_selection.dart b/super_editor/lib/src/infrastructure/platforms/android/drag_handle_selection.dart new file mode 100644 index 0000000000..791f3e44ad --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/android/drag_handle_selection.dart @@ -0,0 +1,224 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/infrastructure/touch_controls.dart'; + +/// A strategy for selecting text while the user is dragging a drag handle, +/// similar to how the Android OS selects text during a handle drag. +/// +/// The following behaviors are implemented: +/// +/// - When the user drags a downstream handle in downstream direction, +/// the selection expands by word. +/// +/// - When the user drags a downstream handle in upstream direction, +/// the selection expands by character. +/// +/// - When the user drags an upstream handle in upstream direction, +/// the selection expands by word. +/// +/// - When the user drags an upstream handle in downstream direction, +/// the selection expands by character. +/// +/// - When the user drags a collapsed handle, the selection is placed +/// at the drag handle focal point. +class AndroidTextFieldDragHandleSelectionStrategy { + AndroidTextFieldDragHandleSelectionStrategy({ + required Document document, + required DocumentLayout documentLayout, + required void Function(DocumentSelection) select, + }) : _document = document, + _docLayout = documentLayout, + _select = select; + + final Document _document; + final DocumentLayout _docLayout; + final void Function(DocumentSelection) _select; + + DocumentSelection? _lastSelection; + + /// The last position pointed by the drag handle. + DocumentPosition? _lastFocalPosition; + + /// Whether the user is dragging upstream or downstream. + TextAffinity? _currentDragDirection; + + /// The current focal point of the drag handle, in content space. + /// + /// This is the center of the [DocumentPosition] that the drag handle points to. + Offset? _currentFocalPoint; + + /// The drag handle used to start the gesture. + HandleType? _dragHandleType; + + /// The effective drag handle type based on the selection affinity. + /// + /// When the user the starts dragging a handle and causes the selection + /// to invert the affinity, for example, dragging the extent handle until the + /// extent position is upstream of the base position, the downstream handle + /// will behave as if it were the upstream handle, i.e., it will select by word + /// upstream and by character downstream. + HandleType? _effectiveDragHandleType; + + /// Whether the user is selecting by character or by word. + _SelectionModifier? _selectionModifier; + + /// Clients should call this method when a drag handle gesture is initially recognized. + void onHandlePanStart(DragStartDetails details, DocumentSelection initialSelection, HandleType handleType) { + _lastSelection = initialSelection; + + if (handleType == HandleType.collapsed && !_lastSelection!.isCollapsed) { + throw Exception("Tried to drag a collapsed Android handle but the selection is expanded."); + } + if (handleType != HandleType.collapsed && _lastSelection!.isCollapsed) { + throw Exception("Tried to drag an expanded Android handle but the selection is collapsed."); + } + _dragHandleType = handleType; + + final isSelectionDownstream = initialSelection.hasDownstreamAffinity(_document); + late final DocumentPosition selectionBoundPosition; + if (isSelectionDownstream) { + selectionBoundPosition = handleType == HandleType.upstream ? initialSelection.base : initialSelection.extent; + } else { + selectionBoundPosition = handleType == HandleType.upstream ? initialSelection.extent : initialSelection.base; + } + + _currentFocalPoint = _docLayout.getAncestorOffsetFromDocumentOffset( + _docLayout.getRectForPosition(selectionBoundPosition)!.center, + ); + + _dragHandleType = handleType; + _effectiveDragHandleType = _dragHandleType; + _lastFocalPosition = selectionBoundPosition; + } + + /// Clients should call this method when a drag handle gesture is updated. + void onHandlePanUpdate(DragUpdateDetails details) { + _currentFocalPoint = _currentFocalPoint! + details.delta; + + final nearestPosition = _docLayout.getDocumentPositionNearestToOffset( + _docLayout.getDocumentOffsetFromAncestorOffset(_currentFocalPoint!), + ); + if (nearestPosition == null) { + return; + } + + if (_dragHandleType == HandleType.collapsed) { + // A collapsed handle always produces a collapsed selection. + _lastSelection = DocumentSelection.collapsed(position: nearestPosition); + _select(_lastSelection!); + return; + } + + final isOverNonTextNode = nearestPosition.nodePosition is! TextNodePosition; + if (isOverNonTextNode) { + // Don't change selection if the user long-presses over a non-text node and then + // moves the finger over the same node. This prevents the selection from collapsing + // when the user moves the finger towards the starting edge of the node. + if (nearestPosition.nodeId != _lastSelection!.base.nodeId) { + // The user is dragging over content that isn't text, therefore it doesn't have + // a concept of "words". Select the whole node. + _select(_lastSelection!.expandTo(nearestPosition)); + } + return; + } + + final nearestPositionTextOffset = (nearestPosition.nodePosition as TextNodePosition).offset; + final previousNearestPositionTextOffset = (_lastFocalPosition!.nodePosition as TextNodePosition).offset; + + final didFocalPointStayInSameNode = _lastFocalPosition!.nodeId == nearestPosition.nodeId; + + final didFocalPointMoveToDownstreamNode = _document.getAffinityBetween( + base: _lastFocalPosition!, + extent: nearestPosition, + ) == + TextAffinity.downstream && + !didFocalPointStayInSameNode; + + final didFocalPointMoveToUpstreamNode = _document.getAffinityBetween( + base: _lastFocalPosition!, + extent: nearestPosition, + ) == + TextAffinity.upstream && + !didFocalPointStayInSameNode; + + final didFocalPointMoveDownstream = didFocalPointMoveToDownstreamNode || + (didFocalPointStayInSameNode && nearestPositionTextOffset > previousNearestPositionTextOffset) || + (didFocalPointStayInSameNode && details.delta.dx > 0); + + final didFocalPointMoveUpstream = didFocalPointMoveToUpstreamNode || + (didFocalPointStayInSameNode && nearestPositionTextOffset < previousNearestPositionTextOffset) || + (didFocalPointStayInSameNode && details.delta.dx < 0); + + _lastFocalPosition = nearestPosition; + + if (_currentDragDirection == null) { + // The user just started dragging the handle. + _currentDragDirection = didFocalPointMoveDownstream ? TextAffinity.downstream : TextAffinity.upstream; + + if (_dragHandleType == HandleType.upstream && didFocalPointMoveUpstream) { + _selectionModifier = _SelectionModifier.word; + } else if (_dragHandleType == HandleType.downstream && didFocalPointMoveDownstream) { + _selectionModifier = _SelectionModifier.word; + } else { + _selectionModifier = _SelectionModifier.character; + } + } else { + // Check if the user started dragging the handle in the opposite direction. + late TextAffinity newDragDirection; + if (_currentDragDirection == TextAffinity.upstream) { + newDragDirection = didFocalPointMoveDownstream ? TextAffinity.downstream : TextAffinity.upstream; + } else { + newDragDirection = didFocalPointMoveUpstream ? TextAffinity.upstream : TextAffinity.downstream; + } + + // Invert the drag handle type if the selection has upstream affinity. + final newEffectiveHandleType = _lastSelection!.hasDownstreamAffinity(_document) // + ? _dragHandleType! + : (_dragHandleType == HandleType.upstream ? HandleType.downstream : HandleType.upstream); + + if (newDragDirection != _currentDragDirection || newEffectiveHandleType != _effectiveDragHandleType) { + _currentDragDirection = newDragDirection; + _effectiveDragHandleType = newEffectiveHandleType; + + if (_effectiveDragHandleType == HandleType.downstream && newDragDirection == TextAffinity.downstream) { + _selectionModifier = _SelectionModifier.word; + } else if (_effectiveDragHandleType == HandleType.upstream && newDragDirection == TextAffinity.upstream) { + _selectionModifier = _SelectionModifier.word; + } else { + _selectionModifier = _SelectionModifier.character; + } + } + } + + final rangeToExpandSelection = _selectionModifier == _SelectionModifier.word + ? _dragHandleType == _effectiveDragHandleType + ? getWordSelection(docPosition: nearestPosition, docLayout: _docLayout) + : _flipSelection(getWordSelection(docPosition: nearestPosition, docLayout: _docLayout)!) + : DocumentSelection.collapsed(position: nearestPosition); + + if (rangeToExpandSelection != null) { + _lastSelection = _lastSelection!.copyWith( + base: _dragHandleType == HandleType.upstream ? rangeToExpandSelection.base : _lastSelection!.base, + extent: _dragHandleType == HandleType.downstream ? rangeToExpandSelection.extent : _lastSelection!.extent, + ); + _select(_lastSelection!); + } + } + + /// Invert the selection so that the base and extent are swapped. + DocumentSelection _flipSelection(DocumentSelection selection) { + return selection.copyWith( + base: selection.extent, + extent: selection.base, + ); + } +} + +enum _SelectionModifier { + character, + word, +} diff --git a/super_editor/lib/src/infrastructure/platforms/android/long_press_selection.dart b/super_editor/lib/src/infrastructure/platforms/android/long_press_selection.dart new file mode 100644 index 0000000000..8a8a9fbfae --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/android/long_press_selection.dart @@ -0,0 +1,717 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/composable_text.dart'; + +/// A strategy for selecting text during a long-press drag gesture, similar to +/// how the Android OS selects text during a long-press drag. +/// +/// This strategy is made to operate over a document layout. +/// +/// This strategy isn't identical to the Android OS behavior, but it's very similar. +/// +/// Differences: +/// +/// * Android lets the user collapse the initial word selection when selecting +/// in one direction, and then selecting in the other. This strategy always +/// keeps the initial word selection, regardless of whether the user initially +/// drags in one direction and then reverses back and crosses to the other +/// side of the initial word. +/// +/// * Android selects a word when the user selects at least half way into the word. +/// This strategy selects the word as soon as the user selects any part of the +/// word. +/// +/// * Android seems to maintain a virtual selection offset, which can be different +/// from the finger position. This is easy to see when the user initially selects +/// by word, and then reverses direction to select by character. The placement of +/// the virtual selection offset, as compared to the finger offset, is different +/// depending on whether the user is pulling back by character, or pushing forward +/// by character. +/// +/// * This strategy attempts to match Android's virtual selection offset when the +/// user pulls back from per-word selection and begins per-character selection. +/// +/// * When the user starts pushing forward again, after previously pulling back +/// for per-character selection, this strategy waits until the user exceeds +/// the current word boundary and then switches back to per-word selection. +/// This is different from Android, which applies some kind of heuristic to +/// begin pushing the selection forward before the user's finger reaches the +/// edge of the word. +/// +class AndroidDocumentLongPressSelectionStrategy { + /// The default distance between the user's finger, the far boundary of + /// a word, when the user is dragging in the reverse direction, which + /// triggers a switch from per-word selection to per-character selection. + /// + /// This value was chosen experimentally. + static const _defaultBoundaryDistanceToSwitchToCharacterSelection = 24.0; + + AndroidDocumentLongPressSelectionStrategy({ + required Document document, + required DocumentLayout documentLayout, + required void Function(DocumentSelection) select, + }) : _document = document, + _docLayout = documentLayout, + _select = select; + + final Document _document; + final DocumentLayout _docLayout; + final void Function(DocumentSelection) _select; + + /// The word the user initially selects upon long-pressing. + DocumentSelection? _longPressInitialSelection; + + /// The node where the user's finger was dragging most recently. + String? _longPressMostRecentBoundaryNodeId; + + /// The direction of the user's current selection in relation to the initial word selection. + TextAffinity? _longPressSelectionDirection; + + /// The most recent select-by-word boundary in the upstream direction. + /// + /// Initially, a long-press drag selects the word under the users finger as the + /// user drags upstream. The user can drag in the opposite direction (downstream) + /// to begin selecting by character, instead of by word. However, once the user + /// switches back to the original upstream drag direction, and the user passes + /// this boundary, the selection mode returns to per-word selection. + /// + /// As the user selects words, this boundary is set to the edge of the selected + /// word that's furthest from the initial selection. When the user drags in reverse + /// and selects characters, this boundary moves back to the upstream edge of + /// whichever word contains the characters that the user is currently selecting. + /// + /// Examples of boundary movement: + /// - "[" and "]" are selection bounds + /// - "|" is the upstream word boundary + /// - "*" is a stationary finger + /// - "*--" is a finger moving upstream + /// - "--*" is a finger moving downstream + /// + /// ``` + /// one two three four five six seven + /// [ * ] + /// + /// one two three four five six seven + /// |[ *-- ] <- selection by word + /// + /// one two three four five six seven + /// |[ * ] <- selection by word + /// + /// one two three four five six seven + /// | [--* ] <- selection by character + /// + /// one two three four five six seven + /// | [--* ] <- selection by character + /// + /// one two three four five six seven + /// | [ * ] + /// + /// one two three four five six seven + /// | [ *-- ] <- selection by character + /// + /// one two three four five six seven + /// |[ *-- ] <- selection by word + /// + /// one two three four five six seven + /// |[ *-- ] <- selection by word + /// + /// one two three four five six seven + /// |[ * ] + /// ``` + int? _longPressMostRecentUpstreamWordBoundary; + + /// The most recent select-by-word boundary in the downstream direction. + /// + /// See [_longPressMostRecentUpstreamWordBoundary] for move info. + int? _longPressMostRecentDownstreamWordBoundary; + + /// Whether the user is currently selecting by character, or by word. + bool _isSelectingByCharacter = false; + + /// The [DocumentPosition] that the user most recently touched with the + /// long-press finger. + DocumentPosition? _longPressMostRecentTouchDocumentPosition; + + /// When dragging by word, this value is `0`, when dragging by character, + /// this is the horizontal offset between the user's finger and the + /// [_longPressMostRecentUpstreamWordBoundary] or the + /// [_longPressMostRecentDownstreamWordBoundary] when the user switched to + /// dragging by character. + /// + /// This offset is used, during character selection, to select text that's + /// some distance away from the user's finger. The closer the user's finger + /// is to the edge of a word, before going into character selection mode, + /// the shorter this distance will be. If the user's finger sits directly on + /// the edge of a word before going into character selection mode, this + /// value will be near zero, and the visual effect will be unnoticeable. + double _longPressCharacterSelectionXOffset = 0; + + /// Clients should call this method when a long press gesture is initially + /// recognized. + /// + /// Returns `true` if a long-press selection started, or `false` if the user's + /// press didn't occur over selectable content. + bool onLongPressStart({ + required Offset tapDownDocumentOffset, + }) { + longPressSelectionLog.fine("Long press start"); + final docPosition = _docLayout.getDocumentPositionNearestToOffset(tapDownDocumentOffset); + if (docPosition == null) { + longPressSelectionLog.finer("No doc position where the user pressed"); + return false; + } + + if (docPosition.nodePosition is! TextNodePosition) { + // Select the whole node. + _longPressInitialSelection = DocumentSelection( + base: DocumentPosition( + nodeId: docPosition.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: docPosition.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.downstream(), + ), + ); + } else { + _longPressInitialSelection = getWordSelection(docPosition: docPosition, docLayout: _docLayout); + } + + _select(_longPressInitialSelection!); + + // Initially, the word vs character selection bound tracking is set equal to + // the word boundaries of the first selected word. + longPressSelectionLog.finer("Setting initial long-press upstream bound to: ${_longPressInitialSelection!.start}"); + _longPressMostRecentBoundaryNodeId = _longPressInitialSelection!.start.nodeId; + + if (docPosition.nodePosition is TextNodePosition) { + _longPressMostRecentUpstreamWordBoundary = + (_longPressInitialSelection!.start.nodePosition as TextNodePosition).offset; + _longPressMostRecentDownstreamWordBoundary = + (_longPressInitialSelection!.end.nodePosition as TextNodePosition).offset; + } + + return true; + } + + /// Clients should call this method when an existing long-press gesture first + /// begins to pan. + /// + /// Upon long-press pan movements, clients should call [onLongPressDragUpdate]. + void onLongPressDragStart(DragStartDetails details) { + longPressSelectionLog.fine("Long press drag start"); + } + + /// Clients should call this method whenever a long-press gesture pans, after + /// initially calling [onLongPressStart]. + void onLongPressDragUpdate(Offset fingerDocumentOffset, DocumentPosition? fingerDocumentPosition) { + longPressSelectionLog.finer("--------------------------------------------"); + longPressSelectionLog.fine("Long press drag update"); + longPressSelectionLog.finer("Finger offset: $fingerDocumentOffset"); + longPressSelectionLog.finer("Finger position: $fingerDocumentPosition"); + if (fingerDocumentPosition == null) { + return; + } + + final isOverNonTextNode = fingerDocumentPosition.nodePosition is! TextNodePosition; + if (isOverNonTextNode) { + // Don't change selection if the user long-presses over a non-text node and then + // moves the finger over the same node. This prevents the selection from collapsing + // when the user moves the finger towards the starting edge of the node. + if (fingerDocumentPosition.nodeId != _longPressInitialSelection!.base.nodeId) { + // The user is dragging over content that isn't text, therefore it doesn't have + // a concept of "words". Select the whole node. + longPressSelectionLog.finer("Dragging over non-text node. Selecting the whole node."); + _select(_longPressInitialSelection!.expandTo(fingerDocumentPosition)); + } + return; + } + + final focalPointDocumentOffset = !_isSelectingByCharacter + ? fingerDocumentOffset + : fingerDocumentOffset + Offset(_longPressCharacterSelectionXOffset, 0); + final focalPointDocumentPosition = !_isSelectingByCharacter + ? fingerDocumentPosition + : _docLayout.getDocumentPositionNearestToOffset(focalPointDocumentOffset)!; + + final fingerIsInInitialWord = + _document.doesSelectionContainPosition(_longPressInitialSelection!, focalPointDocumentPosition); + + if (fingerIsInInitialWord) { + longPressSelectionLog.finer("Dragging in the initial word."); + _onLongPressFingerIsInInitialWord(fingerDocumentOffset); + return; + } + + final componentUnderFinger = _docLayout.getComponentByNodeId(fingerDocumentPosition.nodeId); + final textComponent = + componentUnderFinger is TextComponentState ? componentUnderFinger : componentUnderFinger as ProxyTextComposable; + final fingerTextOffset = (fingerDocumentPosition.nodePosition as TextNodePosition).offset; + + TextNodePosition? mostRecentBoundaryLine; + if (_longPressInitialSelection!.base.nodePosition is TextNodePosition) { + final initialSelectionStartOffset = (_longPressInitialSelection!.base.nodePosition as TextNodePosition).offset; + final initialSelectionEndOffset = (_longPressInitialSelection!.end.nodePosition as TextNodePosition).offset; + final mostRecentBoundaryTextOffset = _longPressSelectionDirection == TextAffinity.upstream + ? _longPressMostRecentUpstreamWordBoundary ?? initialSelectionStartOffset + : _longPressMostRecentDownstreamWordBoundary ?? initialSelectionEndOffset; + mostRecentBoundaryLine = + textComponent.getPositionAtStartOfLine(TextNodePosition(offset: mostRecentBoundaryTextOffset)); + } + + final fingerLine = textComponent.getPositionAtStartOfLine(TextNodePosition(offset: fingerTextOffset)); + final fingerIsOnNewLine = fingerLine != mostRecentBoundaryLine; + if (fingerIsOnNewLine || fingerDocumentPosition.nodeId != _longPressMostRecentBoundaryNodeId) { + // The user either dragged from one line of text to another, or the user dragged + // from one text node to another. For either case, we want to stop any on-going + // per-character dragging and return to per-word dragging. + _resetWordVsCharacterTracking(); + } + + final fingerIsUpstream = + _document.getAffinityBetween(base: fingerDocumentPosition, extent: _longPressInitialSelection!.end) == + TextAffinity.downstream; + if (fingerIsUpstream) { + _onLongPressDragUpstreamOfInitialWord( + fingerDocumentOffset: fingerDocumentOffset, + fingerDocumentPosition: fingerDocumentPosition, + focalPointDocumentPosition: focalPointDocumentPosition, + ); + } else { + _onLongPressDragDownstreamOfInitialWord( + fingerDocumentOffset: fingerDocumentOffset, + fingerDocumentPosition: fingerDocumentPosition, + focalPointDocumentPosition: focalPointDocumentPosition, + ); + } + } + + void _resetWordVsCharacterTracking() { + longPressSelectionLog.finest("Resetting word-vs-character tracking"); + _longPressMostRecentBoundaryNodeId = _longPressInitialSelection!.start.nodeId; + + if (_longPressInitialSelection is TextNodePosition) { + _longPressMostRecentUpstreamWordBoundary = + (_longPressInitialSelection!.start.nodePosition as TextNodePosition).offset; + _longPressMostRecentDownstreamWordBoundary = + (_longPressInitialSelection!.end.nodePosition as TextNodePosition).offset; + } + _isSelectingByCharacter = false; + _longPressCharacterSelectionXOffset = 0; + } + + void _onLongPressFingerIsInInitialWord(Offset fingerOffsetInDocument) { + // The initial word always remains selected. The entire word is the basis for + // selection. Whenever the user presses over the initial word, the user isn't + // selecting in on particular direction or the other. + _longPressMostRecentUpstreamWordBoundary = + (_longPressInitialSelection!.start.nodePosition as TextNodePosition).offset; + _longPressMostRecentDownstreamWordBoundary = + (_longPressInitialSelection!.end.nodePosition as TextNodePosition).offset; + _longPressSelectionDirection = null; + _isSelectingByCharacter = false; + _longPressCharacterSelectionXOffset = 0; + + final initialWordRect = + _docLayout.getRectForSelection(_longPressInitialSelection!.base, _longPressInitialSelection!.extent)!; + final distanceToUpstream = (fingerOffsetInDocument.dx - initialWordRect.left).abs(); + final distanceToDownstream = (fingerOffsetInDocument.dx - initialWordRect.right).abs(); + + if (distanceToDownstream <= distanceToUpstream) { + // The user's finger is closer to the downstream side than the upstream side. + // Report the selection with the extent at the downstream edge to indicate the + // direction the user is likely to move. + _select(DocumentSelection( + base: _longPressInitialSelection!.start, + extent: _longPressInitialSelection!.end, + )); + } else { + // The user's finger is closer to the upstream side than the downstream side. + // Report the selection with the extent at the upstream edge to indicate the + // direction the user is likely to move. + _select(DocumentSelection( + base: _longPressInitialSelection!.end, + extent: _longPressInitialSelection!.start, + )); + } + } + + void _onLongPressDragUpstreamOfInitialWord({ + required Offset fingerDocumentOffset, + required DocumentPosition fingerDocumentPosition, + required DocumentPosition focalPointDocumentPosition, + }) { + longPressSelectionLog.finest("Dragging upstream from initial word."); + + _longPressSelectionDirection = TextAffinity.upstream; + + final focalPointNodeId = focalPointDocumentPosition.nodeId; + + if (focalPointNodeId != _longPressMostRecentBoundaryNodeId) { + // The user dragged into a different node. The word boundary from the previous + // node is no longer useful for calculations. Select a new boundary in the + // newly selected node. + _longPressMostRecentBoundaryNodeId = focalPointNodeId; + + // When the user initially drags into a new node, we want the user to drag + // by word, even if the user was previously dragging by character. To help + // ensure this strategy accomplishes that, place the new upstream boundary + // at the end of the text so that any user selection position will be seen + // as passing that boundary and therefore triggering a selection by word + // instead of a selection by character. + final textNode = _document.getNodeById(focalPointNodeId) as TextNode; + _longPressMostRecentUpstreamWordBoundary = textNode.endPosition.offset; + } + + int focalPointTextOffset = (focalPointDocumentPosition.nodePosition as TextNodePosition).offset; + final focalPointIsBeyondMostRecentUpstreamWordBoundary = focalPointNodeId == _longPressMostRecentBoundaryNodeId && + focalPointTextOffset < _longPressMostRecentUpstreamWordBoundary!; + longPressSelectionLog.finest( + "Focal point: $focalPointTextOffset, boundary: $_longPressMostRecentUpstreamWordBoundary, most recent touch position: $_longPressMostRecentTouchDocumentPosition"); + + late final bool selectByWord; + if (focalPointIsBeyondMostRecentUpstreamWordBoundary) { + longPressSelectionLog.finest("Select by word because finger is beyond most recent boundary."); + longPressSelectionLog.finest(" - most recent boundary position: $_longPressMostRecentUpstreamWordBoundary"); + longPressSelectionLog.finest(" - focal point position: $focalPointDocumentPosition"); + selectByWord = true; + } else { + longPressSelectionLog.finest("Focal point is NOT beyond boundary. Considering per-character selection."); + final isMovingBackward = _longPressMostRecentTouchDocumentPosition != null && + fingerDocumentPosition != _longPressMostRecentTouchDocumentPosition && + _document.getAffinityBetween( + base: _longPressMostRecentTouchDocumentPosition!, + extent: fingerDocumentPosition, + ) == + TextAffinity.downstream; + final longPressMostRecentUpstreamWordBoundaryPosition = DocumentPosition( + nodeId: _longPressMostRecentBoundaryNodeId!, + nodePosition: TextNodePosition(offset: _longPressMostRecentUpstreamWordBoundary!), + ); + final upstreamSelectionX = _docLayout + .getRectForSelection(longPressMostRecentUpstreamWordBoundaryPosition, _longPressInitialSelection!.start)! + .left; + final reverseDirectionDistance = fingerDocumentOffset.dx - upstreamSelectionX; + final startedMovingBackward = !_isSelectingByCharacter && + isMovingBackward && + reverseDirectionDistance > _defaultBoundaryDistanceToSwitchToCharacterSelection; + longPressSelectionLog.finest(" - current doc drag position: $fingerDocumentPosition"); + longPressSelectionLog.finest(" - most recent drag position: $_longPressMostRecentTouchDocumentPosition"); + longPressSelectionLog.finest(" - is moving backward? $isMovingBackward"); + longPressSelectionLog.finest(" - is already selecting by character? $_isSelectingByCharacter"); + longPressSelectionLog.finest(" - reverse direction distance: $reverseDirectionDistance"); + + if (startedMovingBackward || _isSelectingByCharacter) { + longPressSelectionLog.finest("Selecting by character:"); + longPressSelectionLog.finest(" - just started moving backward: $startedMovingBackward"); + longPressSelectionLog.finest(" - continuing an existing character selection: $_isSelectingByCharacter"); + selectByWord = false; + } else { + longPressSelectionLog.finest("User is still dragging away from initial word, selecting by word."); + selectByWord = true; + } + } + + if (!selectByWord && !_isSelectingByCharacter) { + // This will be the first frame where we start selecting by character. + // Move the drag reference point from the user's finger to the end of the + // current selected word. + + if (_longPressSelectionDirection == null) { + // If we've triggered a "select by character" position, then in theory + // it shouldn't be possible that we don't know the direction of the user's + // selection, but that information is null. Log a warning and skip this + // calculation. + longPressSelectionLog.warning( + "The user triggered per-character selection, but we don't know which direction the user started moving the selection. We expected to know that information at this point."); + } else { + longPressSelectionLog.finest("Switched to per-character..."); + // The user is selecting upstream. The end of the current selected word + // is the upstream bound of the current selection. + final longPressMostRecentUpstreamWordBoundaryPosition = DocumentPosition( + nodeId: _longPressMostRecentBoundaryNodeId!, + nodePosition: TextNodePosition(offset: _longPressMostRecentUpstreamWordBoundary!), + ); + final DocumentPosition boundary = longPressMostRecentUpstreamWordBoundaryPosition; + + final boundaryOffsetInDocument = _docLayout.getRectForPosition(boundary)!.center; + _longPressCharacterSelectionXOffset = boundaryOffsetInDocument.dx - fingerDocumentOffset.dx; + + longPressSelectionLog.finest(" - Upstream boundary position: $boundary"); + longPressSelectionLog.finest(" - Upstream boundary offset in document: $boundaryOffsetInDocument"); + longPressSelectionLog.finest(" - Touch document offset: $fingerDocumentOffset"); + longPressSelectionLog.finest(" - Per-character selection x-offset: $_longPressCharacterSelectionXOffset"); + + // Calculate an updated focal point now that we've started selecting by character. + final focalPointDocumentOffset = fingerDocumentOffset + Offset(_longPressCharacterSelectionXOffset, 0); + focalPointDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(focalPointDocumentOffset)!; + focalPointTextOffset = (focalPointDocumentPosition.nodePosition as TextNodePosition).offset; + longPressSelectionLog.finest("Updated the focal point because we just started selecting by character"); + longPressSelectionLog.finest(" - new focal point text offset: $focalPointTextOffset"); + } + } + + _isSelectingByCharacter = !selectByWord; + + late final DocumentSelection newSelection; + if (selectByWord) { + longPressSelectionLog.finest("Selecting by word..."); + longPressSelectionLog.finest(" - finding word around finger position: ${fingerDocumentPosition.nodePosition}"); + final wordUnderFinger = getWordSelection(docPosition: fingerDocumentPosition, docLayout: _docLayout); + if (wordUnderFinger == null) { + // This shouldn't happen. If we've gotten here, the user is selecting over + // text content but we couldn't find a word selection. The best we can do + // is fizzle. + longPressSelectionLog.warning("Long-press selecting. Couldn't find word at position: $fingerDocumentPosition"); + return; + } + + final wordSelection = TextSelection( + baseOffset: (wordUnderFinger.base.nodePosition as TextNodePosition).offset, + extentOffset: (wordUnderFinger.extent.nodePosition as TextNodePosition).offset, + ); + longPressSelectionLog.finest(" - word selection: $wordSelection"); + final textNode = _document.getNodeById(wordUnderFinger.base.nodeId) as TextNode; + final wordText = textNode.text.substringInRange(wordSelection.toSpanRange()); + longPressSelectionLog.finest("Selected word text: '$wordText'"); + + newSelection = DocumentSelection(base: _longPressInitialSelection!.end, extent: wordUnderFinger.start); + + // Update the most recent bounds for word-by-word selection. + final longPressMostRecentUpstreamTextOffset = _longPressMostRecentUpstreamWordBoundary!; + longPressSelectionLog.finest( + "Word upstream offset: ${wordSelection.start}, long press upstream bound: $longPressMostRecentUpstreamTextOffset"); + final newSelectionIsBeyondLastUpstreamWordBoundary = wordSelection.start < longPressMostRecentUpstreamTextOffset; + if (newSelectionIsBeyondLastUpstreamWordBoundary) { + _longPressMostRecentUpstreamWordBoundary = wordSelection.start; + longPressSelectionLog.finest( + "Updating long-press most recent upstream word boundary: $_longPressMostRecentUpstreamWordBoundary"); + } + } else { + // Select by character. + longPressSelectionLog.finest("Selecting by character..."); + longPressSelectionLog.finest("Calculating the character drag position:"); + longPressSelectionLog.finest(" - character drag position: $focalPointDocumentPosition"); + longPressSelectionLog.finest(" - long-press character x-offset: $_longPressCharacterSelectionXOffset"); + newSelection = + _document.getAffinityBetween(base: focalPointDocumentPosition, extent: _longPressInitialSelection!.end) == + TextAffinity.downstream + ? DocumentSelection(base: _longPressInitialSelection!.end, extent: focalPointDocumentPosition) + : DocumentSelection(base: _longPressInitialSelection!.start, extent: focalPointDocumentPosition); + + // When dragging by character, if the user drags backward far enough to move to + // an earlier word, we want to re-activate drag-by-word for the word that we just + // moved away from. To accomplish this, we update our word boundary as the user + // drags by character. + final focalPointWord = getWordSelection(docPosition: focalPointDocumentPosition, docLayout: _docLayout); + if (focalPointWord != null) { + final upstreamWordBoundary = (focalPointWord.start.nodePosition as TextNodePosition).offset; + + if (upstreamWordBoundary > _longPressMostRecentUpstreamWordBoundary!) { + longPressSelectionLog.finest( + "The user moved backward into another word. We're pushing back the upstream boundary from $_longPressMostRecentUpstreamWordBoundary to $upstreamWordBoundary"); + _longPressMostRecentUpstreamWordBoundary = upstreamWordBoundary; + } + } + } + + _longPressMostRecentTouchDocumentPosition = fingerDocumentPosition; + + _select(newSelection); + } + + void _onLongPressDragDownstreamOfInitialWord({ + required Offset fingerDocumentOffset, + required DocumentPosition fingerDocumentPosition, + required DocumentPosition focalPointDocumentPosition, + }) { + longPressSelectionLog.finest("Dragging downstream from initial word."); + + _longPressSelectionDirection = TextAffinity.downstream; + + final focalPointNodeId = focalPointDocumentPosition.nodeId; + + if (focalPointNodeId != _longPressMostRecentBoundaryNodeId) { + // The user dragged into a different node. The word boundary from the previous + // node is no longer useful for calculations. Select a new boundary in the + // newly selected node. + _longPressMostRecentBoundaryNodeId = focalPointNodeId; + + // When the user initially drags into a new node, we want the user to drag + // by word, even if the user was previously dragging by character. To help + // ensure this strategy accomplishes that, place the new downstream boundary + // at the beginning of the text so that any user selection position will + // be seen as passing that boundary and therefore triggering a selection + // by word instead of a selection by character. + final textNode = _document.getNodeById(focalPointNodeId) as TextNode; + _longPressMostRecentDownstreamWordBoundary = textNode.beginningPosition.offset; + } + + int focalPointTextOffset = (focalPointDocumentPosition.nodePosition as TextNodePosition).offset; + final focalPointIsBeyondMostRecentDownstreamWordBoundary = focalPointNodeId == _longPressMostRecentBoundaryNodeId && + focalPointTextOffset > _longPressMostRecentDownstreamWordBoundary!; + longPressSelectionLog.finest( + "Focal point: $focalPointTextOffset, boundary: $_longPressMostRecentDownstreamWordBoundary, most recent touch position: $_longPressMostRecentTouchDocumentPosition"); + + late final bool selectByWord; + if (focalPointIsBeyondMostRecentDownstreamWordBoundary) { + longPressSelectionLog.finest("Select by word because finger is beyond most recent boundary."); + longPressSelectionLog.finest(" - most recent boundary position: $_longPressMostRecentDownstreamWordBoundary"); + longPressSelectionLog.finest(" - focal point position: $focalPointDocumentPosition"); + selectByWord = true; + } else { + longPressSelectionLog.finest("Focal point is NOT beyond boundary. Considering per-character selection."); + final isMovingBackward = _longPressMostRecentTouchDocumentPosition != null && + fingerDocumentPosition != _longPressMostRecentTouchDocumentPosition && + _document.getAffinityBetween( + base: fingerDocumentPosition, + extent: _longPressMostRecentTouchDocumentPosition!, + ) == + TextAffinity.downstream; + final longPressMostRecentDownstreamWordBoundaryPosition = DocumentPosition( + nodeId: _longPressMostRecentBoundaryNodeId!, + nodePosition: TextNodePosition(offset: _longPressMostRecentDownstreamWordBoundary!), + ); + final downstreamSelectionX = _docLayout + .getRectForSelection(longPressMostRecentDownstreamWordBoundaryPosition, _longPressInitialSelection!.start)! + .right; + final reverseDirectionDistance = downstreamSelectionX - fingerDocumentOffset.dx; + final startedMovingBackward = !_isSelectingByCharacter && + isMovingBackward && + reverseDirectionDistance > _defaultBoundaryDistanceToSwitchToCharacterSelection; + longPressSelectionLog.finest(" - current doc drag position: $fingerDocumentPosition"); + longPressSelectionLog.finest(" - most recent drag position: $_longPressMostRecentTouchDocumentPosition"); + longPressSelectionLog.finest(" - is moving backward? $isMovingBackward"); + longPressSelectionLog.finest(" - is already selecting by character? $_isSelectingByCharacter"); + longPressSelectionLog.finest(" - reverse direction distance: $reverseDirectionDistance"); + + if (startedMovingBackward || _isSelectingByCharacter) { + longPressSelectionLog.finest("Selecting by character:"); + longPressSelectionLog.finest(" - just started moving backward: $startedMovingBackward"); + longPressSelectionLog.finest(" - continuing an existing character selection: $_isSelectingByCharacter"); + selectByWord = false; + } else { + longPressSelectionLog.finest("User is still dragging away from initial word, selecting by word."); + selectByWord = true; + } + } + + if (!selectByWord && !_isSelectingByCharacter) { + // This will be the first frame where we start selecting by character. + // Move the drag reference point from the user's finger to the end of the + // current selected word. + + if (_longPressSelectionDirection == null) { + // If we've triggered a "select by character" position, then in theory + // it shouldn't be possible that we don't know the direction of the user's + // selection, but that information is null. Log a warning and skip this + // calculation. + longPressSelectionLog.warning( + "The user triggered per-character selection, but we don't know which direction the user started moving the selection. We expected to know that information at this point."); + } else { + longPressSelectionLog.finest("Switched to per-character..."); + // The user is selecting downstream. The end of the current selected word + // is the downstream bound of the current selection. + final longPressMostRecentDownstreamWordBoundaryPosition = DocumentPosition( + nodeId: _longPressMostRecentBoundaryNodeId!, + nodePosition: TextNodePosition(offset: _longPressMostRecentDownstreamWordBoundary!), + ); + final DocumentPosition boundary = longPressMostRecentDownstreamWordBoundaryPosition; + + final boundaryOffsetInDocument = _docLayout.getRectForPosition(boundary)!.center; + _longPressCharacterSelectionXOffset = boundaryOffsetInDocument.dx - fingerDocumentOffset.dx; + + longPressSelectionLog.finest(" - Downstream boundary position: $boundary"); + longPressSelectionLog.finest(" - Downstream boundary offset in document: $boundaryOffsetInDocument"); + longPressSelectionLog.finest(" - Touch document offset: $fingerDocumentOffset"); + longPressSelectionLog.finest(" - Per-character selection x-offset: $_longPressCharacterSelectionXOffset"); + + // Calculate an updated focal point now that we've started selecting by character. + final focalPointDocumentOffset = fingerDocumentOffset + Offset(_longPressCharacterSelectionXOffset, 0); + focalPointDocumentPosition = _docLayout.getDocumentPositionNearestToOffset(focalPointDocumentOffset)!; + focalPointTextOffset = (focalPointDocumentPosition.nodePosition as TextNodePosition).offset; + longPressSelectionLog.finest("Updated the focal point because we just started selecting by character"); + longPressSelectionLog.finest(" - new focal point text offset: $focalPointTextOffset"); + } + } + + _isSelectingByCharacter = !selectByWord; + + late final DocumentSelection newSelection; + if (selectByWord) { + longPressSelectionLog.finest("Selecting by word..."); + longPressSelectionLog.finest(" - finger document position: $fingerDocumentPosition"); + final wordUnderFinger = getWordSelection(docPosition: fingerDocumentPosition, docLayout: _docLayout); + if (wordUnderFinger == null) { + // This shouldn't happen. If we've gotten here, the user is selecting over + // text content but we couldn't find a word selection. The best we can do + // is fizzle. + longPressSelectionLog.warning("Long-press selecting. Couldn't find word at position: $fingerDocumentPosition"); + return; + } + + final wordSelection = TextSelection( + baseOffset: (wordUnderFinger.base.nodePosition as TextNodePosition).offset, + extentOffset: (wordUnderFinger.extent.nodePosition as TextNodePosition).offset, + ); + final textNode = _document.getNodeById(wordUnderFinger.base.nodeId) as TextNode; + final wordText = textNode.text.substringInRange(wordSelection.toSpanRange()); + longPressSelectionLog.finest("Selected word text: '$wordText'"); + + newSelection = DocumentSelection(base: _longPressInitialSelection!.start, extent: wordUnderFinger.end); + + // Update the most recent bounds for word-by-word selection. + final longPressMostRecentDownstreamTextOffset = _longPressMostRecentDownstreamWordBoundary!; + longPressSelectionLog.finest( + "Word downstream offset: ${wordSelection.end}, long press downstream bound: $longPressMostRecentDownstreamTextOffset"); + final newSelectionIsBeyondLastDownstreamWordBoundary = + wordSelection.end > longPressMostRecentDownstreamTextOffset; + if (newSelectionIsBeyondLastDownstreamWordBoundary) { + _longPressMostRecentDownstreamWordBoundary = wordSelection.end; + longPressSelectionLog.finest( + "Updating long-press most recent downstream word boundary: $_longPressMostRecentDownstreamWordBoundary"); + } + } else { + // Select by character. + longPressSelectionLog.finest("Selecting by character..."); + longPressSelectionLog.finest("Calculating the character drag position:"); + longPressSelectionLog.finest(" - character drag position: $focalPointDocumentPosition"); + longPressSelectionLog.finest(" - long-press character x-offset: $_longPressCharacterSelectionXOffset"); + newSelection = DocumentSelection(base: _longPressInitialSelection!.start, extent: focalPointDocumentPosition); + + // When dragging by character, if the user drags backward far enough to move to + // an earlier word, we want to re-activate drag-by-word for the word that we just + // moved away from. To accomplish this, we update our word boundary as the user + // drags by character. + final focalPointWord = getWordSelection(docPosition: focalPointDocumentPosition, docLayout: _docLayout); + if (focalPointWord != null) { + final downstreamWordBoundary = (focalPointWord.end.nodePosition as TextNodePosition).offset; + + if (downstreamWordBoundary < _longPressMostRecentDownstreamWordBoundary!) { + longPressSelectionLog.finest( + "The user moved backward into another word. We're pushing back the downstream boundary from $_longPressMostRecentDownstreamWordBoundary to $downstreamWordBoundary"); + _longPressMostRecentDownstreamWordBoundary = downstreamWordBoundary; + } + } + } + + _longPressMostRecentTouchDocumentPosition = fingerDocumentPosition; + + _select(newSelection); + } + + /// Clients should call this method when a long-press drag ends, or is cancelled. + void onLongPressEnd() { + longPressSelectionLog.fine("Long press end"); + _longPressInitialSelection = null; + _longPressMostRecentUpstreamWordBoundary = null; + _longPressMostRecentDownstreamWordBoundary = null; + } +} diff --git a/super_editor/lib/src/infrastructure/platforms/android/magnifier.dart b/super_editor/lib/src/infrastructure/platforms/android/magnifier.dart index 74382c363b..9c6258c3dd 100644 --- a/super_editor/lib/src/infrastructure/platforms/android/magnifier.dart +++ b/super_editor/lib/src/infrastructure/platforms/android/magnifier.dart @@ -1,8 +1,10 @@ +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/magnifier.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/outer_box_shadow.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/magnifier.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/outer_box_shadow.dart'; -/// An Android magnifying glass that follows a [LayerLink]. +/// An Android magnifying glass that follows a [LeaderLink]. class AndroidFollowingMagnifier extends StatelessWidget { const AndroidFollowingMagnifier({ Key? key, @@ -10,18 +12,24 @@ class AndroidFollowingMagnifier extends StatelessWidget { this.offsetFromFocalPoint = Offset.zero, }) : super(key: key); - final LayerLink layerLink; + final LeaderLink layerLink; final Offset offsetFromFocalPoint; @override Widget build(BuildContext context) { - return CompositedTransformFollower( + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + + return Follower.withOffset( link: layerLink, offset: offsetFromFocalPoint, - child: FractionalTranslation( - translation: const Offset(-0.5, -0.5), - child: AndroidMagnifyingGlass( - offsetFromFocalPoint: offsetFromFocalPoint, + leaderAnchor: Alignment.center, + followerAnchor: Alignment.center, + boundary: const ScreenFollowerBoundary(), + child: AndroidMagnifyingGlass( + magnificationScale: 1.5, + offsetFromFocalPoint: Offset( + offsetFromFocalPoint.dx / devicePixelRatio, + offsetFromFocalPoint.dy / devicePixelRatio, ), ), ); @@ -34,9 +42,12 @@ class AndroidMagnifyingGlass extends StatelessWidget { static const _cornerRadius = 8.0; const AndroidMagnifyingGlass({ + super.key, + this.magnificationScale = 1.5, this.offsetFromFocalPoint = Offset.zero, }); + final double magnificationScale; final Offset offsetFromFocalPoint; @override @@ -49,7 +60,7 @@ class AndroidMagnifyingGlass extends StatelessWidget { ), offsetFromFocalPoint: offsetFromFocalPoint, size: const Size(_width, _height), - magnificationScale: 1.5, + magnificationScale: magnificationScale, ), Container( width: _width, diff --git a/super_editor/lib/src/infrastructure/platforms/android/selection_handles.dart b/super_editor/lib/src/infrastructure/platforms/android/selection_handles.dart index 30c54f7321..571b0ba9b0 100644 --- a/super_editor/lib/src/infrastructure/platforms/android/selection_handles.dart +++ b/super_editor/lib/src/infrastructure/platforms/android/selection_handles.dart @@ -3,28 +3,71 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; +/// An Android-style mobile selection drag handle. +/// +/// Android renders three different types of handles: collapsed, upstream, and downstream. +/// +/// All three types of handles look like 3/4 of a circle combined with 1/4 of a square (with +/// a pointy corner). The primary difference between each handle appearance is the way the +/// pointy corner is directed. +/// +/// * Collapsed: The pointy corner points up. +/// * Upstream: The pointy corner points to the upper right (marking the start of a selection). +/// * Downstream: The pointy corner points to the upper left (marking the end of a selection). class AndroidSelectionHandle extends StatelessWidget { + static const defaultTouchRegionExpansion = EdgeInsets.only(left: 16, right: 16, bottom: 16); + const AndroidSelectionHandle({ Key? key, required this.handleType, required this.color, this.radius = 10, + this.touchRegionExpansion = defaultTouchRegionExpansion, + this.showDebugTouchRegion = false, }) : super(key: key); + /// The type of handle, e.g., collapsed, upstream, downstream. final HandleType handleType; + + /// The color of the handle. final Color color; + + /// The radius of the handle - each handle is essentially a circle with one pointy + /// corner. final double radius; + /// Invisible space added around the handle to increase the touch area the handle. + /// + /// This invisible area expands the intrinsic size of the handle, and therefore the + /// visual handle will no longer be aligned exactly with the content that's following. + /// The parent layout needs to adjust the positioning of the handle to account for + /// the [touchRegionExpansion]. + final EdgeInsets touchRegionExpansion; + + /// Whether to render the [touchRegionExpansion] with a translucent color for visual + /// debugging. + final bool showDebugTouchRegion; + @override Widget build(BuildContext context) { + late final Widget handle; switch (handleType) { case HandleType.collapsed: - return _buildCollapsed(); + handle = _buildCollapsed(); case HandleType.upstream: - return _buildUpstream(); + handle = _buildUpstream(); case HandleType.downstream: - return _buildDownstream(); + handle = _buildDownstream(); } + + return Container( + padding: touchRegionExpansion, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: showDebugTouchRegion ? Colors.red.withValues(alpha: 0.5) : Colors.transparent, + ), + child: handle, + ); } Widget _buildCollapsed() { diff --git a/super_editor/lib/src/infrastructure/platforms/android/toolbar.dart b/super_editor/lib/src/infrastructure/platforms/android/toolbar.dart index cf516d0d24..eb370bb1be 100644 --- a/super_editor/lib/src/infrastructure/platforms/android/toolbar.dart +++ b/super_editor/lib/src/infrastructure/platforms/android/toolbar.dart @@ -1,72 +1,141 @@ import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/infrastructure/flutter/android_toolbar.dart'; -class AndroidTextEditingFloatingToolbar extends StatelessWidget { +class AndroidTextEditingFloatingToolbar extends StatefulWidget { const AndroidTextEditingFloatingToolbar({ Key? key, + this.focalPoint, + this.floatingToolbarKey, this.onCutPressed, this.onCopyPressed, this.onPastePressed, this.onSelectAllPressed, }) : super(key: key); + final Key? floatingToolbarKey; + final LeaderLink? focalPoint; + final VoidCallback? onCutPressed; final VoidCallback? onCopyPressed; final VoidCallback? onPastePressed; final VoidCallback? onSelectAllPressed; @override - Widget build(BuildContext context) { - return Material( - borderRadius: BorderRadius.circular(4), - elevation: 3, - color: Colors.white, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (onCutPressed != null) - _buildButton( - onPressed: onCutPressed!, - title: 'Cut', - ), - if (onCopyPressed != null) - _buildButton( - onPressed: onCopyPressed!, - title: 'Copy', - ), - if (onPastePressed != null) - _buildButton( - onPressed: onPastePressed!, - title: 'Paste', - ), - if (onSelectAllPressed != null) - _buildButton( - onPressed: onSelectAllPressed!, - title: 'Select All', - ), - ], - ), - ); + State createState() => _AndroidTextEditingFloatingToolbarState(); +} + +class _AndroidTextEditingFloatingToolbarState extends State { + /// Whether the toolbar is above or below the focal point. + /// + /// This is used to determine the position of the back button in the overflow menu. + bool _isAbove = true; + + @override + void initState() { + super.initState(); + widget.focalPoint?.addListener(_onFocalPointChange); } - Widget _buildButton({ - required String title, - required VoidCallback onPressed, - }) { - return TextButton( - onPressed: onPressed, - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), + @override + void didUpdateWidget(AndroidTextEditingFloatingToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focalPoint != widget.focalPoint) { + oldWidget.focalPoint?.removeListener(_onFocalPointChange); + widget.focalPoint?.addListener(_onFocalPointChange); + } + } + + @override + void dispose() { + widget.focalPoint?.removeListener(_onFocalPointChange); + super.dispose(); + } + + void _onFocalPointChange() { + final leader = widget.focalPoint?.leader; + if (leader == null) { + return; + } + + final box = context.findRenderObject() as RenderBox?; + if (box == null) { + return; + } + + final leaderOffset = leader.offset; + final followerOffset = box.localToGlobal(Offset.zero); + final isAbove = followerOffset < leaderOffset; + + if (isAbove != _isAbove) { + setState(() { + _isAbove = isAbove; + }); + } + } + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + final buttons = <_ButtonViewModel>[ + if (widget.onCutPressed != null) + _ButtonViewModel( + onPressed: widget.onCutPressed!, + title: 'Cut', + ), + if (widget.onCopyPressed != null) + _ButtonViewModel( + onPressed: widget.onCopyPressed!, + title: 'Copy', + ), + if (widget.onPastePressed != null) + _ButtonViewModel( + onPressed: widget.onPastePressed!, + title: 'Paste', + ), + if (widget.onSelectAllPressed != null) + _ButtonViewModel( + onPressed: widget.onSelectAllPressed!, + title: 'Select All', + ), + ]; + + return Theme( + data: ThemeData( + colorScheme: brightness == Brightness.light // + ? const ColorScheme.light(primary: Colors.black) + : const ColorScheme.dark(primary: Colors.white), ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Text( - title, - style: const TextStyle( - color: Colors.black, - fontSize: 14, - ), + child: KeyedSubtree( + key: widget.floatingToolbarKey, + child: AndroidPopoverToolbar( + isAbove: _isAbove, + toolbarBuilder: _defaultToolbarBuilder, + children: [ + for (int i = 0; i < buttons.length; i++) + TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding(i, buttons.length), + onPressed: buttons[i].onPressed, + alignment: AlignmentDirectional.center, + child: Text(buttons[i].title), + ), + ], ), ), ); } } + +Widget _defaultToolbarBuilder(BuildContext context, Widget child) { + return AndroidPopoverToolbarContainer(child: child); +} + +class _ButtonViewModel { + _ButtonViewModel({ + required this.title, + required this.onPressed, + }); + + final String title; + final VoidCallback onPressed; +} diff --git a/super_editor/lib/src/infrastructure/platforms/ios/colors.dart b/super_editor/lib/src/infrastructure/platforms/ios/colors.dart new file mode 100644 index 0000000000..dad29b37c9 --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/ios/colors.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +const iOSToolbarDarkBackgroundColor = Color(0xFF333333); +const iOSToolbarLightBackgroundColor = Colors.white; + +const iOSToolbarLightArrowActiveColor = Color(0xFF000000); +const iOSToolbarDarkArrowActiveColor = Color(0xFFFFFFFF); + +const iOSToolbarLightArrowInactiveColor = Color(0xFF999999); +const iOSToolbarDarkArrowInactiveColor = Color(0xFF757575); diff --git a/super_editor/lib/src/infrastructure/platforms/ios/floating_cursor.dart b/super_editor/lib/src/infrastructure/platforms/ios/floating_cursor.dart new file mode 100644 index 0000000000..c24993eb00 --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/ios/floating_cursor.dart @@ -0,0 +1,11 @@ +/// Values that reflect standard or default floating cursor policies. +class FloatingCursorPolicies { + static const defaultFloatingCursorHeight = 20.0; + static const defaultFloatingCursorWidth = 2.0; + + /// The maximum horizontal distance from the bounds of selectable text, for which we want to render + /// the floating cursor. + /// + /// Beyond this distance, no floating cursor is rendered. + static const maximumDistanceToBeNearText = 30.0; +} diff --git a/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart b/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart index 963cdbf738..6f8de4eb8a 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/ios_document_controls.dart @@ -1,48 +1,44 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/default_editor/text.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; +import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/document_layers.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; -import 'package:super_editor/src/infrastructure/toolbar_position_delegate.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import 'magnifier.dart'; - -class IosDocumentTouchEditingControls extends StatefulWidget { - const IosDocumentTouchEditingControls({ +/// An application overlay that displays an iOS-style toolbar. +class IosFloatingToolbarOverlay extends StatefulWidget { + const IosFloatingToolbarOverlay({ Key? key, - required this.editingController, - required this.floatingCursorController, - required this.documentLayout, - required this.document, - required this.selection, - required this.handleColor, - this.onDoubleTapOnCaret, - this.onTripleTapOnCaret, - this.onFloatingCursorStart, - this.onFloatingCursorMoved, - this.onFloatingCursorStop, - this.magnifierFocalPointOffset, - required this.popoverToolbarBuilder, + required this.shouldShowToolbar, + required this.toolbarFocalPoint, + required this.floatingToolbarBuilder, this.createOverlayControlsClipper, - this.disableGestureHandling = false, this.showDebugPaint = false, }) : super(key: key); - final IosDocumentGestureEditingController editingController; - - final Document document; + final ValueListenable shouldShowToolbar; - final ValueNotifier selection; - - final FloatingCursorController floatingCursorController; - - final DocumentLayout documentLayout; + /// The focal point, which determines where the toolbar is positioned, and + /// where the toolbar points. + /// + /// In the case that the associated [Leader] has meaningful width and height, + /// the toolbar focuses on the center of the [Leader]'s bounding box. + final LeaderLink toolbarFocalPoint; /// Creates a clipper that applies to overlay controls, preventing /// the overlay controls from appearing outside the given clipping @@ -53,422 +49,79 @@ class IosDocumentTouchEditingControls extends StatefulWidget { /// (probably the entire screen). final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; - /// Color the iOS-style text selection drag handles. - final Color handleColor; - - /// Callback invoked on iOS when the user double taps on the caret. - final VoidCallback? onDoubleTapOnCaret; - - /// Callback invoked on iOS when the user triple taps on the caret. - final VoidCallback? onTripleTapOnCaret; - - /// Callback invoked when the floating cursor becomes visible. - final VoidCallback? onFloatingCursorStart; - - /// Callback invoked whenever the iOS floating cursor moves to a new - /// position. - final void Function(Offset)? onFloatingCursorMoved; - - /// Callback invoked when the floating cursor disappears. - final VoidCallback? onFloatingCursorStop; - - /// Offset where the magnifier should focus. - /// - /// The magnifier is displayed whenever this offset is non-null, otherwise - /// the magnifier is not shown. - final Offset? magnifierFocalPointOffset; - - /// Builder that constructs the popover toolbar that's displayed above + /// Builder that constructs the floating toolbar that's displayed above /// selected text. /// /// Typically, this bar includes actions like "copy", "cut", "paste", etc. - final Widget Function(BuildContext) popoverToolbarBuilder; - - /// Disables all gesture interaction for these editing controls, - /// allowing gestures to pass through these controls to whatever - /// content currently sits beneath them. - /// - /// While this is `true`, the user can't tap or drag on selection - /// handles or other controls. - final bool disableGestureHandling; + final DocumentFloatingToolbarBuilder floatingToolbarBuilder; final bool showDebugPaint; @override - State createState() => _IosDocumentTouchEditingControlsState(); + State createState() => _IosFloatingToolbarOverlayState(); } -class _IosDocumentTouchEditingControlsState extends State - with SingleTickerProviderStateMixin { - // These global keys are assigned to each draggable handle to - // prevent a strange dragging issue. - // - // Without these keys, if the user drags into the auto-scroll area - // for a period of time, we never receive a - // "pan end" or "pan cancel" callback. I have no idea why this is - // the case. These handles sit in an Overlay, so it's not as if they - // suffered some conflict within a ScrollView. I tried many adjustments - // to recover the end/cancel callbacks. Finally, I tried adding these - // global keys based on a hunch that perhaps the gesture detector was - // somehow getting switched out, or assigned to a different widget, and - // that was somehow disrupting the callback series. For now, these keys - // seem to solve the problem. - final _collapsedHandleKey = GlobalKey(); - final _upstreamHandleKey = GlobalKey(); - final _downstreamHandleKey = GlobalKey(); - - late BlinkController _caretBlinkController; - Offset? _prevCaretOffset; - - static const _defaultFloatingCursorHeight = 20.0; - final _isShowingFloatingCursor = ValueNotifier(false); - final _floatingCursorKey = GlobalKey(); - Offset? _initialFloatingCursorOffset; - final _floatingCursorOffset = ValueNotifier(null); - double _floatingCursorHeight = _defaultFloatingCursorHeight; - - @override - void initState() { - super.initState(); - _caretBlinkController = BlinkController(tickerProvider: this); - _prevCaretOffset = widget.editingController.caretTop; - widget.editingController.addListener(_onEditingControllerChange); - widget.floatingCursorController.addListener(_onFloatingCursorChange); - } - - @override - void didUpdateWidget(IosDocumentTouchEditingControls oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.editingController != oldWidget.editingController) { - oldWidget.editingController.removeListener(_onEditingControllerChange); - widget.editingController.addListener(_onEditingControllerChange); - } - if (widget.floatingCursorController != oldWidget.floatingCursorController) { - oldWidget.floatingCursorController.removeListener(_onFloatingCursorChange); - widget.floatingCursorController.addListener(_onFloatingCursorChange); - } - } - - @override - void dispose() { - widget.floatingCursorController.removeListener(_onFloatingCursorChange); - widget.editingController.removeListener(_onEditingControllerChange); - _caretBlinkController.dispose(); - super.dispose(); - } - - void _onEditingControllerChange() { - if (_prevCaretOffset != widget.editingController.caretTop) { - if (widget.editingController.caretTop == null) { - _caretBlinkController.stopBlinking(); - } else { - _caretBlinkController.jumpToOpaque(); - } - - _prevCaretOffset = widget.editingController.caretTop; - } - } - - void _onFloatingCursorChange() { - if (widget.floatingCursorController.offset == null) { - if (_floatingCursorOffset.value != null) { - _isShowingFloatingCursor.value = false; - - _caretBlinkController.startBlinking(); - - _initialFloatingCursorOffset = null; - _floatingCursorOffset.value = null; - _floatingCursorHeight = _defaultFloatingCursorHeight; - - widget.onFloatingCursorStop?.call(); - } - - return; - } - - if (widget.selection.value == null) { - // The floating cursor doesn't mean anything when nothing is selected. - return; - } - - if (!widget.selection.value!.isCollapsed) { - // The selection is expanded. First we need to collapse it, then - // we can start showing the floating cursor. - widget.selection.value = widget.selection.value!.collapseDownstream(widget.document); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _onFloatingCursorChange(); - }); - } - - if (_floatingCursorOffset.value == null) { - // The floating cursor just started. - widget.onFloatingCursorStart?.call(); - } - - _caretBlinkController.stopBlinking(); - widget.editingController.hideToolbar(); - widget.editingController.hideMagnifier(); - - _initialFloatingCursorOffset ??= - widget.editingController.caretTop! + const Offset(-1, 0) + Offset(0, widget.editingController.caretHeight! / 2); - _floatingCursorOffset.value = _initialFloatingCursorOffset! + widget.floatingCursorController.offset!; - - final nearestDocPosition = widget.documentLayout.getDocumentPositionNearestToOffset(_floatingCursorOffset.value!)!; - if (nearestDocPosition.nodePosition is TextNodePosition) { - final nearestComponent = widget.documentLayout.getComponentByNodeId(nearestDocPosition.nodeId)!; - _floatingCursorHeight = nearestComponent.getRectForPosition(nearestDocPosition.nodePosition).height; - } else { - final nearestComponent = widget.documentLayout.getComponentByNodeId(nearestDocPosition.nodeId)!; - _floatingCursorHeight = (nearestComponent.context.findRenderObject() as RenderBox).size.height; - } - - widget.onFloatingCursorMoved?.call(_floatingCursorOffset.value!); - } +class _IosFloatingToolbarOverlayState extends State with SingleTickerProviderStateMixin { + final _boundsKey = GlobalKey(); @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: widget.editingController, - builder: (context) { - return Padding( - // Remove the keyboard from the space that we occupy so that - // clipping calculations apply to the expected visual borders, - // instead of applying underneath the keyboard. - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: ClipRect( - clipper: widget.createOverlayControlsClipper?.call(context), - child: SizedBox( - // ^ SizedBox tries to be as large as possible, because - // a Stack will collapse into nothing unless something - // expands it. - width: double.infinity, - height: double.infinity, - child: Stack( - children: [ - // Build caret or drag handles - ..._buildHandles(), - // Build the floating cursor - _buildFloatingCursor(), - // Build the editing toolbar - if (widget.editingController.shouldDisplayToolbar && widget.editingController.isToolbarPositioned) - _buildToolbar(), - // Build the focal point for the magnifier - if (widget.magnifierFocalPointOffset != null) _buildMagnifierFocalPoint(), - // Build the magnifier - if (widget.editingController.shouldDisplayMagnifier) _buildMagnifier(), - if (widget.showDebugPaint) - IgnorePointer( - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.yellow.withOpacity(0.2), - ), - ), - ], - ), + listenable: widget.shouldShowToolbar, + builder: (context, _) { + return Padding( + // Remove the keyboard from the space that we occupy so that + // clipping calculations apply to the expected visual borders, + // instead of applying underneath the keyboard. + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: ClipRect( + clipper: widget.createOverlayControlsClipper?.call(context), + child: SizedBox( + // ^ SizedBox tries to be as large as possible, because + // a Stack will collapse into nothing unless something + // expands it. + key: _boundsKey, + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + // Build the editing toolbar + if (widget.shouldShowToolbar.value) // + _buildToolbar(), + if (widget.showDebugPaint) // + _buildDebugPaint(), + ], ), ), - ); - }); - } - - List _buildHandles() { - if (!widget.editingController.shouldDisplayCollapsedHandle && - !widget.editingController.shouldDisplayExpandedHandles) { - editorGesturesLog.finer('Not building overlay handles because they aren\'t desired'); - return []; - } - - if (widget.editingController.shouldDisplayCollapsedHandle) { - return [ - _buildCollapsedHandle(), - ]; - } else { - return _buildExpandedHandles(); - } - } - - Widget _buildCollapsedHandle() { - return _buildHandleOld( - handleKey: _collapsedHandleKey, - handleType: HandleType.collapsed, - debugColor: Colors.blue, - ); - } - - List _buildExpandedHandles() { - return [ - // Left-bounding handle touch target - _buildHandleOld( - handleKey: _upstreamHandleKey, - handleType: HandleType.upstream, - debugColor: Colors.green, - ), - // right-bounding handle touch target - _buildHandleOld( - handleKey: _downstreamHandleKey, - handleType: HandleType.downstream, - debugColor: Colors.red, - ), - ]; - } - - Widget _buildHandleOld({ - required Key handleKey, - required HandleType handleType, - required Color debugColor, - }) { - const ballDiameter = 8.0; - - late Widget handle; - late Offset handleOffset; - switch (handleType) { - case HandleType.collapsed: - handleOffset = widget.editingController.caretTop! + const Offset(-1, 0); - handle = ValueListenableBuilder( - valueListenable: _isShowingFloatingCursor, - builder: (context, isShowingFloatingCursor, child) { - return IOSCollapsedHandle( - controller: _caretBlinkController, - color: isShowingFloatingCursor ? Colors.grey : widget.handleColor, - caretHeight: widget.editingController.caretHeight!, - ); - }, - ); - break; - case HandleType.upstream: - handleOffset = widget.editingController.upstreamHandleOffset! - - Offset(0, widget.editingController.upstreamCaretHeight!) + - const Offset(-ballDiameter / 2, -3 * ballDiameter / 4); - handle = IOSSelectionHandle.upstream( - color: widget.handleColor, - handleType: handleType, - caretHeight: widget.editingController.upstreamCaretHeight!, - ballRadius: ballDiameter / 2, - ); - break; - case HandleType.downstream: - handleOffset = widget.editingController.downstreamHandleOffset! - - Offset(0, widget.editingController.downstreamCaretHeight!) + - const Offset(-ballDiameter / 2, -3 * ballDiameter / 4); - handle = IOSSelectionHandle.upstream( - color: widget.handleColor, - handleType: handleType, - caretHeight: widget.editingController.downstreamCaretHeight!, - ballRadius: ballDiameter / 2, - ); - break; - } - - return _buildHandle( - handleKey: handleKey, - handleOffset: handleOffset, - handle: handle, - debugColor: debugColor, - ); - } - - Widget _buildHandle({ - required Key handleKey, - required Offset handleOffset, - required Widget handle, - required Color debugColor, - }) { - return CompositedTransformFollower( - key: handleKey, - link: widget.editingController.documentLayoutLink, - offset: handleOffset + const Offset(-5, 0), - child: IgnorePointer( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5), - color: widget.showDebugPaint ? Colors.green : Colors.transparent, - child: handle, - ), - ), - ); - } - - Widget _buildFloatingCursor() { - return ValueListenableBuilder( - valueListenable: _floatingCursorOffset, - builder: (context, floatingCursorOffset, child) { - if (floatingCursorOffset == null) { - return const SizedBox(); - } - - return _buildHandle( - handleKey: _floatingCursorKey, - handleOffset: floatingCursorOffset - Offset(0, _floatingCursorHeight / 2), - handle: Container( - width: 2, - height: _floatingCursorHeight, - color: Colors.red.withOpacity(0.75), ), - debugColor: Colors.blue, ); }, ); } - Widget _buildMagnifierFocalPoint() { - // When the user is dragging a handle in this overlay, we - // are responsible for positioning the focal point for the - // magnifier to follow. We do that here. - return Positioned( - left: widget.magnifierFocalPointOffset!.dx, - top: widget.magnifierFocalPointOffset!.dy, - child: CompositedTransformTarget( - link: widget.editingController.magnifierFocalPointLink, - child: const SizedBox(width: 1, height: 1), + Widget _buildToolbar() { + return FollowerFadeOutBeyondBoundary( + link: widget.toolbarFocalPoint, + boundary: WidgetFollowerBoundary( + boundaryKey: _boundsKey, ), - ); - } - - Widget _buildMagnifier() { - // Display a magnifier that tracks a focal point. - // - // When the user is dragging an overlay handle, we place a LayerLink - // target. This magnifier follows that target. - return Center( - child: IOSFollowingMagnifier.roundedRectangle( - layerLink: widget.editingController.magnifierFocalPointLink, - offsetFromFocalPoint: const Offset(0, -72), + child: Follower.withAligner( + link: widget.toolbarFocalPoint, + aligner: CupertinoPopoverToolbarAligner(), + boundary: WidgetFollowerBoundary( + boundaryKey: _boundsKey, + ), + child: widget.floatingToolbarBuilder(context, DocumentKeys.mobileToolbar, widget.toolbarFocalPoint), ), ); } - Widget _buildToolbar() { - // TODO: figure out why this approach works. Why isn't the text field's - // RenderBox offset stale when the keyboard opens or closes? Shouldn't - // we end up with the previous offset because no rebuild happens? - // - // Dis-proven theory: CompositedTransformFollower's link causes a rebuild of its - // subtree whenever the linked transform changes. - // - // Theory: - // - Keyboard only effects vertical offsets, so global x offset - // was never at risk - // - The global y offset isn't used in the calculation at all - // - If this same approach were used in a situation where the - // distance between the left edge of the available space and the - // text field changed, I think it would fail. - return CustomSingleChildLayout( - delegate: ToolbarPositionDelegate( - // TODO: handle situation where document isn't full screen - textFieldGlobalOffset: Offset.zero, - desiredTopAnchorInTextField: widget.editingController.toolbarTopAnchor!, - desiredBottomAnchorInTextField: widget.editingController.toolbarBottomAnchor!, - ), - child: IgnorePointer( - ignoring: !widget.editingController.shouldDisplayToolbar, - child: AnimatedOpacity( - opacity: widget.editingController.shouldDisplayToolbar ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), - child: Builder(builder: (context) { - return widget.popoverToolbarBuilder(context); - }), - ), + Widget _buildDebugPaint() { + return IgnorePointer( + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.yellow.withValues(alpha: 0.2), ), ); } @@ -477,22 +130,13 @@ class _IosDocumentTouchEditingControlsState extends State _documentLayoutLink; - final LayerLink _documentLayoutLink; + required super.selectionLinks, + required super.magnifierFocalPointLink, + required super.overlayController, + }); /// Whether or not a caret should be displayed. bool get hasCaret => caretTop != null; @@ -599,16 +243,694 @@ class IosDocumentGestureEditingController extends MagnifierAndToolbarController notifyListeners(); } } + + final _magnifierLink = LayerLink(); + + @override + void showMagnifier() { + _newMagnifierLink = _magnifierLink; + super.showMagnifier(); + } + + @override + void hideMagnifier() { + _newMagnifierLink = null; + super.hideMagnifier(); + } + + LayerLink? get newMagnifierLink => _newMagnifierLink; + LayerLink? _newMagnifierLink; + set newMagnifierLink(LayerLink? link) { + if (_newMagnifierLink == link) { + return; + } + + _newMagnifierLink = link; + notifyListeners(); + } } -class FloatingCursorController with ChangeNotifier { +class FloatingCursorController { + void dispose() { + isActive.dispose(); + isNearText.dispose(); + cursorGeometryInViewport.dispose(); + _listeners.clear(); + } + + /// Whether the user is currently interacting with the floating cursor via the + /// software keyboard. + final isActive = ValueNotifier(false); + + /// Whether the floating cursor is currently near text, which impacts whether + /// or not a standard gray caret should be displayed. + final isNearText = ValueNotifier(false); + + /// The offset, width, and height of the active floating cursor in viewport coordinates. + final cursorGeometryInViewport = ValueNotifier(null); + + /// The offset, width, and height of the active floating cursor in document coordinates. + final cursorGeometryInDocument = ValueNotifier(null); + + /// Report that the user has activated the floating cursor. + void onStart() { + isActive.value = true; + for (final listener in _listeners) { + listener.onStart(); + } + } + Offset? get offset => _offset; Offset? _offset; - set offset(Offset? newOffset) { + + /// Report that the user has moved the floating cursor. + void onMove(Offset? newOffset) { if (newOffset == _offset) { return; } _offset = newOffset; - notifyListeners(); + + for (final listener in _listeners) { + listener.onMove(newOffset); + } + } + + /// Report that the user has deactivated the floating cursor. + void onStop() { + isActive.value = false; + for (final listener in _listeners) { + listener.onStop(); + } + } + + final _listeners = {}; + + void addListener(FloatingCursorListener listener) { + _listeners.add(listener); + } + + void removeListener(FloatingCursorListener listener) { + _listeners.remove(listener); + } +} + +class FloatingCursorListener { + FloatingCursorListener({ + VoidCallback? onStart, + void Function(Offset?)? onMove, + VoidCallback? onStop, + }) : _onStart = onStart, + _onMove = onMove, + _onStop = onStop; + + final VoidCallback? _onStart; + final void Function(Offset?)? _onMove; + final VoidCallback? _onStop; + + void onStart() => _onStart?.call(); + + void onMove(Offset? newOffset) => _onMove?.call(newOffset); + + void onStop() => _onStop?.call(); +} + +/// A document layer that positions a leader widget around the user's selection, +/// as a focal point for an iOS-style toolbar display. +/// +/// By default, the toolbar focal point [LeaderLink] is obtained from an ancestor +/// [SuperEditorIosControlsScope]. +class IosToolbarFocalPointDocumentLayer extends DocumentLayoutLayerStatefulWidget { + const IosToolbarFocalPointDocumentLayer({ + Key? key, + required this.document, + required this.selection, + required this.toolbarFocalPointLink, + this.showDebugLeaderBounds = false, + }) : super(key: key); + + /// The editor's [Document], which is used to find the start and end of + /// the user's expanded selection. + final Document document; + + /// The current user's selection within a document. + final ValueListenable selection; + + /// The [LeaderLink], which is attached to the toolbar focal point bounds. + /// + /// By default, this [LeaderLink] is obtained from an ancestor [SuperEditorIosControlsScope]. + /// If [toolbarFocalPointLink] is non-null, it's used instead of the ancestor value. + final LeaderLink toolbarFocalPointLink; + + /// Whether to paint colorful bounds around the leader widgets, for debugging purposes. + final bool showDebugLeaderBounds; + + @override + DocumentLayoutLayerState createState() => _IosToolbarFocalPointDocumentLayerState(); +} + +class _IosToolbarFocalPointDocumentLayerState extends DocumentLayoutLayerState + with SingleTickerProviderStateMixin { + DocumentSelection? _selectionUsedForMostRecentLayout; + + @override + void initState() { + super.initState(); + + widget.selection.addListener(_onSelectionChange); + } + + @override + void didUpdateWidget(IosToolbarFocalPointDocumentLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + } + } + + @override + void dispose() { + widget.selection.removeListener(_onSelectionChange); + + super.dispose(); + } + + void _onSelectionChange() { + final selection = widget.selection.value; + if (selection == _selectionUsedForMostRecentLayout) { + // The selection didn't change from what it was the last time we calculated selection bounds. + return; + } + _selectionUsedForMostRecentLayout = selection; + + // The selection changed, which means the selection bounds changed, we need to recalculate the + // toolbar focal point bounds. + setStateAsSoonAsPossible(() { + // The selection bounds, and Leader build, will take place in methods that + // run in response to setState(). + }); + } + + @override + Rect? computeLayoutDataWithDocumentLayout( + BuildContext contentLayersContext, BuildContext documentContext, DocumentLayout documentLayout) { + final documentSelection = widget.selection.value; + if (documentSelection == null) { + return null; + } + + final selectedComponent = documentLayout.getComponentByNodeId(widget.selection.value!.extent.nodeId); + if (selectedComponent == null) { + // Assume that we're in a momentary transitive state where the document layout + // just gained or lost a component. We expect this method to run again in a moment + // to correct for this. + return null; + } + + return documentLayout.getRectForSelection( + documentSelection.base, + documentSelection.extent, + ); + } + + @override + Widget doBuild(BuildContext context, Rect? selectionBounds) { + if (selectionBounds == null) { + return const SizedBox(); + } + + return IgnorePointer( + child: Stack( + children: [ + Positioned.fromRect( + rect: selectionBounds, + child: Leader( + link: widget.toolbarFocalPointLink, + child: widget.showDebugLeaderBounds + ? DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + width: 4, + color: const Color(0xFFFF00FF), + ), + ), + ) + : null, + ), + ), + ], + ), + ); + } +} + +/// A document layer that displays an iOS-style caret and handles. +/// +/// This layer positions the caret and handles directly, rather than using +/// `Leader`s and `Follower`s, because their position is based on the document +/// layout, rather than the user's gesture behavior. +class IosHandlesDocumentLayer extends DocumentLayoutLayerStatefulWidget { + const IosHandlesDocumentLayer({ + super.key, + required this.document, + required this.documentLayout, + required this.selection, + required this.changeSelection, + this.areSelectionHandlesAllowed, + this.handleBeingDragged, + required this.handleColor, + this.caretWidth = 2, + this.handleBallDiameter = defaultIosHandleBallDiameter, + required this.shouldCaretBlink, + this.floatingCursorController, + this.showDebugPaint = false, + }); + + final Document document; + + final DocumentLayout documentLayout; + + final ValueListenable selection; + + final void Function(DocumentSelection?, SelectionChangeType, String selectionReason) changeSelection; + + /// {@macro are_selection_handles_allowed} + final ValueListenable? areSelectionHandlesAllowed; + + /// Reports the [HandleType] of the handle being dragged by the user. + /// + /// If no drag handle is being dragged, this value is `null`. + final ValueListenable? handleBeingDragged; + + /// Color the iOS-style text selection drag handles. + final Color handleColor; + + final double caretWidth; + + /// The diameter of the small circle that appears on the top and bottom of + /// expanded iOS text handles. + final double handleBallDiameter; + + /// Whether the caret should blink, whenever the caret is visible. + final ValueListenable shouldCaretBlink; + + /// Floating cursor state, used to determine when the floating cursor is active, + /// during which the regular caret is either hidden, or is displayed as a gray + /// caret when the floating cursor is far away from its nearest text. + final FloatingCursorController? floatingCursorController; + + final bool showDebugPaint; + + @override + DocumentLayoutLayerState createState() => + IosControlsDocumentLayerState(); +} + +@visibleForTesting +class IosControlsDocumentLayerState extends DocumentLayoutLayerState + with SingleTickerProviderStateMixin { + // These global keys are assigned to each draggable handle to + // prevent a strange dragging issue. + // + // Without these keys, if the user drags into the auto-scroll area + // for a period of time, we never receive a + // "pan end" or "pan cancel" callback. I have no idea why this is + // the case. These handles sit in an Overlay, so it's not as if they + // suffered some conflict within a ScrollView. I tried many adjustments + // to recover the end/cancel callbacks. Finally, I tried adding these + // global keys based on a hunch that perhaps the gesture detector was + // somehow getting switched out, or assigned to a different widget, and + // that was somehow disrupting the callback series. For now, these keys + // seem to solve the problem. + final _collapsedHandleKey = GlobalKey(); + final _upstreamHandleKey = GlobalKey(); + final _downstreamHandleKey = GlobalKey(); + + late BlinkController _caretBlinkController; + + @override + void initState() { + super.initState(); + _caretBlinkController = BlinkController.withTimer(); + + widget.selection.addListener(_onSelectionChange); + widget.shouldCaretBlink.addListener(_onBlinkModeChange); + widget.floatingCursorController?.isActive.addListener(_onFloatingCursorActivationChange); + widget.handleBeingDragged?.addListener(_onDragChanged); + + _onBlinkModeChange(); + } + + @override + void didUpdateWidget(IosHandlesDocumentLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + } + + if (widget.shouldCaretBlink != oldWidget.shouldCaretBlink) { + oldWidget.shouldCaretBlink.removeListener(_onBlinkModeChange); + widget.shouldCaretBlink.addListener(_onBlinkModeChange); + } + + if (widget.floatingCursorController != oldWidget.floatingCursorController) { + oldWidget.floatingCursorController?.isActive.removeListener(_onFloatingCursorActivationChange); + widget.floatingCursorController?.isActive.addListener(_onFloatingCursorActivationChange); + } + + if (widget.handleBeingDragged != oldWidget.handleBeingDragged) { + oldWidget.handleBeingDragged?.removeListener(_onDragChanged); + widget.handleBeingDragged?.addListener(_onDragChanged); + } + } + + @override + void dispose() { + widget.selection.removeListener(_onSelectionChange); + widget.shouldCaretBlink.removeListener(_onBlinkModeChange); + widget.floatingCursorController?.isActive.removeListener(_onFloatingCursorActivationChange); + widget.handleBeingDragged?.removeListener(_onDragChanged); + + _caretBlinkController.dispose(); + super.dispose(); + } + + @visibleForTesting + Rect? get caret => layoutData?.caret; + + @visibleForTesting + Color get caretColor => widget.handleColor; + + @visibleForTesting + bool get isCaretDisplayed => layoutData?.caret != null; + + @visibleForTesting + bool get isCaretVisible => _caretBlinkController.opacity == 1.0 && isCaretDisplayed; + + @visibleForTesting + Duration get caretFlashPeriod => _caretBlinkController.flashPeriod; + + @visibleForTesting + bool get isUpstreamHandleDisplayed => layoutData?.upstream != null; + + @visibleForTesting + bool get isDownstreamHandleDisplayed => layoutData?.downstream != null; + + void _onSelectionChange() { + _updateCaretFlash(); + setState(() { + // Schedule a new layout computation because the caret and/or handles need to move. + }); + } + + void _onDragChanged() { + setState(() { + // Schedule a new layout computation because we might need to hide/show the handle ball. + }); + } + + void _updateCaretFlash() { + _caretBlinkController.jumpToOpaque(); + _startOrStopBlinking(); + } + + void _startOrStopBlinking() { + // TODO: allow a configurable policy as to whether to show the caret at all when the selection is expanded: https://github.com/superlistapp/super_editor/issues/234 + final wantsToBlink = widget.selection.value != null && widget.shouldCaretBlink.value; + if (wantsToBlink && _caretBlinkController.isBlinking) { + return; + } + if (!wantsToBlink && !_caretBlinkController.isBlinking) { + return; + } + + wantsToBlink // + ? _caretBlinkController.startBlinking() + : _caretBlinkController.stopBlinking(); + } + + void _onBlinkModeChange() { + if (widget.shouldCaretBlink.value) { + _caretBlinkController.startBlinking(); + } else { + _caretBlinkController.stopBlinking(); + } + } + + void _onFloatingCursorActivationChange() { + if (widget.floatingCursorController?.isActive.value == true) { + _caretBlinkController.stopBlinking(); + } else { + _caretBlinkController.startBlinking(); + } + } + + /// Computes a zero width `Rect` that represents the x and y offsets and the height + /// of the upstream or downstream handle in content space. + /// + /// The `Rect` returned by [DocumentLayout.getRectForPosition] doesn't match the + /// top and bottom of the selection hightlight box. This method computes an + /// expanded selection based on the given [position], computes the box for that + /// selection, and returns the edge of the selection box. + Rect _computeRectForExpandedHandle(DocumentPosition position) { + final component = widget.documentLayout.getComponentByNodeId(position.nodeId); + if (component == null) { + return Rect.zero; + } + + // Check if we have a position to the right of the current position within the same node. + NodePosition? extentNodePosition = component.movePositionRight(position.nodePosition); + bool isExtentDownstream = extentNodePosition != null; + + if (extentNodePosition == null) { + // Couldn't find a valid position to the right. Look for a position to the left + // of the current position within the same node. + extentNodePosition = component.movePositionLeft(position.nodePosition); + } + + if (extentNodePosition == null) { + // We couldn't expand the selection neither to the left of the right. Fallback + // to rect for the position, which relies on Flutter's computation for the + // caret offset and height. Flutter's computation produces different offset + // a height from what is returned by the selection highlight box. + return widget.documentLayout.getRectForPosition(position)!; + } + + final rectForSelection = widget.documentLayout.getRectForSelection( + position, + DocumentPosition( + nodeId: position.nodeId, + nodePosition: extentNodePosition, + ), + )!; + + return Rect.fromLTWH( + isExtentDownstream ? rectForSelection.left : rectForSelection.right, + rectForSelection.top, + 0, + rectForSelection.bottom - rectForSelection.top, + ); + } + + @override + DocumentSelectionLayout? computeLayoutDataWithDocumentLayout( + BuildContext contentLayersContext, BuildContext documentContext, DocumentLayout documentLayout) { + final selection = widget.selection.value; + if (selection == null) { + return null; + } + + if (widget.areSelectionHandlesAllowed?.value == false) { + /// We don't want to show any selection handles. + return null; + } + + if (selection.isCollapsed) { + Rect caretRect = documentLayout.getEdgeForPosition(selection.extent)!; + + // Default caret width used by IOSCollapsedHandle. + const caretWidth = 2; + + // Use the content's RenderBox instead of the layer's RenderBox to get the layer's width. + // + // ContentLayers works in four steps: + // + // 1. The content is built. + // 2. The content is laid out. + // 3. The layers are built. + // 4. The layers are laid out. + // + // The computeLayoutData method is called during the layer's build, which means that the + // layer's RenderBox is outdated, because it wasn't laid out yet for the current frame. + // Use the content's RenderBox, which was already laid out for the current frame. + final contentBox = documentContext.findRenderObject(); + if (contentBox != null) { + double? contentWidth; + if (contentBox is RenderSliver && contentBox.hasSize && caretRect.left + caretWidth >= contentBox.size.width) { + contentWidth = contentBox.size.width; + } else if (contentBox is RenderBox && + contentBox.hasSize && + caretRect.left + caretWidth >= contentBox.size.width) { + contentWidth = contentBox.size.width; + } + + if (contentWidth != null) { + // Adjust the caret position to make it entirely visible because it's currently placed + // partially or entirely outside of the layers' bounds. This can happen for downstream selections + // of block components that take all the available width. + caretRect = Rect.fromLTWH( + contentWidth - caretWidth, + caretRect.top, + caretRect.width, + caretRect.height, + ); + } + } + + return DocumentSelectionLayout( + caret: caretRect, + ); + } else { + return DocumentSelectionLayout( + upstream: _computeRectForExpandedHandle( + widget.document.selectUpstreamPosition(selection.base, selection.extent), + ), + downstream: _computeRectForExpandedHandle( + widget.document.selectDownstreamPosition(selection.base, selection.extent), + ), + expandedSelectionBounds: documentLayout.getRectForSelection( + selection.base, + selection.extent, + ), + ); + } + } + + @override + Widget doBuild(BuildContext context, DocumentSelectionLayout? layoutData) { + return IgnorePointer( + child: SizedBox.expand( + child: layoutData != null // + ? _buildHandles(layoutData) + : const SizedBox(), + ), + ); + } + + Widget _buildHandles(DocumentSelectionLayout layoutData) { + if (widget.selection.value == null) { + editorGesturesLog.finer("Not building overlay handles because there's no selection."); + return const SizedBox.shrink(); + } + + return Stack( + clipBehavior: Clip.none, + children: [ + if (layoutData.caret != null) // + _buildCollapsedHandle(caret: layoutData.caret!), + if (layoutData.upstream != null && layoutData.downstream != null) ...[ + _buildUpstreamHandle( + upstream: layoutData.upstream!, + debugColor: Colors.green, + ), + _buildDownstreamHandle( + downstream: layoutData.downstream!, + debugColor: Colors.red, + ), + ], + ], + ); + } + + Widget _buildCollapsedHandle({ + required Rect caret, + }) { + return Positioned( + key: _collapsedHandleKey, + left: caret.left, + top: caret.top, + child: MultiListenableBuilder( + listenables: { + if (widget.floatingCursorController != null) ...{ + widget.floatingCursorController!.isActive, + widget.floatingCursorController!.isNearText, + } + }, + builder: (context) { + final isShowingFloatingCursor = widget.floatingCursorController?.isActive.value == true; + final isNearText = widget.floatingCursorController?.isNearText.value == true; + if (isShowingFloatingCursor && isNearText) { + // The floating cursor is active and it's near some text. We don't want to + // paint a collapsed handle/caret. + return const SizedBox(); + } + + return IOSCollapsedHandle( + key: DocumentKeys.caret, + controller: _caretBlinkController, + color: isShowingFloatingCursor ? Colors.grey : widget.handleColor, + caretHeight: caret.height, + caretWidth: widget.caretWidth, + ); + }, + ), + ); + } + + Widget _buildUpstreamHandle({ + required Rect upstream, + required Color debugColor, + }) { + final shouldShowBall = widget.handleBeingDragged?.value != HandleType.upstream; + final ballRadius = shouldShowBall ? widget.handleBallDiameter / 2 : 0.0; + return Positioned( + key: _upstreamHandleKey, + left: upstream.left, + // Move the handle up so the ball is above the selected area and add half + // of the radius to make the ball overlap the selected area. + top: upstream.top - + selectionHighlightBoxVerticalExpansion + + (shouldShowBall ? (ballRadius / 2) - widget.handleBallDiameter : 0.0), + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: IOSSelectionHandle.upstream( + key: DocumentKeys.upstreamHandle, + color: widget.handleColor, + handleType: HandleType.upstream, + caretHeight: upstream.height + (selectionHighlightBoxVerticalExpansion * 2) - (ballRadius / 2), + caretWidth: widget.caretWidth, + ballRadius: ballRadius, + ), + ), + ); + } + + Widget _buildDownstreamHandle({ + required Rect downstream, + required Color debugColor, + }) { + final ballRadius = widget.handleBeingDragged?.value == HandleType.downstream // + ? 0.0 + : widget.handleBallDiameter / 2; + + return Positioned( + key: _downstreamHandleKey, + left: downstream.left, + top: downstream.top - selectionHighlightBoxVerticalExpansion, + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: IOSSelectionHandle.downstream( + key: DocumentKeys.downstreamHandle, + color: widget.handleColor, + handleType: HandleType.downstream, + caretHeight: downstream.height + (selectionHighlightBoxVerticalExpansion * 2) - (ballRadius / 2), + caretWidth: widget.caretWidth, + ballRadius: ballRadius, + ), + ), + ); } } diff --git a/super_editor/lib/src/infrastructure/platforms/ios/ios_system_context_menu.dart b/super_editor/lib/src/infrastructure/platforms/ios/ios_system_context_menu.dart new file mode 100644 index 0000000000..ca6a345c93 --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/ios/ios_system_context_menu.dart @@ -0,0 +1,108 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; + +/// Displays the iOS system context menu on top of the Flutter view. +/// +/// This class was copied and adjusted from Flutter's [SystemContextMenu]. +/// +/// Currently, only supports iOS 16.0 and above. +/// +/// The context menu is the menu that appears, for example, when doing text +/// selection. Flutter typically draws this menu itself, but this class deals +/// with the platform-rendered context menu instead. +/// +/// There can only be one system context menu visible at a time. Building this +/// widget when the system context menu is already visible will hide the old one +/// and display this one. A system context menu that is hidden is informed via +/// [onSystemHide]. +/// +/// To check if the current device supports showing the system context menu, +/// call [isSupported]. +/// +/// See also: +/// +/// * [SystemContextMenuController], which directly controls the hiding and +/// showing of the system context menu. +class IOSSystemContextMenu extends StatefulWidget { + /// Whether the current device supports showing the system context menu. + /// + /// Currently, this is only supported on iOS 16.0 and above. + static bool isSupported(BuildContext context) { + return MediaQuery.maybeSupportsShowingSystemContextMenu(context) ?? false; + } + + const IOSSystemContextMenu({ + super.key, + required this.leaderLink, + this.onSystemHide, + }); + + /// A [LeaderLink] attached to the widget that determines the position + /// of the system context menu. + final LeaderLink leaderLink; + + /// Called when the system hides this context menu. + /// + /// For example, tapping outside of the context menu typically causes the + /// system to hide the menu. + /// + /// This is not called when showing a new system context menu causes another + /// to be hidden. + final VoidCallback? onSystemHide; + + @override + State createState() => _IOSSystemContextMenuState(); +} + +class _IOSSystemContextMenuState extends State { + late final SystemContextMenuController _systemContextMenuController; + + @override + void initState() { + super.initState(); + _systemContextMenuController = SystemContextMenuController( + onSystemHide: widget.onSystemHide, + ); + widget.leaderLink.addListener(_onLeaderChanged); + onNextFrame((_) => _positionSystemMenu()); + } + + @override + void didUpdateWidget(IOSSystemContextMenu oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.leaderLink != oldWidget.leaderLink) { + oldWidget.leaderLink.removeListener(_onLeaderChanged); + widget.leaderLink.addListener(_onLeaderChanged); + onNextFrame((_) => _positionSystemMenu()); + } + } + + @override + void dispose() { + widget.leaderLink.removeListener(_onLeaderChanged); + _systemContextMenuController.dispose(); + super.dispose(); + } + + void _onLeaderChanged() { + if (widget.leaderLink.offset == null || widget.leaderLink.leaderSize == null) { + return; + } + + onNextFrame((_) { + _positionSystemMenu(); + }); + } + + void _positionSystemMenu() { + _systemContextMenuController.show(widget.leaderLink.offset! & widget.leaderLink.leaderSize!); + } + + @override + Widget build(BuildContext context) { + assert(IOSSystemContextMenu.isSupported(context)); + return const SizedBox.shrink(); + } +} diff --git a/super_editor/lib/src/infrastructure/platforms/ios/long_press_selection.dart b/super_editor/lib/src/infrastructure/platforms/ios/long_press_selection.dart new file mode 100644 index 0000000000..405b7cfd16 --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/ios/long_press_selection.dart @@ -0,0 +1,149 @@ +import 'package:flutter/rendering.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// A strategy for selecting text during a long-press drag gesture, similar to +/// how iOS selects text during a long-press drag. +/// +/// This strategy is made to operate over a document layout. +/// +/// This strategy is expected to be identical to iOS. If differences are found, +/// they should be logged as bugs. +class IosLongPressSelectionStrategy { + IosLongPressSelectionStrategy({ + required Document document, + required DocumentLayout documentLayout, + required void Function(DocumentSelection) select, + }) : _document = document, + _docLayout = documentLayout, + _select = select; + + final Document _document; + final DocumentLayout _docLayout; + final void Function(DocumentSelection) _select; + + /// The word the user initially selects upon long-pressing. + DocumentSelection? _longPressInitialSelection; + + /// Clients should call this method when a long press gesture is initially + /// recognized. + /// + /// Returns `true` if a long-press selection started, or `false` if the user's + /// press didn't occur over selectable content. + bool onLongPressStart({ + required Offset tapDownDocumentOffset, + }) { + longPressSelectionLog.fine("Long press start"); + final docPosition = _docLayout.getDocumentPositionNearestToOffset(tapDownDocumentOffset); + if (docPosition == null) { + longPressSelectionLog.finer("No doc position where the user pressed"); + return false; + } + + if (docPosition.nodePosition is! TextNodePosition) { + // Select the whole node. + _longPressInitialSelection = DocumentSelection( + base: DocumentPosition( + nodeId: docPosition.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: docPosition.nodeId, + nodePosition: const UpstreamDownstreamNodePosition.downstream(), + ), + ); + } else { + _longPressInitialSelection = getWordSelection(docPosition: docPosition, docLayout: _docLayout); + } + + _select(_longPressInitialSelection!); + return true; + } + + /// Clients should call this method when an existing long-press gesture first + /// begins to pan. + /// + /// Upon long-press pan movements, clients should call [onLongPressDragUpdate]. + void onLongPressDragStart() { + longPressSelectionLog.fine("Long press drag start"); + } + + /// Clients should call this method whenever a long-press gesture pans, after + /// initially calling [onLongPressStart]. + void onLongPressDragUpdate(Offset fingerDocumentOffset, DocumentPosition? fingerDocumentPosition) { + longPressSelectionLog.finer("--------------------------------------------"); + longPressSelectionLog.fine("Long press drag update"); + + if (fingerDocumentPosition == null) { + return; + } + + final isOverNonTextNode = fingerDocumentPosition.nodePosition is! TextNodePosition; + if (isOverNonTextNode) { + // Don't change selection if the user long-presses over a non-text node and then + // moves the finger over the same node. This prevents the selection from collapsing + // when the user moves the finger towards the starting edge of the node. + if (fingerDocumentPosition.nodeId != _longPressInitialSelection!.base.nodeId) { + // The user is dragging over content that isn't text, therefore it doesn't have + // a concept of "words". Select the whole node. + _select(_longPressInitialSelection!.expandTo(fingerDocumentPosition)); + } + return; + } + + // In the case of long-press dragging, we select by word, and the base/extent + // of the selection depends on whether the user drags upstream or downstream + // from the originally selected word. + // + // Examples: + // - one two th|ree four five + // - one two [three] four five + // - one [two three] four five + // - one two [three four] five + final wordUnderFinger = getWordSelection(docPosition: fingerDocumentPosition, docLayout: _docLayout); + if (wordUnderFinger == null) { + // This shouldn't happen. If we've gotten here, the user is selecting over + // text content but we couldn't find a word selection. The best we can do + // is fizzle. + longPressSelectionLog.warning("Long-press selecting. Couldn't find word at position: $fingerDocumentPosition"); + return; + } + + if (wordUnderFinger == _longPressInitialSelection) { + // The user is on the original word. Nothing more to do. + _select(_longPressInitialSelection!); + return; + } + + // Figure out whether the newly selected word comes before or after the initially + // selected word. + final newWordDirection = _document.getAffinityForSelection( + DocumentSelection( + base: wordUnderFinger.start, + extent: _longPressInitialSelection!.start, + ), + ); + + late final DocumentSelection newSelection; + if (newWordDirection == TextAffinity.downstream) { + // The newly selected word comes before the initially selected word. + newSelection = DocumentSelection(base: wordUnderFinger.start, extent: _longPressInitialSelection!.end); + } else { + // The newly selected word comes after the initially selected word. + newSelection = DocumentSelection(base: _longPressInitialSelection!.start, extent: wordUnderFinger.end); + } + + _select(newSelection); + } + + /// Clients should call this method when a long-press drag ends, or is cancelled. + void onLongPressEnd() { + longPressSelectionLog.fine("Long press end"); + _longPressInitialSelection = null; + } +} diff --git a/super_editor/lib/src/infrastructure/platforms/ios/magnifier.dart b/super_editor/lib/src/infrastructure/platforms/ios/magnifier.dart index dceb4b2a8b..90dcfa92ed 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/magnifier.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/magnifier.dart @@ -1,96 +1,229 @@ -import 'package:flutter/widgets.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/magnifier.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/outer_box_shadow.dart'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/outer_box_shadow.dart'; +import 'package:super_editor/super_editor.dart'; /// An iOS magnifying glass that follows a [LayerLink]. -class IOSFollowingMagnifier extends StatelessWidget { +class IOSFollowingMagnifier extends StatefulWidget { const IOSFollowingMagnifier.roundedRectangle({ Key? key, - required this.layerLink, + this.magnifierKey, + required this.leaderLink, + this.show = true, this.offsetFromFocalPoint = Offset.zero, + this.handleColor, }) : magnifierBuilder = _roundedRectangleMagnifierBuilder; const IOSFollowingMagnifier.circle({ Key? key, - required this.layerLink, + this.magnifierKey, + required this.leaderLink, + this.show = true, this.offsetFromFocalPoint = Offset.zero, + this.handleColor, }) : magnifierBuilder = _circleMagnifierBuilder; const IOSFollowingMagnifier({ Key? key, - required this.layerLink, + this.magnifierKey, + required this.leaderLink, + this.show = true, this.offsetFromFocalPoint = Offset.zero, + this.handleColor, required this.magnifierBuilder, }) : super(key: key); - final LayerLink layerLink; + final Key? magnifierKey; + final LeaderLink leaderLink; + final bool show; + + /// The distance, in density independent pixels, from the focal point to the magnifier. final Offset offsetFromFocalPoint; + + final Color? handleColor; final MagnifierBuilder magnifierBuilder; + @override + State createState() => _IOSFollowingMagnifierState(); +} + +class _IOSFollowingMagnifierState extends State with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + + /// Wether or not the magnifier should be displayed. + /// + /// The magnifier can still be displayed event when [widget.show] is `false` + /// because the magnifier should be visible during the exit animation. + bool get _shouldShowMagnifier => widget.show || _animationController.status != AnimationStatus.dismissed; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: defaultIosMagnifierEnterAnimationDuration, + reverseDuration: defaultIosMagnifierExitAnimationDuration, + ); + } + + @override + void didUpdateWidget(IOSFollowingMagnifier oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.show != oldWidget.show) { + _onWantsToShowMagnifierChanged(); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _onWantsToShowMagnifierChanged() { + if (widget.show) { + _animationController.forward(); + } else { + // The desire to show the magnifier changed from visible to invisible. Run the exit + // animation and set the magnifier to invisible when the animation finishes. + _animationController.reverse(); + } + } + @override Widget build(BuildContext context) { - return CompositedTransformFollower( - link: layerLink, - offset: offsetFromFocalPoint, - child: FractionalTranslation( - translation: const Offset(-0.5, -0.5), - child: magnifierBuilder( - context, - offsetFromFocalPoint, - ), - ), + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + if (!_shouldShowMagnifier) { + return const SizedBox(); + } + + final percentage = _animationController.value; + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + + return Follower.withOffset( + link: widget.leaderLink, + showWhenUnlinked: false, + // Center-align the magnifier with the focal point, so when the animation starts + // the magnifier is displayed in the same position as the focal point. + leaderAnchor: Alignment.center, + followerAnchor: Alignment.center, + offset: Offset( + widget.offsetFromFocalPoint.dx * devicePixelRatio, + // Animate the magnfier up on entrance and down on exit. + widget.offsetFromFocalPoint.dy * devicePixelRatio * percentage, + ), + boundary: const ScreenFollowerBoundary(), + child: widget.magnifierBuilder( + context, + IosMagnifierViewModel( + offsetFromFocalPoint: Offset( + widget.offsetFromFocalPoint.dx * percentage, + widget.offsetFromFocalPoint.dy * percentage, + ), + animationValue: _animationController.value, + animationDirection: + const [AnimationStatus.forward, AnimationStatus.completed].contains(_animationController.status) + ? AnimationDirection.forward + : AnimationDirection.reverse, + borderColor: widget.handleColor ?? Theme.of(context).primaryColor, + ), + widget.magnifierKey, + ), + ); + }, ); } } -typedef MagnifierBuilder = Widget Function(BuildContext, Offset offsetFromFocalPoint); +typedef MagnifierBuilder = Widget Function(BuildContext, IosMagnifierViewModel magnifierInfo, [Key? magnifierKey]); -Widget _roundedRectangleMagnifierBuilder(BuildContext context, Offset offsetFromFocalPoint) => +Widget _roundedRectangleMagnifierBuilder(BuildContext context, IosMagnifierViewModel magnifierInfo, + [Key? magnifierKey]) => IOSRoundedRectangleMagnifyingGlass( - offsetFromFocalPoint: offsetFromFocalPoint, + key: magnifierKey, + offsetFromFocalPoint: magnifierInfo.offsetFromFocalPoint, + animationValue: magnifierInfo.animationValue, + borderColor: magnifierInfo.borderColor, ); -Widget _circleMagnifierBuilder(BuildContext context, Offset offsetFromFocalPoint) => IOSCircleMagnifyingGlass( - offsetFromFocalPoint: offsetFromFocalPoint, +Widget _circleMagnifierBuilder(BuildContext context, IosMagnifierViewModel magnifierInfo, [Key? magnifierKey]) => + IOSCircleMagnifyingGlass( + key: magnifierKey, + offsetFromFocalPoint: magnifierInfo.offsetFromFocalPoint, ); class IOSRoundedRectangleMagnifyingGlass extends StatelessWidget { - static const _magnification = 1.0; + static const _magnification = 1.5; const IOSRoundedRectangleMagnifyingGlass({ + super.key, this.offsetFromFocalPoint = Offset.zero, + this.animationValue = 1.0, + required this.borderColor, }); + /// The distance, in density independent pixels, from the focal point to the magnifier. final Offset offsetFromFocalPoint; + final double animationValue; + final Color borderColor; @override Widget build(BuildContext context) { - return Stack( - children: [ - MagnifyingGlass( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(24)), - ), - size: const Size(72, 48), - offsetFromFocalPoint: offsetFromFocalPoint, - magnificationScale: _magnification, - ), - Container( - width: 72, - height: 48, - decoration: const ShapeDecoration( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(24)), - ), - shadows: [ - OuterBoxShadow( - color: Color(0x33000000), - blurRadius: 4, + final percent = defaultIosMagnifierAnimationCurve.transform(animationValue); + + final height = lerpDouble(30, defaultIosMagnifierSize.height, percent)!; + final width = lerpDouble(4, defaultIosMagnifierSize.width, percent)!; + final size = Size(width, height); + + final tintOpacity = 1.0 - Curves.easeIn.transform(animationValue); + final borderRadius = lerpDouble(30.0, 50.0, percent)!; + final borderWidth = lerpDouble(15.0, 3.0, percent)!; + + return SizedBox( + width: size.width, + height: size.height, + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), + child: Stack( + children: [ + if (percent >= 0.3) + MagnifyingGlass( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), + ), + size: size, + offsetFromFocalPoint: Offset(offsetFromFocalPoint.dx, offsetFromFocalPoint.dy), + magnificationScale: _magnification, ), - ], - ), + Opacity( + opacity: Curves.easeOutQuint.transform(percent), + child: Container( + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(borderRadius)), + side: BorderSide( + color: borderColor, + width: borderWidth, + ), + ), + color: borderColor.withValues(alpha: tintOpacity), + shadows: const [ + OuterBoxShadow( + color: Color(0x33000000), + blurRadius: 4, + ), + ], + ), + ), + ), + ], ), - ], + ), ); } } @@ -100,9 +233,11 @@ class IOSCircleMagnifyingGlass extends StatelessWidget { static const _magnification = 2.0; const IOSCircleMagnifyingGlass({ + super.key, this.offsetFromFocalPoint = Offset.zero, }); + /// The distance, in density independent pixels, from the focal point to the magnifier. final Offset offsetFromFocalPoint; @override @@ -145,3 +280,22 @@ class IOSCircleMagnifyingGlass extends StatelessWidget { ); } } + +/// Parameters used to render an iOS magnifier. +class IosMagnifierViewModel { + IosMagnifierViewModel({ + required this.offsetFromFocalPoint, + this.animationValue = 1.0, + this.animationDirection = AnimationDirection.forward, + required this.borderColor, + }); + + /// The distance, in density independent pixels, from the focal point to the magnifier. + final Offset offsetFromFocalPoint; + + final double animationValue; + final AnimationDirection animationDirection; + final Color borderColor; +} + +enum AnimationDirection { forward, reverse } diff --git a/super_editor/lib/src/infrastructure/platforms/ios/selection_handles.dart b/super_editor/lib/src/infrastructure/platforms/ios/selection_handles.dart index 139c9ac198..c08a1bc084 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/selection_handles.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/selection_handles.dart @@ -69,40 +69,36 @@ class IOSSelectionHandle extends StatelessWidget { Widget _buildExpandedHandle() { final ballDiameter = ballRadius * 2; - final verticalOffset = handleType == HandleType.upstream ? -ballRadius : ballRadius; - - return Transform.translate( - offset: Offset(0, verticalOffset), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Show the ball on the top for an upstream handle - if (handleType == HandleType.upstream) - Container( - width: ballDiameter, - height: ballDiameter, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Show the ball on the top for an upstream handle + if (handleType == HandleType.upstream) Container( - width: 2, - height: caretHeight + ballRadius, - color: color, + width: ballDiameter, + height: ballDiameter, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), ), - // Show the ball on the bottom for a downstream handle - if (handleType == HandleType.downstream) - Container( - width: ballDiameter, - height: ballDiameter, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), + Container( + width: caretWidth, + height: caretHeight, + color: color, + ), + // Show the ball on the bottom for a downstream handle + if (handleType == HandleType.downstream) + Container( + width: ballDiameter, + height: ballDiameter, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, ), - ], - ), + ), + ], ); } } @@ -135,7 +131,7 @@ class IOSCollapsedHandle extends StatelessWidget { controller: controller, caretOffset: Offset.zero, caretHeight: caretHeight, - width: 2, + width: caretWidth, color: color, borderRadius: BorderRadius.zero, isTextEmpty: false, diff --git a/super_editor/lib/src/infrastructure/platforms/ios/selection_heuristics.dart b/super_editor/lib/src/infrastructure/platforms/ios/selection_heuristics.dart new file mode 100644 index 0000000000..b143fb80ac --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/ios/selection_heuristics.dart @@ -0,0 +1,37 @@ +import 'package:super_editor/src/infrastructure/strings.dart'; + +/// User interaction heuristics that simulate observed behavior on +/// iOS devices. +class IosHeuristics { + /// Adjusts a user's tap offset within a text field, or a paragraph, by + /// moving the caret to a word boundary, as observed on iOS. + static int adjustTapOffset(String text, int tapOffset) { + assert(tapOffset >= 0 && tapOffset < text.length); + if (tapOffset == 0) { + return 0; + } + + final upstreamWordStart = text.moveOffsetUpstreamByWord(tapOffset) ?? 0; + final upstreamWordEnd = text.moveOffsetDownstreamByWord(upstreamWordStart) ?? text.length; + + final downstreamWordEnd = text.moveOffsetDownstreamByWord(tapOffset) ?? text.length; + final downstreamWordStart = text.moveOffsetUpstreamByWord(downstreamWordEnd) ?? 0; + + if (text[tapOffset] == " ") { + // User tapped between words. Pick the nearest word. + return downstreamWordStart - tapOffset < tapOffset - upstreamWordEnd ? downstreamWordStart : upstreamWordEnd; + } else { + // User tapped within a word. Adjust the offset to the end of the + // word unless the user is within 1 character of the start of the word. + if (tapOffset <= upstreamWordEnd) { + // The tap position is within the upstream word. + return tapOffset - upstreamWordStart <= 1 ? upstreamWordStart : upstreamWordEnd; + } else { + // The tap position is within the downstream word. + return tapOffset - downstreamWordStart <= 1 ? downstreamWordStart : downstreamWordEnd; + } + } + } + + const IosHeuristics._(); +} diff --git a/super_editor/lib/src/infrastructure/platforms/ios/toolbar.dart b/super_editor/lib/src/infrastructure/platforms/ios/toolbar.dart index 511494336e..e587db9011 100644 --- a/super_editor/lib/src/infrastructure/platforms/ios/toolbar.dart +++ b/super_editor/lib/src/infrastructure/platforms/ios/toolbar.dart @@ -1,25 +1,51 @@ import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/colors.dart'; class IOSTextEditingFloatingToolbar extends StatelessWidget { const IOSTextEditingFloatingToolbar({ Key? key, + this.floatingToolbarKey, + required this.focalPoint, this.onCutPressed, this.onCopyPressed, this.onPastePressed, }) : super(key: key); + final Key? floatingToolbarKey; + + /// Direction that the toolbar arrow should point. + final LeaderLink focalPoint; + final VoidCallback? onCutPressed; final VoidCallback? onCopyPressed; final VoidCallback? onPastePressed; @override Widget build(BuildContext context) { - return Material( - borderRadius: BorderRadius.circular(8), - elevation: 3, - color: const Color(0xFF222222), - child: Row( - mainAxisSize: MainAxisSize.min, + final brightness = Theme.of(context).brightness; + + return Theme( + data: ThemeData( + colorScheme: brightness == Brightness.light // + ? const ColorScheme.light(primary: Colors.black) + : const ColorScheme.dark(primary: Colors.white), + ), + child: CupertinoPopoverToolbar( + key: floatingToolbarKey, + focalPoint: LeaderMenuFocalPoint(link: focalPoint), + elevation: 8.0, + backgroundColor: brightness == Brightness.dark // + ? iOSToolbarDarkBackgroundColor + : iOSToolbarLightBackgroundColor, + activeButtonTextColor: brightness == Brightness.dark // + ? iOSToolbarDarkArrowActiveColor + : iOSToolbarLightArrowActiveColor, + inactiveButtonTextColor: brightness == Brightness.dark // + ? iOSToolbarDarkArrowInactiveColor + : iOSToolbarLightArrowInactiveColor, children: [ if (onCutPressed != null) _buildButton( @@ -45,23 +71,27 @@ class IOSTextEditingFloatingToolbar extends StatelessWidget { required String title, required VoidCallback onPressed, }) { - return SizedBox( - height: 36, - child: TextButton( - onPressed: onPressed, - style: TextButton.styleFrom( - minimumSize: Size.zero, - padding: EdgeInsets.zero, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Text( - title, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w300, - ), + // TODO: Bring this back after its updated to support theming (Overlord #17) + // return CupertinoPopoverToolbarMenuItem( + // label: title, + // onPressed: onPressed, + // ); + + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + minimumSize: const Size(kMinInteractiveDimension, 0), + padding: EdgeInsets.zero, + splashFactory: NoSplash.splashFactory, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w300, ), ), ), diff --git a/super_editor/lib/src/infrastructure/platforms/mac/mac_ime.dart b/super_editor/lib/src/infrastructure/platforms/mac/mac_ime.dart new file mode 100644 index 0000000000..5b7e81433f --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/mac/mac_ime.dart @@ -0,0 +1,63 @@ +/// MacOS selector names that are sent to [TextInputClient.performSelector]. +/// +/// These selectors express the user intent and are generated by shortcuts. For example, +/// pressing SHIFT + Left Arrow key generates a moveLeftAndModifySelection selector. +/// +/// The full list can be found on https://developer.apple.com/documentation/appkit/nsstandardkeybindingresponding?changes=_8&language=objc +class MacOsSelectors { + static const String deleteBackward = 'deleteBackward:'; + static const String deleteWordBackward = 'deleteWordBackward:'; + static const String deleteToBeginningOfLine = 'deleteToBeginningOfLine:'; + static const String deleteForward = 'deleteForward:'; + static const String deleteWordForward = 'deleteWordForward:'; + static const String deleteToEndOfLine = 'deleteToEndOfLine:'; + static const String deleteBackwardByDecomposingPreviousCharacter = 'deleteBackwardByDecomposingPreviousCharacter:'; + + static const String moveLeft = 'moveLeft:'; + static const String moveRight = 'moveRight:'; + static const String moveForward = 'moveForward:'; + static const String moveBackward = 'moveBackward:'; + static const String moveUp = 'moveUp:'; + static const String moveDown = 'moveDown:'; + + static const String moveWordLeft = 'moveWordLeft:'; + static const String moveWordRight = 'moveWordRight:'; + static const String moveToBeginningOfParagraph = 'moveToBeginningOfParagraph:'; + static const String moveToEndOfParagraph = 'moveToEndOfParagraph:'; + + static const String moveToLeftEndOfLine = 'moveToLeftEndOfLine:'; + static const String moveToRightEndOfLine = 'moveToRightEndOfLine:'; + static const String moveToBeginningOfDocument = 'moveToBeginningOfDocument:'; + static const String moveToEndOfDocument = 'moveToEndOfDocument:'; + + static const String moveLeftAndModifySelection = 'moveLeftAndModifySelection:'; + static const String moveRightAndModifySelection = 'moveRightAndModifySelection:'; + static const String moveUpAndModifySelection = 'moveUpAndModifySelection:'; + static const String moveDownAndModifySelection = 'moveDownAndModifySelection:'; + + static const String moveWordLeftAndModifySelection = 'moveWordLeftAndModifySelection:'; + static const String moveWordRightAndModifySelection = 'moveWordRightAndModifySelection:'; + static const String moveParagraphBackwardAndModifySelection = 'moveParagraphBackwardAndModifySelection:'; + static const String moveParagraphForwardAndModifySelection = 'moveParagraphForwardAndModifySelection:'; + + static const String moveToLeftEndOfLineAndModifySelection = 'moveToLeftEndOfLineAndModifySelection:'; + static const String moveToRightEndOfLineAndModifySelection = 'moveToRightEndOfLineAndModifySelection:'; + static const String moveToBeginningOfDocumentAndModifySelection = 'moveToBeginningOfDocumentAndModifySelection:'; + static const String moveToEndOfDocumentAndModifySelection = 'moveToEndOfDocumentAndModifySelection:'; + + static const String transpose = 'transpose:'; + + static const String scrollToBeginningOfDocument = 'scrollToBeginningOfDocument:'; + static const String scrollToEndOfDocument = 'scrollToEndOfDocument:'; + + static const String scrollPageUp = 'scrollPageUp:'; + static const String scrollPageDown = 'scrollPageDown:'; + static const String pageUpAndModifySelection = 'pageUpAndModifySelection:'; + static const String pageDownAndModifySelection = 'pageDownAndModifySelection:'; + + static const String cancelOperation = 'cancelOperation:'; + + static const String insertTab = 'insertTab:'; + static const String insertBacktab = 'insertBacktab:'; + static const String insertNewLine = 'insertNewline:'; +} diff --git a/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart b/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart index 74af498210..5a02dbb17e 100644 --- a/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart +++ b/super_editor/lib/src/infrastructure/platforms/mobile_documents.dart @@ -1,19 +1,265 @@ import 'package:flutter/widgets.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/_scrolling.dart'; import 'package:super_editor/src/infrastructure/document_gestures.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; -/// Controls the display and position of a magnifier and a floating toolbar. -class MagnifierAndToolbarController with ChangeNotifier { - MagnifierAndToolbarController({ - required LayerLink magnifierFocalPointLink, - }) : _magnifierFocalPointLink = magnifierFocalPointLink; +class DocumentKeys { + static const caret = ValueKey("document_caret"); + static const upstreamHandle = ValueKey("document_upstream_handle"); + static const downstreamHandle = ValueKey("document_downstream_handle"); + static const mobileToolbar = ValueKey("document_mobile_toolbar"); + static const magnifier = ValueKey("document_magnifier"); - /// A `LayerLink` whose top-left corner sits at the location where the - /// magnifier should magnify. - LayerLink get magnifierFocalPointLink => _magnifierFocalPointLink; - final LayerLink _magnifierFocalPointLink; + static const androidCaretHandle = ValueKey("document_android_caret_handle"); + + DocumentKeys._(); +} + +/// Builds a full-screen collapsed drag handle display, with the handle positioned near the [focalPoint], +/// and with the handle attached to the given [handleKey]. +/// +/// Implementers of this builder have the following responsibilities: +/// * Attach the [handleKey] to the widget that renders the handle. +/// * Wrap the handle widget with a `Follower` and attach the `focalPoint` to the `Follower`. +/// * Wrap the handle widget with a `GestureDetector` and attach the provided [gestureDelegate] callbacks to the `GestureDetector`. +/// * When [shouldShow] is `false`, hide the handle and ensure that no gestures are handled. +/// +/// ```dart +/// Widget buildCollapsedHandle(BuildContext context, { +/// required LeaderLink focalPoint, +/// required DocumentHandleGestureDelegate gestureDelegate, +/// required Key handleKey, +/// required bool shouldShow, +/// }) { +/// if (!shouldShow) { +/// return const SizedBox(); +/// } +/// return Follower.withOffset( +/// offset: Offset.zero, +/// link: focalPoint, +/// child: GestureDetector( +/// onTap: gestureDelegate.onTap, +/// onPanStart: gestureDelegate.onPanStart, +/// onPanUpdate: gestureDelegate.onPanUpdate, +/// onPanEnd: gestureDelegate.onPanEnd, +/// onPanCancel: gestureDelegate.onPanCancel, +/// child: CollapsedHandle( +/// key: handleKey, +/// ), +/// ), +/// ); +/// } +/// ``` +typedef DocumentCollapsedHandleBuilder = Widget Function( + BuildContext, { + required Key handleKey, + required LeaderLink focalPoint, + required DocumentHandleGestureDelegate gestureDelegate, + required bool shouldShow, +}); + +/// Builds a full-screen display of a set of expanded drag handles, with the handles positioned near the +/// [upstreamFocalPoint] and [downstreamFocalPoint], respectively, and with the handles attached to the +/// given [upstreamHandleKey] and [downstreamHandleKey], respectively. +/// +/// The [upstreamHandleKey] and [downstreamHandleKey] are used to find the handles in the widget tree for +/// various purposes, e.g., within tests to verify the presence or absence of the handles. +/// +/// Implementers of this builder have the following responsibilities: +/// * Attach the [upstreamHandleKey] to the widget that renders the upstream handle and [downstreamHandleKey] +/// to the downstream handle. +/// * Wrap each handle widget with a `Follower`, attaching the [downstreamFocalPoint] to the downstream handle `Follower` +/// and [upstreamFocalPoint] to the upstream handle `Follower`. +/// * Wrap each handle widget with a `GestureDetector`, attaching the provided [upstreamGestureDelegate] callbacks to +/// the upstream handle `GestureDetector` and the [downstreamGestureDelegate] callbacks to the downstream +/// handle `GestureDetector`. +/// * When [shouldShow] is `false`, hide the handle and ensure that no gestures are handled. +/// +/// The handle keys must be attached to the handles, not the top-level widget returned +/// from this builder, because the handle keys might be used to verify the size and location +/// of the handles. For example: +/// +/// ```dart +/// Widget buildExpandedHandles(BuildContext context, { +/// required LeaderLink downstreamFocalPoint, +/// required DocumentHandleGestureDelegate downstreamGestureDelegate, +/// required Key downstreamHandleKey, +/// required LeaderLink upstreamFocalPoint, +/// required DocumentHandleGestureDelegate upstreamGestureDelegate, +/// required Key upstreamHandleKey, +/// required bool shouldShow, +/// }) { +/// if (!shouldShow) { +/// return const SizedBox(); +/// } +/// return Stack( +/// children: [ +/// Follower.withOffset( +/// offset: Offset.zero, +/// link: upstreamFocalPoint, +/// child: GestureDetector( +/// onTapDown: upstreamGestureDelegate.onTapDown, +/// onPanStart: upstreamGestureDelegate.onPanStart, +/// onPanUpdate: upstreamGestureDelegate.onPanUpdate, +/// onPanEnd: upstreamGestureDelegate.onPanEnd, +/// onPanCancel: upstreamGestureDelegate.onPanCancel, +/// child: UpstreamHandle(key: upstreamHandleKey), +/// ), +/// ), +/// Follower.withOffset( +/// offset: Offset.zero, +/// link: downstreamFocalPoint, +/// child: GestureDetector( +/// onTapDown: downstreamGestureDelegate.onTapDown, +/// onPanStart: downstreamGestureDelegate.onPanStart, +/// onPanUpdate: downstreamGestureDelegate.onPanUpdate, +/// onPanEnd: downstreamGestureDelegate.onPanEnd, +/// onPanCancel: downstreamGestureDelegate.onPanCancel, +/// child: DownstreamHandle(key: downstreamHandleKey), +/// ), +/// ), +/// ], +/// ); +/// } +/// ``` +typedef DocumentExpandedHandlesBuilder = Widget Function( + BuildContext, { + required Key upstreamHandleKey, + required LeaderLink upstreamFocalPoint, + required DocumentHandleGestureDelegate upstreamGestureDelegate, + required Key downstreamHandleKey, + required LeaderLink downstreamFocalPoint, + required DocumentHandleGestureDelegate downstreamGestureDelegate, + required bool shouldShow, +}); + +/// Delegate for handling gestures on a document handle. +/// +/// These callbacks are intended to make it easier for developers to customize +/// the drag handles, without having to re-implement the gesture logic. For +/// example, implementers can wrap the handle in a `GestureDetector`: +/// +/// ```dart +/// Widget buildCollapsedHandle(BuildContext context, { +/// required LeaderLink focalPoint, +/// required DocumentHandleGestureDelegate gestureDelegate, +/// required Key handleKey, +/// required bool shouldShow, +/// }) { +/// return Follower( +/// link: focalPoint, +/// child: GestureDetector( +/// onTap: gestureDelegate.onTap, +/// onPanStart: gestureDelegate.onPanStart, +/// onPanUpdate: gestureDelegate.onPanUpdate, +/// onPanEnd: gestureDelegate.onPanEnd, +/// onPanCancel: gestureDelegate.onPanCancel, +/// child: CollapsedHandle( +/// key: handleKey, +/// ), +/// ), +/// ); +/// } +/// ``` +class DocumentHandleGestureDelegate { + DocumentHandleGestureDelegate({ + this.onTapDown, + this.onTap, + this.onPanStart, + this.onPanUpdate, + this.onPanEnd, + this.onPanCancel, + }); + + final GestureTapDownCallback? onTapDown; + final GestureTapCallback? onTap; + final GestureDragStartCallback? onPanStart; + final GestureDragUpdateCallback? onPanUpdate; + final GestureDragEndCallback? onPanEnd; + final GestureDragCancelCallback? onPanCancel; +} + +/// Builds a full-screen floating toolbar display, with the toolbar positioned near the +/// [focalPoint], and with the toolbar attached to the given [mobileToolbarKey]. +/// +/// The [mobileToolbarKey] is used to find the toolbar in the widget tree for various purposes, +/// e.g., within tests to verify the presence or absence of a toolbar. If your builder chooses +/// not to build a toolbar, e.g., returns a `SizedBox()` instead of a toolbar, then +/// you shouldn't use the [mobileToolbarKey]. +/// +/// The [mobileToolbarKey] must be attached to the toolbar, not the top-level widget returned +/// from this builder, because the [mobileToolbarKey] might be used to verify the size and location +/// of the toolbar. For example: +/// +/// ```dart +/// Widget buildMagnifier(context, mobileToolbarKey, focalPoint) { +/// return Follower( +/// link: focalPoint, +/// child: Toolbar( +/// key: mobileToolbarKey, +/// width: 100, +/// height: 42, +/// magnification: 1.5, +/// ), +/// ); +/// } +/// ``` +typedef DocumentFloatingToolbarBuilder = Widget Function( + BuildContext context, + Key mobileToolbarKey, + LeaderLink focalPoint, +); + +/// Builds a full-screen magnifier display, with the magnifier following the given [focalPoint], +/// and with the magnifier attached to the given [magnifierKey]. +/// +/// The [visible] parameter is used to let the magnifier animate its appearance and disappearance. +/// For example, [visible] can be `false` and the magnifier can still be present in the widget tree +/// while an exit animation runs. Upon animation end, the widget bound to [magnifierKey] should be +/// removed from the widget tree. If an animation isn't desired, a [SizedBox] can be returned when +/// [visible] is `false`. +/// +/// The [magnifierKey] is used to find the magnifier in the widget tree for various purposes, +/// e.g., within tests to verify the presence or absence of a magnifier. If your builder chooses +/// not to build a magnifier, e.g., returns a `SizedBox()` instead of a magnifier, then +/// you shouldn't use the [magnifierKey]. +/// +/// The [magnifierKey] must be attached to the magnifier, not the top-level widget returned +/// from this builder, because the [magnifierKey] might be used to verify the size and location +/// of the magnifier. For example: +/// +/// ```dart +/// Widget buildMagnifier(context, magnifierKey, focalPoint) { +/// return Follower( +/// link: focalPoint, +/// child: Magnifier( +/// key: magnifierKey, +/// width: 100, +/// height: 42, +/// magnification: 1.5, +/// ), +/// ); +/// } +/// ``` +typedef DocumentMagnifierBuilder = Widget Function( + BuildContext, Key magnifierKey, LeaderLink focalPoint, bool isVisible); + +/// Global flag that disables long-press selection for Android and iOS, as a hack for Superlist, because +/// Superlist has a custom long-press behavior per-component. +/// +/// This is a hack and is expected to be replaced ASAP. Issue: https://github.com/superlistapp/super_editor/issues/1547 +/// +/// The underlying issue is that the document layout components have a gesture mode of "translucent", which +/// lets both the document component and the overall document gesture interactor both respond to the touch +/// event. As a result, if a user long-presses on a component to re-order it, that long-press also triggers +/// the long-press text selection behavior within the standard document interactor. +@Deprecated("Don't use this unless you're Superlist. This will be removed ASAP. See issue #1547.") +bool disableLongPressSelectionForSuperlist = false; +/// Controls the display and position of a magnifier and a floating toolbar. +class MagnifierAndToolbarController with ChangeNotifier { /// Whether the magnifier should be displayed. bool get shouldDisplayMagnifier => _isMagnifierVisible; bool _isMagnifierVisible = false; @@ -59,6 +305,17 @@ class MagnifierAndToolbarController with ChangeNotifier { Offset? get toolbarBottomAnchor => _toolbarBottomAnchor; Offset? _toolbarBottomAnchor; + /// Minimum space from the screen edges. + EdgeInsets? get screenPadding => _screenPadding; + set screenPadding(EdgeInsets? value) { + if (value != _screenPadding) { + _screenPadding = value; + notifyListeners(); + } + } + + EdgeInsets? _screenPadding; + /// Sets the toolbar's position to the given [topAnchor] and [bottomAnchor]. /// /// Setting the position will not cause the toolbar to be displayed on it's own. @@ -107,6 +364,115 @@ class MagnifierAndToolbarController with ChangeNotifier { } } +/// Controls the display and position of a magnifier and a floating toolbar +/// using a [MagnifierAndToolbarController] as the source of truth. +class GestureEditingController with ChangeNotifier { + GestureEditingController({ + required this.selectionLinks, + required MagnifierAndToolbarController overlayController, + required LeaderLink magnifierFocalPointLink, + }) : _magnifierFocalPointLink = magnifierFocalPointLink, + _overlayController = overlayController { + _overlayController.addListener(_toolbarChanged); + } + + @override + void dispose() { + _overlayController.removeListener(_toolbarChanged); + super.dispose(); + } + + final SelectionLayerLinks selectionLinks; + + /// A `LayerLink` whose top-left corner sits at the location where the + /// magnifier should magnify. + LeaderLink get magnifierFocalPointLink => _magnifierFocalPointLink; + final LeaderLink _magnifierFocalPointLink; + + /// Controls the magnifier and the toolbar. + MagnifierAndToolbarController get overlayController => _overlayController; + late MagnifierAndToolbarController _overlayController; + set overlayController(MagnifierAndToolbarController value) { + if (_overlayController != value) { + _overlayController.removeListener(_toolbarChanged); + _overlayController = value; + _overlayController.addListener(_toolbarChanged); + } + } + + /// Whether the toolbar currently has a designated display position. + /// + /// The toolbar should not be displayed if this is `false`, even if + /// [shouldDisplayToolbar] is `true`. + bool get isToolbarPositioned => _overlayController.isToolbarPositioned; + + /// Whether the toolbar should be displayed. + bool get shouldDisplayToolbar => _overlayController.shouldDisplayToolbar; + + /// Whether the magnifier should be displayed. + bool get shouldDisplayMagnifier => _overlayController.shouldDisplayMagnifier; + + /// The point about which the floating toolbar should focus, when the toolbar + /// appears above the selected content. + /// + /// It's the clients responsibility to determine whether there's room for the + /// toolbar above this point. If not, use [toolbarBottomAnchor]. + Offset? get toolbarTopAnchor => _overlayController.toolbarTopAnchor; + + /// The point about which the floating toolbar should focus, when the toolbar + /// appears below the selected content. + /// + /// It's the clients responsibility to determine whether there's room for the + /// toolbar below this point. If not, use [toolbarTopAnchor]. + Offset? get toolbarBottomAnchor => _overlayController.toolbarBottomAnchor; + + /// Minimum space from the screen edges. + EdgeInsets? get screenPadding => _overlayController.screenPadding; + + /// Shows the toolbar, and hides the magnifier. + void showToolbar() { + _overlayController.showToolbar(); + } + + /// Hides the toolbar. + void hideToolbar() { + _overlayController.hideToolbar(); + } + + /// Shows the magnify, and hides the toolbar. + void showMagnifier() { + _overlayController.showMagnifier(); + } + + /// Hides the magnifier. + void hideMagnifier() { + _overlayController.hideMagnifier(); + } + + /// Toggles the toolbar from visible to not visible, or vis-a-versa. + void toggleToolbar() { + _overlayController.toggleToolbar(); + } + + /// Sets the toolbar's position to the given [topAnchor] and [bottomAnchor]. + /// + /// Setting the position will not cause the toolbar to be displayed on it's own. + /// To display the toolbar, call [showToolbar], too. + void positionToolbar({ + required Offset topAnchor, + required Offset bottomAnchor, + }) { + _overlayController.positionToolbar( + topAnchor: topAnchor, + bottomAnchor: bottomAnchor, + ); + } + + void _toolbarChanged() { + notifyListeners(); + } +} + /// Auto-scrolls a given `ScrollPosition` based on the current position of /// a drag handle near the boundary of the scroll region. /// @@ -149,7 +515,11 @@ class DragHandleAutoScroller { final scrollPosition = _getScrollPosition(); final currentScrollOffset = scrollPosition.pixels; - editorGesturesLog.fine("Current scroll offset: $currentScrollOffset"); + + // The offset calculation below does not work correctly in custom scroll view with sliver header + // and causes overscroll so for now clamp the offset. + final max = scrollPosition.maxScrollExtent; + final min = scrollPosition.minScrollExtent; if (offsetInViewport.dy < _dragAutoScrollBoundary.leading) { // The offset is above the leading boundary. We need to scroll up @@ -158,22 +528,19 @@ class DragHandleAutoScroller { // If currentScrollOffset isn't greater than zero it means we are already // at the top edge of the scrollable, so we can't scroll further up. if (currentScrollOffset > 0.0) { - // Jump to the position where the offset sits at the leading boundary. - scrollPosition.jumpTo( - currentScrollOffset + (offsetInViewport.dy - _dragAutoScrollBoundary.leading), - ); + final clampedVisibleScrollOffset = + (currentScrollOffset + (offsetInViewport.dy - _dragAutoScrollBoundary.leading)).clamp(min, max); + scrollPosition.jumpTo(clampedVisibleScrollOffset); } } else if (offsetInViewport.dy > _getViewportBox().size.height - _dragAutoScrollBoundary.trailing) { // The offset is below the trailing boundary. We need to scroll down editorGesturesLog.fine('The scrollable needs to scroll down to make offset visible.'); - // If currentScrollOffset isn't lesser than the maxScrollExtent it means - // we are already at the bottom edge of the scrollable, so we can't scroll further down. if (currentScrollOffset < scrollPosition.maxScrollExtent) { - // Jump to the position where the offset sits at the trailing boundary - scrollPosition.jumpTo( - currentScrollOffset + - (offsetInViewport.dy - (_getViewportBox().size.height - _dragAutoScrollBoundary.trailing)), - ); + // We want to scroll further to show the offset, and there's still more scrollable + // distance below. Scroll to where the offset sits at the trailing boundary. + final jumpDeltaToShowOffset = + offsetInViewport.dy + _dragAutoScrollBoundary.trailing - _getViewportBox().size.height; + scrollPosition.jumpTo((currentScrollOffset + jumpDeltaToShowOffset).clamp(min, max)); } } } @@ -195,7 +562,8 @@ class DragHandleAutoScroller { void updateAutoScrollHandleMonitoring({ required Offset dragEndInViewport, }) { - if (dragEndInViewport.dy < _dragAutoScrollBoundary.leading) { + if (dragEndInViewport.dy < _dragAutoScrollBoundary.leading && + _getScrollPosition().pixels > _getScrollPosition().minScrollExtent) { editorGesturesLog.finest('Metrics say we should try to scroll up'); final leadingScrollBoundary = _dragAutoScrollBoundary.leading; @@ -207,7 +575,8 @@ class DragHandleAutoScroller { _autoScroller.stopScrollingUp(); } - if (_getViewportBox().size.height - dragEndInViewport.dy < _dragAutoScrollBoundary.trailing) { + if (_getViewportBox().size.height - dragEndInViewport.dy < _dragAutoScrollBoundary.trailing && + _getScrollPosition().pixels < _getScrollPosition().maxScrollExtent) { editorGesturesLog.finest('Metrics say we should try to scroll down'); final trailingScrollBoundary = _dragAutoScrollBoundary.trailing; diff --git a/super_editor/lib/src/infrastructure/platforms/platform.dart b/super_editor/lib/src/infrastructure/platforms/platform.dart new file mode 100644 index 0000000000..ec67febe8d --- /dev/null +++ b/super_editor/lib/src/infrastructure/platforms/platform.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; + +class CurrentPlatform { + /// Whether or not we are running on an Apple device (MacOS or iOS). + static bool get isApple => + defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.iOS; + + /// Whether or not we are running on web. + /// + /// By default this is the same as [kIsWeb]. + /// + /// [debugIsWebOverride] may be used to override the natural value of [isWeb]. + static bool get isWeb => debugIsWebOverride == null // + ? kIsWeb + : debugIsWebOverride == WebPlatformOverride.web; +} + +/// Overrides the value of [CurrentPlatform.isWeb]. +/// +/// This is intended to be used in tests. +/// +/// Set it to `null` to use the default value of [CurrentPlatform.isWeb]. +/// +/// Set it to [WebPlatformOverride.web] to configure to run as if we are on web. +/// +/// Set it to [WebPlatformOverride.native] to configure to run as if we are NOT on web. +@visibleForTesting +WebPlatformOverride? debugIsWebOverride; + +@visibleForTesting +enum WebPlatformOverride { + /// Configuration to run the app as if we are a native app. + native, + + /// Configuration to run the app as if we are on web. + web, +} diff --git a/super_editor/lib/src/infrastructure/popovers.dart b/super_editor/lib/src/infrastructure/popovers.dart new file mode 100644 index 0000000000..b5aecf380f --- /dev/null +++ b/super_editor/lib/src/infrastructure/popovers.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/src/infrastructure/actions.dart'; + +/// A popover that shares focus with a `SuperEditor`. +/// +/// Popovers often need to handle keyboard input, such as arrow keys for +/// selection movement. But `SuperEditor` also needs to continue handling +/// keyboard input to move the caret, enter text, etc. In such a case, the +/// popover has primary focus, and `SuperEditor` has non-primary focus. +/// Due to the way that Flutter's `Actions` system works, along with +/// Flutter's default response to certain key events, a few careful +/// adjustments need to be made so that a popover works with +/// `SuperEditor` as expected. This widget handles those adjustments. +/// +/// Despite this widget being a "Super Editor popover", this widget can be +/// placed anywhere in the widget tree, so long as it's able to share focus +/// with `SuperEditor`. +/// +/// This widget is purely logical - it doesn't impose any particular layout +/// or constraints. It's up to you whether this widget tightly hugs your +/// popover [child], or whether it expands to fill a space. +/// +/// It's possible to create a `SuperEditor` popover without this widget. +/// This widget doesn't have any special access to `SuperEditor` +/// properties or behavior. But, if you choose to display a popover +/// without using this widget, you'll likely need to re-implement this +/// behavior to avoid unexpected user interaction results. +class SuperEditorPopover extends StatelessWidget { + const SuperEditorPopover({ + super.key, + required this.popoverFocusNode, + required this.editorFocusNode, + this.onKeyEvent, + required this.child, + }); + + /// The [FocusNode] attached to the popover. + final FocusNode popoverFocusNode; + + /// The [FocusNode] attached to the editor. + /// + /// The [popoverFocusNode] will be reparented with this [FocusNode]. + final FocusNode editorFocusNode; + + /// Callback that notifies key events. + final FocusOnKeyEventCallback? onKeyEvent; + + /// The popover to display. + final Widget child; + + @override + Widget build(BuildContext context) { + return IntentBlocker( + intents: appleBlockedIntents, + child: Focus( + focusNode: popoverFocusNode, + parentNode: editorFocusNode, + onKeyEvent: onKeyEvent, + child: child, + ), + ); + } +} diff --git a/super_editor/lib/src/infrastructure/render_sliver_ext.dart b/super_editor/lib/src/infrastructure/render_sliver_ext.dart new file mode 100644 index 0000000000..89a64b91b4 --- /dev/null +++ b/super_editor/lib/src/infrastructure/render_sliver_ext.dart @@ -0,0 +1,28 @@ +import 'package:flutter/rendering.dart'; + +/// Extension on [RenderSliver] that that brings over some of the missing +/// [RenderBox] functionality. +extension RenderSliverExt on RenderSliver { + Size get size { + assert(attached); + return Size(geometry!.crossAxisExtent ?? constraints.crossAxisExtent, geometry!.paintExtent); + } + + bool get hasSize { + assert(attached); + return geometry != null; + } + + Offset globalToLocal(Offset point, {RenderObject? ancestor}) { + assert(attached); + final transform = getTransformTo(ancestor); + transform.invert(); + return MatrixUtils.transformPoint(transform, point); + } + + Offset localToGlobal(Offset point, {RenderObject? ancestor}) { + assert(attached); + final transform = getTransformTo(ancestor); + return MatrixUtils.transformPoint(transform, point); + } +} diff --git a/super_editor/lib/src/infrastructure/scrolling/desktop_mouse_wheel_and_trackpad_scrolling.dart b/super_editor/lib/src/infrastructure/scrolling/desktop_mouse_wheel_and_trackpad_scrolling.dart new file mode 100644 index 0000000000..94461b4506 --- /dev/null +++ b/super_editor/lib/src/infrastructure/scrolling/desktop_mouse_wheel_and_trackpad_scrolling.dart @@ -0,0 +1,342 @@ +import 'dart:collection'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter/gestures.dart'; + +/// A widget that scrolls a given [controller], either vertically or horizontally, when the user +/// interacts with the widget via a trackpad or a mouse wheel, but only when no other scrollable +/// has claimed scrolling ownership. +/// +/// Integrating trackpad and mouse wheel scrolling is a challenge because those pointer and +/// scrolling events don't participate in the gesture arena. Therefore, you have to implement +/// scrolling ownership yourself. +/// +/// This widget participates in a custom scrolling ownership behavior. This widget talks to +/// a [GlobalScrollLock]. The global scroll lock assigns scrolling ownership based +/// on whether a given pointer event is mostly vertical, or mostly horizontal. This way, you can +/// use a trackpad or a Magic Mouse to scroll something like a chat conversation vertically, +/// while also scrolling a table horizontally, without scrolling both at the same time. +/// +/// This scroll ownership system is very basic, but it should be sufficient for common use-cases. +class SingleAxisTrackpadAndWheelScroller extends StatefulWidget { + const SingleAxisTrackpadAndWheelScroller({ + super.key, + required this.axis, + required this.controller, + required this.child, + }); + + final Axis axis; + final ScrollController controller; + final Widget child; + + @override + State createState() => _SingleAxisTrackpadAndWheelScrollerState(); +} + +class _SingleAxisTrackpadAndWheelScrollerState extends State { + final _velocitySamples = ListQueue<(Duration timestamp, double offset)>(10); + + void _onTrackpadStart(PointerPanZoomStartEvent event) { + // Immediately stop any on-going scrolling. + // + // This fixes a common Flutter bug. A user fling scrolls a scrollable with a + // trackpad, then the user touches the trackpad again to continue scrolling, or + // to launch another fling. But when the user touches back down, the previous + // scroll momentum continues, and there's nothing the user can do to stop it. + // This line stops it. + (widget.controller.position as ScrollPositionWithSingleContext).goIdle(); + } + + void _onTrackpadUpdate(PointerPanZoomUpdateEvent event) { + switch (widget.axis) { + case Axis.horizontal: + if (event.panDelta.dx == 0) { + return; + } + widget.controller.position.pointerScroll(-event.panDelta.dx); + + case Axis.vertical: + if (event.panDelta.dy == 0) { + return; + } + widget.controller.position.pointerScroll(-event.panDelta.dy); + } + + // Only save 10 most recent position samples for velocity estimation. + if (_velocitySamples.length == 10) { + _velocitySamples.removeLast(); + } + + // Add the latest scroll offset to the velocity samples. + _velocitySamples.addFirst( + (event.timeStamp, widget.controller.position.pixels), + ); + } + + void _onTrackpadEnd(PointerPanZoomEndEvent event) { + if (_velocitySamples.length < 2) { + // We didn't collect enough samples to calculate a velocity. Fizzle. + return; + } + + // Launch the scrollable with an estimated final trackpad scroll velocity. + (widget.controller.position as ScrollPositionWithSingleContext).goBallistic( + (_velocitySamples.first.$2 - _velocitySamples.last.$2) / + ((_velocitySamples.first.$1 - _velocitySamples.last.$1).inMilliseconds / 1000), + ); + + // Clear the velocity log. + _velocitySamples.clear(); + } + + void _onScrollWheel(PointerScrollEvent event) { + switch (widget.axis) { + case Axis.horizontal: + if (event.scrollDelta.dx == 0) { + return; + } + widget.controller.position.pointerScroll(event.scrollDelta.dx); + + case Axis.vertical: + if (event.scrollDelta.dy == 0) { + return; + } + widget.controller.position.pointerScroll(event.scrollDelta.dy); + } + } + + @override + Widget build(BuildContext context) { + return _DeferredOwnershipTrackpadAndMouseWheelScroller( + scrollAxis: widget.axis, + onPanZoomStart: _onTrackpadStart, + onPanZoomUpdate: _onTrackpadUpdate, + onPanZoomEnd: _onTrackpadEnd, + onScrollWheel: _onScrollWheel, + child: widget.child, + ); + } +} + +/// A widget that forwards callbacks for trackpad and mouse wheel events, but only when this +/// widget has obtained the global scrolling lock from the [GlobalScrollLock]. +class _DeferredOwnershipTrackpadAndMouseWheelScroller extends StatefulWidget { + const _DeferredOwnershipTrackpadAndMouseWheelScroller({ + required this.scrollAxis, + this.onPanZoomStart, + this.onPanZoomUpdate, + this.onPanZoomEnd, + this.onScrollWheel, + required this.child, + }); + + final Axis scrollAxis; + + final void Function(PointerPanZoomStartEvent)? onPanZoomStart; + final void Function(PointerPanZoomUpdateEvent)? onPanZoomUpdate; + final void Function(PointerPanZoomEndEvent)? onPanZoomEnd; + final void Function(PointerScrollEvent)? onScrollWheel; + + final Widget child; + + @override + State<_DeferredOwnershipTrackpadAndMouseWheelScroller> createState() => + _DeferredOwnershipTrackpadAndMouseWheelScrollerState(); +} + +class _DeferredOwnershipTrackpadAndMouseWheelScrollerState + extends State<_DeferredOwnershipTrackpadAndMouseWheelScroller> { + void _onTrackpadStart(PointerPanZoomStartEvent e) { + widget.onPanZoomStart?.call(e); + } + + void _onTrackpadUpdate(PointerPanZoomUpdateEvent e) { + if (GlobalScrollLock.instance._owner == null) { + switch (widget.scrollAxis) { + case Axis.horizontal: + if (e.panDelta.dx.abs() > e.panDelta.dy.abs()) { + GlobalScrollLock.instance.requestLock(this); + } + case Axis.vertical: + if (e.panDelta.dy.abs() > e.panDelta.dx.abs()) { + GlobalScrollLock.instance.requestLock(this); + } + } + } + + if (GlobalScrollLock.instance._owner != this) { + return; + } + + widget.onPanZoomUpdate?.call(e); + } + + void _onTrackpadEnd(PointerPanZoomEndEvent e) { + if (GlobalScrollLock.instance._owner != this) { + return; + } + + widget.onPanZoomEnd?.call(e); + GlobalScrollLock.instance.release(this); + } + + void _onScrollWheelChange(PointerScrollEvent e) { + if (GlobalScrollLock.instance._owner != null) { + return; + } + + widget.onScrollWheel?.call(e); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerPanZoomStart: _onTrackpadStart, + onPointerPanZoomUpdate: _onTrackpadUpdate, + onPointerPanZoomEnd: _onTrackpadEnd, + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + _onScrollWheelChange(event); + } + }, + behavior: HitTestBehavior.opaque, + child: widget.child, + ); + } +} + +/// A singleton that provides a lock for interested/participating scrollables. +/// +/// This lock is intended to help integrate trackpad and mouse wheel scrolling into a +/// UI that may also include traditional gesture-based scrollables. This is needed because +/// trackpad and mouse wheel scrolling behaviors don't participate in the gesture arena, +/// and therefore have no natural ownership mechanism. Not only do those scrolling behaviors +/// need to defer to ongoing gesture scrolling, but there also needs to be a mechanism to +/// force traditional gesture scrollables to defer to trackpade and mouse wheel scrollables. +/// +/// To force traditional scrollables to defer to trackpads and mouse wheels, apply a +/// [DeferToTrackpadsAndMouseWheelsScrollBehavior] `ScrollBehavior` to your entire widget tree. +/// +/// To integrate trackpad and mouse wheel scrolling around an existing scrollable widget, +/// use [SingleAxisTrackpadAndWheelScroller] or [VerticalTrackpadAndWheelScroller]. Or, build +/// your own with a [_DeferredOwnershipTrackpadAndMouseWheelScroller]. +class GlobalScrollLock { + static final GlobalScrollLock instance = GlobalScrollLock._(); + + GlobalScrollLock._(); + + Object? _owner; + + /// Returns `true` if the global scroll lock is currently held by some scrollable, + /// or `false` if the lock isn't held at all. + bool get isLocked => _owner != null; + + /// Returns `true` if the global scroll lock is held by [me], or `false` if its + /// held by some other owner, or not held at all. + bool isLockedByMe(Object me) => _owner == me; + + /// Returns `true` if the global scroll lock is held by an owner that IS NOT [me], + /// or `false` if the lock is held by [me] or not held by any owner at all. + bool isLockedByOther(Object me) => _owner != null && _owner != me; + + /// Request ownership of the global scroll lock, returns `true` if granted. + bool requestLock(Object owner) { + if (_owner == null || _owner == owner) { + _owner = owner; + return true; + } + return false; + } + + /// Releases the global scroll lock, if it's currently owned by [owner]. + void release(Object owner) { + if (_owner == owner) { + _owner = null; + } + } +} + +/// A [ScrollBehavior] that prevents gesture-based scrolling when a trackpad or mouse wheel +/// is already scrolling. +/// +/// This is needed because trackpad and mouse wheel scrolling do not participate in the +/// gesture arena, and therefore there is no built-in mechanism for gesture-based scrollables +/// to defer to on-going trackpad and mouse wheel scrolling. This [ScrollBehavior] intercepts +/// attempts to scroll a gesture-based scrollable, and zeros out the scroll offset, if +/// a trackpad or mouse wheel scroll is currently on-going. +/// +/// To apply this behavior to all scrollables in a given widget tree, surround the tree +/// with a [ScrollConfiguration] widget. +/// +/// ```dart +/// ScrollConfiguration( +/// behavior: const DeferToTrackpadsAndMouseWheelsScrollBehavior(), +/// child: MyWidgetTree(), +/// ); +/// ``` +class DeferToTrackpadsAndMouseWheelsScrollBehavior extends ScrollBehavior { + const DeferToTrackpadsAndMouseWheelsScrollBehavior(); + + @override + ScrollPhysics getScrollPhysics(BuildContext context) { + return _DeferToTrackpadsAndMouseWheelsScrollPhysics(parent: super.getScrollPhysics(context)); + } + + @override + Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) { + // Don't wrap with another Scrollbar + return child; + } +} + +class _DeferToTrackpadsAndMouseWheelsScrollPhysics extends ScrollPhysics { + const _DeferToTrackpadsAndMouseWheelsScrollPhysics({ScrollPhysics? parent}) : super(parent: parent); + + @override + _DeferToTrackpadsAndMouseWheelsScrollPhysics applyTo(ScrollPhysics? ancestor) { + return _DeferToTrackpadsAndMouseWheelsScrollPhysics(parent: buildParent(ancestor)); + } + + @override + double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { + if (position is! ScrollPositionWithSingleContext) { + return super.applyPhysicsToUserOffset(position, offset); + } + + // The ScrollableState is used as the owner identifier + final scrollerId = position.context; + if (GlobalScrollLock.instance.isLockedByOther(scrollerId)) { + return 0.0; + } + + if (GlobalScrollLock.instance._owner == null) { + GlobalScrollLock.instance.requestLock(scrollerId); + + // Watch scrolling until it ends and then release our ownership. + // + // We have to do this in its own object because this class is immutable + // and we need to store a reference to the scroll position. + _ScrollEndHandler(position); + } + + return super.applyPhysicsToUserOffset(position, offset); + } +} + +class _ScrollEndHandler { + _ScrollEndHandler(this._position) { + _position.isScrollingNotifier.addListener(_onScrollingStateChange); + } + + final ScrollPosition _position; + + void _onScrollingStateChange() { + final isScrolling = _position.isScrollingNotifier.value; + if (!isScrolling) { + // Release the global lock + GlobalScrollLock.instance.release(_position.context); + _position.isScrollingNotifier.removeListener(_onScrollingStateChange); + } + } +} diff --git a/super_editor/lib/src/infrastructure/scrolling_diagnostics/_scrolling_minimap.dart b/super_editor/lib/src/infrastructure/scrolling_diagnostics/_scrolling_minimap.dart index ff9f4ebfae..24345f1ba8 100644 --- a/super_editor/lib/src/infrastructure/scrolling_diagnostics/_scrolling_minimap.dart +++ b/super_editor/lib/src/infrastructure/scrolling_diagnostics/_scrolling_minimap.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; // TODO: Write golden tests for scrolling minimap // (Matt - April, 2022) - I tried writing the tests but couldn't get the @@ -47,7 +48,7 @@ class _ScrollingMinimapsState extends State with ScrollingMin } } -abstract class ScrollingMinimapsRepository { +abstract mixin class ScrollingMinimapsRepository { bool hasInstrumentation(String id) => get(id) != null; ScrollableInstrumentation? get(String id); @@ -98,9 +99,7 @@ class _ScrollingMinimapState extends State { } if (!viewportBox.hasSize) { // The viewport hasn't laid out yet. Try again next frame. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - setState(() {}); - }); + scheduleBuildAfterBuild(); return const SizedBox(); } diff --git a/super_editor/lib/src/infrastructure/selectable_list.dart b/super_editor/lib/src/infrastructure/selectable_list.dart new file mode 100644 index 0000000000..ec2eff3793 --- /dev/null +++ b/super_editor/lib/src/infrastructure/selectable_list.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A list where the user can navigate between its items and select one of them. +/// +/// Includes the following keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item. +class ItemSelectionList extends StatefulWidget { + const ItemSelectionList({ + super.key, + required this.value, + required this.items, + this.axis = Axis.vertical, + required this.itemBuilder, + this.separatorBuilder, + this.onItemActivated, + required this.onItemSelected, + this.onCancel, + this.focusNode, + }); + + /// The currently selected value or `null` if no item is selected. + final T? value; + + /// The items that will be displayed in the popover list. + /// + /// For each item, [itemBuilder] is called to build its visual representation. + final List items; + + /// Determines if the list should be displayed vertically or horizontally. + final Axis axis; + + /// Builds each item in the popover list. + /// + /// This method is called for each item in [items], to build its visual representation. + /// + /// The provided `onTap` must be called when the item is tapped. + final SelectableListItemBuilder itemBuilder; + + /// Builds a separator for each item in the list. + /// + /// If `null`, no separator is displayed. + final IndexedWidgetBuilder? separatorBuilder; + + /// Called when the user activates an item on the popover list. + /// + /// The activation can be performed by: + /// 1. Opening the popover, when the selected item is activate. + /// 2. Pressing UP ARROW or DOWN ARROW. + final ValueChanged? onItemActivated; + + /// Called when the user selects an item on the popover list. + /// + /// The selection can be performed by: + /// 1. Tapping on an item in the popover list. + /// 2. Pressing ENTER when the popover list has an active item. + final ValueChanged onItemSelected; + + /// Called when the user presses ESCAPE. + final VoidCallback? onCancel; + + /// The [FocusNode] of the list. + final FocusNode? focusNode; + + @override + State> createState() => ItemSelectionListState(); +} + +@visibleForTesting +class ItemSelectionListState extends State> with SingleTickerProviderStateMixin { + final GlobalKey _scrollableKey = GlobalKey(); + + @visibleForTesting + final ScrollController scrollController = ScrollController(); + + /// Holds keys to each item on the list. + /// + /// Used to scroll the list to reveal the active item. + final List _itemKeys = []; + + int? _activeIndex; + + @override + void initState() { + super.initState(); + _activateSelectedItem(); + } + + @override + void dispose() { + scrollController.dispose(); + + super.dispose(); + } + + void _activateSelectedItem() { + final selectedItem = widget.value; + + if (selectedItem == null) { + _activeIndex = null; + return; + } + + int selectedItemIndex = widget.items.indexOf(selectedItem); + if (selectedItemIndex < 0) { + // A selected item was provided, but it isn't included in the list of items. + _activeIndex = null; + return; + } + + // We just opened the popover. + // Jump to the active item without animation. + _activateItem(selectedItemIndex, animationDuration: Duration.zero); + } + + /// Activates the item at [itemIndex] and ensure it's visible on screen. + /// + /// The active item is selected when the user presses ENTER. + void _activateItem(int? itemIndex, {required Duration animationDuration}) { + _activeIndex = itemIndex; + if (itemIndex != null) { + widget.onItemActivated?.call(widget.items[itemIndex]); + } + + // This method might be called before the widget was rendered. + // For example, when the widget is created with a selected item, + // this item is immediately activated, before the rendering pipeline is + // executed. Therefore, the RenderBox won't be available at the same frame. + // + // Scrolls on the next frame to let the popover be laid-out first, + // so we can access its RenderBox. + onNextFrame((timeStamp) { + _scrollToShowActiveItem(animationDuration); + }); + } + + /// Scrolls the popover scrollable to display the selected item. + void _scrollToShowActiveItem(Duration animationDuration) { + if (_activeIndex == null) { + return; + } + + final key = _itemKeys[_activeIndex!]; + + final childRenderBox = key.currentContext?.findRenderObject() as RenderBox?; + if (childRenderBox == null) { + return; + } + + childRenderBox.showOnScreen( + rect: Offset.zero & childRenderBox.size, + duration: animationDuration, + curve: Curves.easeIn, + ); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + if (!const [ + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.escape, + ].contains(event.logicalKey)) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.escape) { + widget.onCancel?.call(); + return KeyEventResult.handled; + } + + if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { + if (_activeIndex == null) { + // The user pressed ENTER without an active item. + // Clear the selected item. + widget.onItemSelected(null); + return KeyEventResult.handled; + } + + widget.onItemSelected(widget.items[_activeIndex!]); + + return KeyEventResult.handled; + } + + // The user pressed an arrow key. Update the active item. + int? newActiveIndex; + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_activeIndex == null || _activeIndex! >= widget.items.length - 1) { + // We don't have an active item or we are at the end of the list. Activate the first item. + newActiveIndex = 0; + } else { + // Activate the next item. + newActiveIndex = _activeIndex! + 1; + } + } + + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_activeIndex == null || _activeIndex! <= 0) { + // We don't have an active item or we are at the beginning of the list. Activate the last item. + newActiveIndex = widget.items.length - 1; + } else { + // Activate the previous item. + newActiveIndex = _activeIndex! - 1; + } + } + + setState(() { + _activateItem(newActiveIndex, animationDuration: const Duration(milliseconds: 100)); + }); + + return KeyEventResult.handled; + } + + @override + Widget build(BuildContext context) { + _itemKeys.clear(); + + for (int i = 0; i < widget.items.length; i++) { + _itemKeys.add(GlobalKey()); + } + return Focus( + focusNode: widget.focusNode, + onKeyEvent: _onKeyEvent, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + scrollbars: false, + overscroll: false, + physics: const ClampingScrollPhysics(), + ), + child: PrimaryScrollController( + controller: scrollController, + child: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + key: _scrollableKey, + primary: true, + child: IntrinsicWidth( + child: _buildItemsLayout( + children: [ + for (int i = 0; i < widget.items.length; i++) ...[ + if (i > 0 && widget.separatorBuilder != null) // + widget.separatorBuilder!(context, i), + KeyedSubtree( + key: _itemKeys[i], + child: widget.itemBuilder( + context, + widget.items[i], + i == _activeIndex, + () => widget.onItemSelected(widget.items[i]), + ), + ), + ] + ], + ), + ), + ), + ), + ), + ), + ); + } + + /// Builds a `Row` or `Column` which displays the items, depending + /// whether the list is configured to be displayed horizontally or vertically. + Widget _buildItemsLayout({required List children}) { + return widget.axis == Axis.horizontal // + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: children, + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: children, + ); + } +} + +/// Builds a list item. +/// +/// [isActive] is `true` if [item] is the currently active item on the list, or `false` otherwise. +/// +/// The active item is the currently focused item in the list, which can be selected by pressing ENTER. +/// +/// The provided [onTap] must be called when the button is tapped. +typedef SelectableListItemBuilder = Widget Function(BuildContext context, T item, bool isActive, VoidCallback onTap); diff --git a/super_editor/lib/src/infrastructure/serialization/html/document_to_html.dart b/super_editor/lib/src/infrastructure/serialization/html/document_to_html.dart new file mode 100644 index 0000000000..9a55458737 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/html/document_to_html.dart @@ -0,0 +1,115 @@ +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_blockquotes.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_code.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_headers.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_horizontal_rules.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_images.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_list_items.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_paragraphs.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_table.dart'; + +extension HtmlSerialization on Document { + /// Converts this [Document] to an HTML representation. + /// + /// When [selection] is `null`, the entire document is converted to HTML. When + /// [selection] is non-`null`, only the selected content is converted to HTML. + /// + /// When [skipUnknownNodes] is `true`, nodes that don't have an HTML serializer + /// will be ignored. When it's `false`, an exception will be thrown. + String toHtml({ + DocumentSelection? selection, + NodeHtmlSerializerChain nodeSerializers = defaultNodeHtmlSerializerChain, + InlineHtmlSerializerChain inlineSerializers = defaultInlineHtmlSerializers, + bool skipUnknownNodes = true, + }) { + final htmlBuffer = StringBuffer(); + + if (selection != null && selection.isCollapsed) { + // The selection is collapsed to a single position, which by definition can't + // contain any content. + return ""; + } + + late final DocumentRange? selectedRange; + late final List selectedNodes; + if (selection != null) { + selectedRange = selection.normalize(this); + selectedNodes = getNodesInside( + selectedRange.start, + selectedRange.end, + ); + } else { + selectedRange = null; + selectedNodes = toList(growable: false); + } + + for (final node in selectedNodes) { + late final NodeSelection? nodeSelection; + if (selectedRange != null && node.id == selectedRange.start.nodeId && node.id == selectedRange.end.nodeId) { + // The entire copy selection is within this node. + nodeSelection = node.computeSelection( + base: selectedRange.start.nodePosition, + extent: selectedRange.end.nodePosition, + ); + } else if (selectedRange != null && node.id == selectedRange.start.nodeId) { + // The selection starts somewhere in this node and goes to the end of the node. + nodeSelection = node.computeSelection( + base: selectedRange.start.nodePosition, + extent: node.endPosition, + ); + } else if (selectedRange != null && node.id == selectedRange.end.nodeId) { + // The selection starts at the beginning of this node and ends somewhere within this node. + nodeSelection = node.computeSelection( + base: node.beginningPosition, + extent: selectedRange.end.nodePosition, + ); + } else { + nodeSelection = null; + } + + bool didSerializeNode = false; + for (final serializer in nodeSerializers) { + final html = serializer(this, node, nodeSelection, inlineSerializers); + if (html != null) { + htmlBuffer.write(html); + didSerializeNode = true; + break; + } + } + if (!didSerializeNode && !skipUnknownNodes) { + throw Exception("Tried to serialize node ($node) but couldn't find a compatible HTML serializer."); + } + } + + return htmlBuffer.toString(); + } +} + +/// The standard HTML serializers for every type of [DocumentNode] in a [Document]. +/// +/// To customize how [Document]s are serialized to HTML, create a custom list of serializers +/// as needed, and pass that chain into the HTML serializer. +const NodeHtmlSerializerChain defaultNodeHtmlSerializerChain = [ + defaultImageToHtmlSerializer, + defaultHorizontalRuleToHtmlSerializer, + defaultListItemToHtmlSerializer, + defaultHeaderToHtmlSerializer, + defaultBlockquoteToHtmlSerializer, + defaultCodeBlockToHtmlSerializer, + defaultParagraphToHtmlSerializer, + defaultTableToHtmlSerializer, +]; + +/// A priority-order list of [NodeHtmlSerializer]s, which can be used to serialize +/// an entire [Document] of nodes. +typedef NodeHtmlSerializerChain = List; + +/// A function that (maybe) serializes the given [node] to HTML. +typedef NodeHtmlSerializer = String? Function( + Document document, + DocumentNode node, + NodeSelection? selection, + InlineHtmlSerializerChain inlineSerializers, +); diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_blockquotes.dart b/super_editor/lib/src/infrastructure/serialization/html/html_blockquotes.dart new file mode 100644 index 0000000000..ba1fbaf051 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/html/html_blockquotes.dart @@ -0,0 +1,36 @@ +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart'; + +String? defaultBlockquoteToHtmlSerializer( + Document document, + DocumentNode node, + NodeSelection? selection, + InlineHtmlSerializerChain inlineSerializers, +) { + if (node is! ParagraphNode) { + return null; + } + if (node.getMetadataValue(NodeMetadata.blockType) != blockquoteAttribution) { + return null; + } + if (selection != null && selection is! TextNodeSelection) { + // We don't know how to handle this selection type. + return null; + } + + final textSelection = selection as TextNodeSelection?; + if (true == textSelection?.isCollapsed) { + // Nothing is selected. + return ""; + } + + final content = node.text.toHtml( + serializers: inlineSerializers, + start: textSelection?.start, + end: textSelection?.end, + ); + return '
$content
'; +} diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_code.dart b/super_editor/lib/src/infrastructure/serialization/html/html_code.dart new file mode 100644 index 0000000000..6dd07f0fde --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/html/html_code.dart @@ -0,0 +1,36 @@ +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart'; + +String? defaultCodeBlockToHtmlSerializer( + Document document, + DocumentNode node, + NodeSelection? selection, + InlineHtmlSerializerChain inlineSerializers, +) { + if (node is! ParagraphNode) { + return null; + } + if (node.getMetadataValue(NodeMetadata.blockType) != codeAttribution) { + return null; + } + if (selection != null && selection is! TextNodeSelection) { + // We don't know how to handle this selection type. + return null; + } + + final textSelection = selection as TextNodeSelection?; + if (true == textSelection?.isCollapsed) { + // Nothing is selected. + return ""; + } + + final content = node.text.toHtml( + serializers: inlineSerializers, + start: textSelection?.start, + end: textSelection?.end, + ); + return '
$content
'; +} diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_headers.dart b/super_editor/lib/src/infrastructure/serialization/html/html_headers.dart new file mode 100644 index 0000000000..d0c59e6662 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/html/html_headers.dart @@ -0,0 +1,70 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart'; + +String? defaultHeaderToHtmlSerializer( + Document document, + DocumentNode node, + NodeSelection? selection, + InlineHtmlSerializerChain inlineSerializers, +) { + if (node is! ParagraphNode) { + return null; + } + + final headerType = node.getMetadataValue(NodeMetadata.blockType); + if (!const [ + header1Attribution, + header2Attribution, + header3Attribution, + header4Attribution, + header5Attribution, + header6Attribution, + ].contains(headerType)) { + // Not a header. + return null; + } + if (selection != null && selection is! TextNodeSelection) { + // We don't know how to handle this selection type. + return null; + } + + final textSelection = selection as TextNodeSelection?; + if (true == textSelection?.isCollapsed) { + // Nothing is selected. + return ""; + } + + final openTag = _openTag(headerType); + final closeTag = _closeTag(headerType); + final content = node.text.toHtml( + serializers: inlineSerializers, + start: textSelection?.start, + end: textSelection?.end, + ); + return '$openTag$content$closeTag'; +} + +String _openTag(Attribution headerType) { + return _tag(headerType, isOpening: true); +} + +String _closeTag(Attribution headerType) { + return _tag(headerType, isOpening: false); +} + +String _tag(Attribution headerType, {required bool isOpening}) { + return switch (headerType) { + header1Attribution => isOpening ? "

" : "

", + header2Attribution => isOpening ? "

" : "

", + header3Attribution => isOpening ? "

" : "

", + header4Attribution => isOpening ? "

" : "

", + header5Attribution => isOpening ? "
" : "
", + header6Attribution => isOpening ? "
" : "
", + _ => throw Exception( + "Tried to create HTML tag for a header block, but we don't recognize this block type: $headerType"), + }; +} diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_horizontal_rules.dart b/super_editor/lib/src/infrastructure/serialization/html/html_horizontal_rules.dart new file mode 100644 index 0000000000..3b8af6a93c --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/html/html_horizontal_rules.dart @@ -0,0 +1,28 @@ +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/horizontal_rule.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart'; + +String? defaultHorizontalRuleToHtmlSerializer( + Document document, + DocumentNode node, + NodeSelection? selection, + InlineHtmlSerializerChain inlineSerializers, +) { + if (node is! HorizontalRuleNode) { + return null; + } + if (selection != null) { + if (selection is! UpstreamDownstreamNodeSelection) { + // We don't know how to handle this selection type. + return null; + } + if (selection.isCollapsed) { + // This selection doesn't include the HR - it's a collapsed selection + // either on the upstream or downstream edge. + return null; + } + } + + return '
'; +} diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_images.dart b/super_editor/lib/src/infrastructure/serialization/html/html_images.dart new file mode 100644 index 0000000000..8788b843d0 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/html/html_images.dart @@ -0,0 +1,28 @@ +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/image.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart'; + +String? defaultImageToHtmlSerializer( + Document document, + DocumentNode node, + NodeSelection? selection, + InlineHtmlSerializerChain inlineSerializers, +) { + if (node is! ImageNode) { + return null; + } + if (selection != null) { + if (selection is! UpstreamDownstreamNodeSelection) { + // We don't know how to handle this selection type. + return null; + } + if (selection.isCollapsed) { + // This selection doesn't include the image - it's a collapsed selection + // either on the upstream or downstream edge. + return null; + } + } + + return ''; +} diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_inline_text_styles.dart b/super_editor/lib/src/infrastructure/serialization/html/html_inline_text_styles.dart new file mode 100644 index 0000000000..079bd6b040 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/html/html_inline_text_styles.dart @@ -0,0 +1,194 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; + +extension AttributedTextToHtml on AttributedText { + String toHtml({ + int? start, + int? end, + InlineHtmlSerializerChain serializers = defaultInlineHtmlSerializers, + }) { + // Pull out the part of this text that we want to encode. + final substring = start != null || end != null ? copyText(start ?? 0, end ?? length) : this; + + // Write this attributed text to HTML. + final htmlBuffer = StringBuffer(); + int textIndex = 0; + + substring.visitAttributions( + CallbackAttributionVisitor( + visitAttributions: ( + AttributedText fullText, + int index, + Set startingAttributions, + Set endingAttributions, + ) { + // Encode the content between the last set of tags and just before + // this offset. + if (index > 0) { + htmlBuffer.write(fullText.substring(textIndex, index)); + } + + // Encode opening tags before the character at this index. + if (startingAttributions.isNotEmpty) { + final tags = startingAttributions + .map((a) => _encodeTag(serializers, a, TagType.opening)) + .nonNulls + .sorted(_sortOpeningTags); + if (tags.isNotEmpty) { + htmlBuffer.write(tags.join("")); + } + } + + // Write the character at this index. + htmlBuffer.write(fullText[index]); + + // Encode closing tags after the character at this index. + if (endingAttributions.isNotEmpty) { + final tags = endingAttributions + .map((a) => _encodeTag(serializers, a, TagType.closing)) + .nonNulls + .sorted(_sortClosingTags); + if (tags.isNotEmpty) { + htmlBuffer.write(tags.join("")); + } + } + + textIndex = index + 1; + }, + onVisitEnd: () { + // TODO: consider changing this behavior in attributed_text because + // it's unexpected that we wouldn't be passed all the + // attributions that end at the end of the string. In other + // words, `visitAttributions` stops one segment too soon. + // Append the final string segment. + if (textIndex < substring.length) { + htmlBuffer.write(substring.substring(textIndex)); + + // Encode final ending tags. + final endingAttributions = getAllAttributionsAt(length - 1); + if (endingAttributions.isNotEmpty) { + final tags = endingAttributions + .map((a) => _encodeTag(serializers, a, TagType.closing)) + .nonNulls + .sorted(_sortClosingTags); + if (tags.isNotEmpty) { + htmlBuffer.write(tags.join("")); + } + } + } + }, + ), + ); + + return htmlBuffer.toString(); + } + + String? _encodeTag(InlineHtmlSerializerChain serializers, Attribution attribution, TagType tagType) { + for (final serializer in serializers) { + final tag = serializer(attribution, tagType); + if (tag != null) { + return tag; + } + } + + return null; + } + + int _sortOpeningTags(String tagA, String tagB) { + // Sort opening tags alphabetically, to give us a consistent ordering. + return tagA.compareTo(tagB); + } + + int _sortClosingTags(String tagA, String tagB) { + // Sort in the opposite order as starting tags, so tags end from inside out. + return -_sortOpeningTags(tagA, tagB); + } +} + +enum TagType { + opening, + closing; +} + +const defaultInlineHtmlSerializers = [ + defaultBoldHtmlSerializer, + defaultItalicsHtmlSerializer, + defaultUnderlineHtmlSerializer, + defaultStrikethroughHtmlSerializer, + defaultCodeHtmlSerializer, + defaultLinkHtmlSerializer, +]; + +/// A priority-order list of [InlineHtmlSerializer]s, which can be used to serialize +/// text segments from a document to inline HTML. +typedef InlineHtmlSerializerChain = List; + +/// A function that (maybe) serializes the given [attribution] to an HTML tag. +typedef InlineHtmlSerializer = String? Function(Attribution attribution, TagType tagType); + +String? defaultBoldHtmlSerializer(Attribution attribution, TagType tagType) { + if (attribution != boldAttribution) { + return null; + } + + return switch (tagType) { + TagType.opening => '', + TagType.closing => '', + }; +} + +String? defaultItalicsHtmlSerializer(Attribution attribution, TagType tagType) { + if (attribution != italicsAttribution) { + return null; + } + + return switch (tagType) { + TagType.opening => '', + TagType.closing => '', + }; +} + +String? defaultUnderlineHtmlSerializer(Attribution attribution, TagType tagType) { + if (attribution != underlineAttribution) { + return null; + } + + return switch (tagType) { + TagType.opening => '', + TagType.closing => '', + }; +} + +String? defaultStrikethroughHtmlSerializer(Attribution attribution, TagType tagType) { + if (attribution != strikethroughAttribution) { + return null; + } + + return switch (tagType) { + TagType.opening => '', + TagType.closing => '', + }; +} + +String? defaultCodeHtmlSerializer(Attribution attribution, TagType tagType) { + if (attribution != codeAttribution) { + return null; + } + + return switch (tagType) { + TagType.opening => '', + TagType.closing => '', + }; +} + +String? defaultLinkHtmlSerializer(Attribution attribution, TagType tagType) { + if (attribution is! LinkAttribution) { + return null; + } + + return switch (tagType) { + TagType.opening => '', + TagType.closing => '', + }; +} diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_list_items.dart b/super_editor/lib/src/infrastructure/serialization/html/html_list_items.dart new file mode 100644 index 0000000000..cfd26078f0 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/html/html_list_items.dart @@ -0,0 +1,63 @@ +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/list_items.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart'; + +String? defaultListItemToHtmlSerializer( + Document document, + DocumentNode node, + NodeSelection? selection, + InlineHtmlSerializerChain inlineSerializers, +) { + if (node is! ListItemNode) { + return null; + } + if (selection != null && selection is! TextNodeSelection) { + // We don't know how to handle this selection type. + return null; + } + final textSelection = selection as TextNodeSelection?; + + return node.toHtml(document, inlineSerializers, start: textSelection?.start, end: textSelection?.end); +} + +extension ListItemNodeToHtml on ListItemNode { + String toHtml(Document document, InlineHtmlSerializerChain inlineSerializers, {int? start, int? end}) { + if (start != null && start == end) { + // Selection is collapsed. Nothing is selected for copy. + return ""; + } + + final content = text.toHtml(serializers: inlineSerializers, start: start, end: end); + + final nodeBefore = document.getNodeBeforeById(id); + final isListStart = nodeBefore == null || nodeBefore is! ListItemNode || nodeBefore.type != type; + + final nodeAfter = document.getNodeAfterById(id); + final isListEnd = nodeAfter == null || nodeAfter is! ListItemNode || nodeAfter.type != type; + + final htmlBuffer = StringBuffer(); + + if (isListStart) { + switch (type) { + case ListItemType.ordered: + htmlBuffer.write('
    '); + case ListItemType.unordered: + htmlBuffer.write('
      '); + } + } + + htmlBuffer.write('
    • $content
    • '); + + if (isListEnd) { + switch (type) { + case ListItemType.ordered: + htmlBuffer.write('
'); + case ListItemType.unordered: + htmlBuffer.write(''); + } + } + + return htmlBuffer.toString(); + } +} diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_paragraphs.dart b/super_editor/lib/src/infrastructure/serialization/html/html_paragraphs.dart new file mode 100644 index 0000000000..da1f95e5b7 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/html/html_paragraphs.dart @@ -0,0 +1,36 @@ +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart'; + +String? defaultParagraphToHtmlSerializer( + Document document, + DocumentNode node, + NodeSelection? selection, + InlineHtmlSerializerChain inlineSerializers, +) { + if (node is! ParagraphNode) { + return null; + } + if (node.getMetadataValue(NodeMetadata.blockType) != paragraphAttribution) { + return null; + } + if (selection != null && selection is! TextNodeSelection) { + // We don't know how to handle this selection type. + return null; + } + + final textSelection = selection as TextNodeSelection?; + if (true == textSelection?.isCollapsed) { + // Nothing is selected. + return ""; + } + + final content = node.text.toHtml( + serializers: inlineSerializers, + start: textSelection?.start, + end: textSelection?.end, + ); + return '

$content

'; +} diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_table.dart b/super_editor/lib/src/infrastructure/serialization/html/html_table.dart new file mode 100644 index 0000000000..3521e6f66b --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/html/html_table.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/tables/table_block.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart'; + +/// Serializes a [TableBlockNode] to HTML. +/// +/// Table rows that contain only header cells (i.e., cells with the [tableHeaderAttribution] +/// metadata) and appear at the beginning of the table are serialized inside a `` element. +/// +/// All other rows are serialized inside a `` element. +/// +/// Each cell with the [tableHeaderAttribution] metadata is serialized as a `` element. +/// +/// If the [selection] is non-`null`, it must be an [UpstreamDownstreamNodeSelection]. Returns +/// an empty string if the selection is collapsed. +String? defaultTableToHtmlSerializer( + Document document, + DocumentNode node, + NodeSelection? selection, + InlineHtmlSerializerChain inlineSerializers, +) { + if (node is! TableBlockNode) { + return null; + } + if (selection != null) { + if (selection is! UpstreamDownstreamNodeSelection) { + // We don't know how to handle this selection type. + return null; + } + if (selection.isCollapsed) { + // This selection doesn't include the table - it's a collapsed selection + // either on the upstream or downstream edge. Return an empty string to + // signal we handled the serialization, but there's no content to include. + return ''; + } + } + + return node.toHtml(document, inlineSerializers); +} + +extension TableBlockNodeToHtml on TableBlockNode { + String toHtml(Document document, InlineHtmlSerializerChain inlineSerializers) { + final htmlBuffer = StringBuffer(); + htmlBuffer.write(''); + + final headers = >[]; + final dataRows = >[]; + + for (int i = 0; i < rowCount; i++) { + final row = getRow(i); + + if (dataRows.isNotEmpty) { + // We already have data rows. Each row after the first data row is also + // a data row. + dataRows.add(row); + continue; + } + + bool doesRowContainOnlyHeaders = true; + for (final cell in row) { + if (cell.getMetadataValue(NodeMetadata.blockType) != tableHeaderAttribution) { + doesRowContainOnlyHeaders = false; + break; + } + } + + if (doesRowContainOnlyHeaders) { + headers.add(row); + } else { + dataRows.add(row); + } + } + + if (headers.isNotEmpty) { + htmlBuffer.write(''); + for (final headerRow in headers) { + htmlBuffer.write(''); + for (final cell in headerRow) { + final cellContent = cell.text.toHtml( + serializers: inlineSerializers, + ); + htmlBuffer.write('$cellContent'); + } + htmlBuffer.write(''); + } + htmlBuffer.write(''); + } + + if (dataRows.isNotEmpty) { + htmlBuffer.write(''); + for (final row in dataRows) { + htmlBuffer.write(''); + for (final cell in row) { + final cellContent = cell.text.toHtml( + serializers: inlineSerializers, + ); + final tag = cell.getMetadataValue(NodeMetadata.blockType) == tableHeaderAttribution ? 'th' : 'td'; + htmlBuffer.write('<$tag${_getTextAlignStyle(cell)}>$cellContent'); + } + htmlBuffer.write(''); + } + htmlBuffer.write(''); + } + + htmlBuffer.write('
'); + return htmlBuffer.toString(); + } + + String _getTextAlignStyle(TextNode cell) { + final textAlign = cell.getMetadataValue('textAlign'); + if (textAlign == TextAlign.left) { + // Default alignment is left, so we don't need to specify it. + return ''; + } + final textAlignString = switch (textAlign) { + TextAlign.center => 'center', + TextAlign.right => 'right', + _ => 'left', + }; + return textAlign != null ? ' style="text-align:$textAlignString"' : ''; + } +} diff --git a/super_editor/lib/src/infrastructure/serialization/markdown/document_to_markdown_serializer.dart b/super_editor/lib/src/infrastructure/serialization/markdown/document_to_markdown_serializer.dart new file mode 100644 index 0000000000..3d837015d5 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/markdown/document_to_markdown_serializer.dart @@ -0,0 +1,731 @@ +import 'dart:ui'; + +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/foundation.dart'; +import 'package:markdown/markdown.dart' hide Document; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/horizontal_rule.dart'; +import 'package:super_editor/src/default_editor/image.dart'; +import 'package:super_editor/src/default_editor/list_items.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/tables/table_block.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/default_editor/text.dart'; + +import 'package:super_editor/src/infrastructure/serialization/markdown/super_editor_syntax.dart'; + +/// Serializes the given [doc] to Markdown text. +/// +/// When [selection] is provided, only the selected range of the document is serialized. +/// +/// The given [syntax] controls how the [doc] is serialized, e.g., [MarkdownSyntax.normal] +/// for standard Markdown syntax, or [MarkdownSyntax.superEditor] to use Super Editor's +/// extended syntax. +/// +/// To serialize [DocumentNode]s that aren't part of Super Editor's standard serialization, +/// provide [customNodeSerializers] to serialize those custom nodes. +String serializeDocumentToMarkdown( + Document doc, { + DocumentSelection? selection, + MarkdownSyntax syntax = MarkdownSyntax.superEditor, + List customNodeSerializers = const [], +}) { + final nodeSerializers = [ + // Custom serializers first, in case the custom serializers handle + // specialized cases of traditional nodes, such as serializing a + // `ParagraphNode` with a special `"blockType"`. + ...customNodeSerializers, + ImageNodeSerializer(useSizeNotation: syntax == MarkdownSyntax.superEditor), + const HorizontalRuleNodeSerializer(), + const ListItemNodeSerializer(), + const TaskNodeSerializer(), + HeaderNodeSerializer(syntax), + ParagraphNodeSerializer(syntax), + const TableBlockNodeSerializer(), + ]; + + StringBuffer buffer = StringBuffer(); + + late final DocumentRange? selectedRange; + late final List selectedNodes; + if (selection != null) { + selectedRange = selection.normalize(doc); + selectedNodes = doc.getNodesInside( + selectedRange.start, + selectedRange.end, + ); + } else { + selectedRange = null; + selectedNodes = doc.toList(growable: false); + } + + for (int i = 0; i < selectedNodes.length; ++i) { + final node = selectedNodes[i]; + late final NodeSelection? nodeSelection; + if (selectedRange != null && node.id == selectedRange.start.nodeId && node.id == selectedRange.end.nodeId) { + // The entire copy selection is within this node. + nodeSelection = node.computeSelection( + base: selectedRange.start.nodePosition, + extent: selectedRange.end.nodePosition, + ); + } else if (selectedRange != null && node.id == selectedRange.start.nodeId) { + // The selection starts somewhere in this node and goes to the end of the node. + nodeSelection = node.computeSelection( + base: selectedRange.start.nodePosition, + extent: node.endPosition, + ); + } else if (selectedRange != null && node.id == selectedRange.end.nodeId) { + // The selection starts at the beginning of this node and ends somewhere within this node. + nodeSelection = node.computeSelection( + base: node.beginningPosition, + extent: selectedRange.end.nodePosition, + ); + } else { + // The node is fully selected, so we don't need to specify a selection. + nodeSelection = null; + } + + for (final serializer in nodeSerializers) { + final serialization = serializer.serialize(doc, node, selection: nodeSelection); + if (serialization != null) { + if (i > 0) { + // Add a new line before every node, except the first node. + buffer.writeln(""); + } + + buffer.write(serialization); + break; + } + } + } + + return buffer.toString(); +} + +/// Serializes a given [DocumentNode] to a Markdown `String`. +abstract class DocumentNodeMarkdownSerializer { + /// Serializes the given [node] to a Markdown `String`. + /// + /// When [selection] is `null`, the entire node is converted to markdown. When + /// [selection] is non-`null`, only the selected range is converted to markdown. + /// + /// Returns `null` if the [node] is not supported by this serializer. + String? serialize( + Document document, + DocumentNode node, { + NodeSelection? selection, + }); +} + +/// A [DocumentNodeMarkdownSerializer] that automatically rejects any +/// [DocumentNode] that doesn't match the given [NodeType]. +/// +/// Use this base class to avoid repeating type checks across various +/// serializers. +abstract class NodeTypedDocumentNodeMarkdownSerializer implements DocumentNodeMarkdownSerializer { + const NodeTypedDocumentNodeMarkdownSerializer(); + + @override + String? serialize( + Document document, + DocumentNode node, { + NodeSelection? selection, + }) { + if (node is! NodeType) { + return null; + } + + return doSerialization(document, node as NodeType, selection: selection); + } + + @protected + String doSerialization( + Document document, + NodeType node, { + NodeSelection? selection, + }); +} + +/// [DocumentNodeMarkdownSerializer] for serializing [ImageNode]s as standard Markdown +/// images. +class ImageNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { + const ImageNodeSerializer({ + this.useSizeNotation = false, + }); + + final bool useSizeNotation; + + @override + String doSerialization( + Document document, + ImageNode node, { + NodeSelection? selection, + }) { + if (selection != null) { + if (selection is! UpstreamDownstreamNodeSelection) { + // We don't know how to handle this selection type. + return ''; + } + if (selection.isCollapsed) { + // This selection doesn't include the image - it's a collapsed selection + // either on the upstream or downstream edge. + return ''; + } + } + + if (!useSizeNotation || (node.expectedBitmapSize?.width == null && node.expectedBitmapSize?.height == null)) { + // We don't want to use size notation or the image doesn't have + // size information. Use the regular syntax. + return '![${node.altText}](${node.imageUrl})'; + } + + StringBuffer sizeNotation = StringBuffer(); + sizeNotation.write(' ='); + + if (node.expectedBitmapSize?.width != null) { + sizeNotation.write(node.expectedBitmapSize!.width!.toInt()); + } + + sizeNotation.write('x'); + + if (node.expectedBitmapSize?.height != null) { + sizeNotation.write(node.expectedBitmapSize!.height!.toInt()); + } + + return '![${node.altText}](${node.imageUrl}${sizeNotation.toString()})'; + } +} + +/// [DocumentNodeMarkdownSerializer] for serializing [HorizontalRuleNode]s as standard +/// Markdown horizontal rules. +class HorizontalRuleNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { + const HorizontalRuleNodeSerializer(); + + @override + String doSerialization( + Document document, + HorizontalRuleNode node, { + NodeSelection? selection, + }) { + if (selection != null) { + if (selection is! UpstreamDownstreamNodeSelection) { + // We don't know how to handle this selection type. + return ''; + } + if (selection.isCollapsed) { + // This selection doesn't include the horizontal rule - it's a collapsed selection + // either on the upstream or downstream edge. + return ''; + } + } + + return '---'; + } +} + +/// [DocumentNodeMarkdownSerializer] for serializing [ListItemNode]s as standard Markdown +/// list items. +/// +/// Includes support for ordered and unordered list items. +class ListItemNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { + const ListItemNodeSerializer(); + + @override + String doSerialization( + Document document, + ListItemNode node, { + NodeSelection? selection, + }) { + if (selection != null && selection is! TextNodeSelection) { + // We don't know how to handle this selection type. + return ''; + } + final textSelection = selection as TextNodeSelection?; + if (textSelection != null && textSelection.isCollapsed) { + // Selection is collapsed. Nothing is selected for copy. + return ''; + } + final textToConvert = textSelection != null // + ? node.text.copyText(textSelection.start, textSelection.end) + : node.text; + + final buffer = StringBuffer(); + + final indent = List.generate(node.indent + 1, (index) => ' ').join(''); + final symbol = node.type == ListItemType.unordered ? '*' : '1.'; + + buffer.write('$indent$symbol ${textToConvert.toMarkdown()}'); + + final nodeIndex = document.getNodeIndexById(node.id); + final nodeBelow = nodeIndex < document.nodeCount - 1 ? document.getNodeAt(nodeIndex + 1) : null; + if (nodeBelow != null && (nodeBelow is! ListItemNode || nodeBelow.type != node.type)) { + // This list item is the last item in the list. Add an extra + // blank line after it. + buffer.writeln(''); + } + + return buffer.toString(); + } +} + +/// [DocumentNodeMarkdownSerializer] for serializing [ParagraphNode]s as standard Markdown +/// paragraphs. +/// +/// Includes support for headers, blockquotes, and code blocks. +class ParagraphNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { + const ParagraphNodeSerializer(this.markdownSyntax); + + final MarkdownSyntax markdownSyntax; + + @override + String doSerialization( + Document document, + ParagraphNode node, { + NodeSelection? selection, + }) { + if (selection != null && selection is! TextNodeSelection) { + // We don't know how to handle this selection type. + return ''; + } + final textSelection = selection as TextNodeSelection?; + if (textSelection != null && textSelection.isCollapsed) { + // Selection is collapsed. Nothing is selected for copy. + return ''; + } + + final buffer = StringBuffer(); + + final Attribution? blockType = node.getMetadataValue('blockType'); + + final inlineMarkdown = (textSelection != null // + ? node.text.copyText(textSelection.start, textSelection.end) + : node.text) + .toMarkdown(); + + if (blockType == header1Attribution) { + buffer.write('# $inlineMarkdown'); + } else if (blockType == header2Attribution) { + buffer.write('## $inlineMarkdown'); + } else if (blockType == header3Attribution) { + buffer.write('### $inlineMarkdown'); + } else if (blockType == header4Attribution) { + buffer.write('#### $inlineMarkdown'); + } else if (blockType == header5Attribution) { + buffer.write('##### $inlineMarkdown'); + } else if (blockType == header6Attribution) { + buffer.write('###### $inlineMarkdown'); + } else if (blockType == blockquoteAttribution) { + // TODO: handle multiline + buffer.write('> $inlineMarkdown'); + } else if (blockType == codeAttribution) { + buffer // + ..writeln('```') // + ..writeln(inlineMarkdown) // + ..write('```'); + } else { + final String? textAlign = node.getMetadataValue('textAlign'); + // Left alignment is the default, so there is no need to add the alignment token. + if (markdownSyntax == MarkdownSyntax.superEditor && textAlign != null && textAlign != 'left') { + final alignmentToken = _convertAlignmentToMarkdown(textAlign); + if (alignmentToken != null) { + buffer.writeln(alignmentToken); + } + } + buffer.write(inlineMarkdown); + } + + // We're not at the end of the document yet. Add a blank line after the + // paragraph so that we can tell the difference between separate + // paragraphs vs. newlines within a single paragraph. + final nodeIndex = document.getNodeIndexById(node.id); + if (nodeIndex != document.nodeCount - 1) { + buffer.writeln(); + } + + return buffer.toString(); + } +} + +/// [DocumentNodeMarkdownSerializer] for serializing [TaskNode]s using Github's style syntax. +/// +/// A completed task is serialized as `- [x] This is a completed task` +/// An incomplete task is serialized as `- [ ] This is an incomplete task` +class TaskNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { + const TaskNodeSerializer(); + + @override + String doSerialization( + Document document, + TaskNode node, { + NodeSelection? selection, + }) { + if (selection != null && selection is! TextNodeSelection) { + // We don't know how to handle this selection type. + return ''; + } + final textSelection = selection as TextNodeSelection?; + if (textSelection != null && textSelection.isCollapsed) { + // Selection is collapsed. Nothing is selected for copy. + return ''; + } + final textToConvert = textSelection != null // + ? node.text.copyText(textSelection.start, textSelection.end) + : node.text; + + return '- [${node.isComplete ? 'x' : ' '}] ${textToConvert.toMarkdown()}'; + } +} + +String? _convertAlignmentToMarkdown(String alignment) { + switch (alignment) { + case 'left': + return ':---'; + case 'center': + return ':---:'; + case 'right': + return '---:'; + case 'justify': + return '-::-'; + default: + return null; + } +} + +/// Extension on [AttributedText] to serialize the [AttributedText] to a Markdown `String`. +extension Markdown on AttributedText { + String toMarkdown() { + final serializer = AttributedTextMarkdownSerializer(); + return serializer.serialize(this); + } +} + +/// Serializes an [AttributedText] into markdown format +class AttributedTextMarkdownSerializer extends AttributionVisitor { + late String _fullText; + late StringBuffer _buffer; + late int _bufferCursor; + + String serialize(AttributedText attributedText) { + _fullText = attributedText.toPlainText(); + _buffer = StringBuffer(); + _bufferCursor = 0; + if (attributedText.toPlainText().isNotEmpty) { + attributedText.visitAttributions(this); + } + return _buffer.toString(); + } + + @override + void visitAttributions( + AttributedText fullText, + int index, + Set startingAttributions, + Set endingAttributions, + ) { + // Write out the text between the end of the last markers, and these new markers. + _writeTextToBuffer( + fullText.toPlainText().substring(_bufferCursor, index), + ); + + // Add start markers. + if (startingAttributions.isNotEmpty) { + final markdownStyles = _sortAndSerializeAttributions(startingAttributions, AttributionVisitEvent.start); + // Links are different from the plain styles since they are both not NamedAttributions (and therefore + // can't be checked using equality comparison) and asymmetrical in markdown. + final linkMarker = _encodeLinkMarker(startingAttributions, AttributionVisitEvent.start); + + _buffer + ..write(linkMarker) + ..write(markdownStyles); + } + + // Write out the character at this index. + _writeTextToBuffer(_fullText[index]); + _bufferCursor = index + 1; + + // Add end markers. + if (endingAttributions.isNotEmpty) { + final markdownStyles = _sortAndSerializeAttributions(endingAttributions, AttributionVisitEvent.end); + // Links are different from the plain styles since they are both not NamedAttributions (and therefore + // can't be checked using equality comparison) and asymmetrical in markdown. + final linkMarker = _encodeLinkMarker(endingAttributions, AttributionVisitEvent.end); + + _buffer + ..write(markdownStyles) + ..write(linkMarker); + } + } + + @override + void onVisitEnd() { + // When the last span has no attributions, we still have text that wasn't added to the buffer yet. + if (_bufferCursor <= _fullText.length - 1) { + _writeTextToBuffer(_fullText.substring(_bufferCursor)); + } + } + + /// Writes the given [text] to [_buffer]. + /// + /// Separates multiple lines in a single paragraph using two spaces before each line break. + /// + /// A line ending with two or more spaces represents a hard line break, + /// as defined in the Markdown spec. + void _writeTextToBuffer(String text) { + final lines = text.split('\n'); + for (int i = 0; i < lines.length; i++) { + if (i > 0) { + // Adds two spaces before line breaks. + // The Markdown spec defines that a line ending with two or more spaces + // represents a hard line break, which causes the next line to be part of + // the previous paragraph during deserialization. + _buffer.write(' '); + _buffer.write('\n'); + } + + _buffer.write(lines[i]); + } + } + + /// Serializes style attributions into markdown syntax in a repeatable + /// order such that opening and closing styles match each other on + /// the opening and closing ends of a span. + static String _sortAndSerializeAttributions(Set attributions, AttributionVisitEvent event) { + const startOrder = [ + codeAttribution, + boldAttribution, + italicsAttribution, + strikethroughAttribution, + underlineAttribution, + ]; + + final buffer = StringBuffer(); + final encodingOrder = event == AttributionVisitEvent.start ? startOrder : startOrder.reversed; + + for (final markdownStyleAttribution in encodingOrder) { + if (attributions.contains(markdownStyleAttribution)) { + buffer.write(_encodeMarkdownStyle(markdownStyleAttribution)); + } + } + + return buffer.toString(); + } + + static String _encodeMarkdownStyle(Attribution attribution) { + if (attribution == codeAttribution) { + return '`'; + } else if (attribution == boldAttribution) { + return '**'; + } else if (attribution == italicsAttribution) { + return '*'; + } else if (attribution == strikethroughAttribution) { + return '~'; + } else if (attribution == underlineAttribution) { + return '¬'; + } else { + return ''; + } + } + + /// Checks for the presence of a link in the attributions and returns the characters necessary to represent it + /// at the open or closing boundary of the attribution, depending on the event. + static String _encodeLinkMarker(Set attributions, AttributionVisitEvent event) { + final linkAttributions = attributions.whereType(); + if (linkAttributions.isNotEmpty) { + final linkAttribution = linkAttributions.first as LinkAttribution; + + if (event == AttributionVisitEvent.start) { + return '['; + } else { + return '](${linkAttribution.plainTextUri})'; + } + } + return ""; + } +} + +/// [DocumentNodeMarkdownSerializer], which serializes Markdown headers to +/// [ParagraphNode]s with an appropriate header block type, and (optionally) a +/// block alignment. +/// +/// Headers are represented by `ParagraphNode`s and therefore this serializer must +/// run before a [ParagraphNodeSerializer], so that this serializer can process +/// header-specific details, such as header alignment. +class HeaderNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { + const HeaderNodeSerializer(this.markdownSyntax); + + final MarkdownSyntax markdownSyntax; + + @override + String? serialize( + Document document, + DocumentNode node, { + NodeSelection? selection, + }) { + if (node is! ParagraphNode) { + return null; + } + + // Only serialize this node when this is a header node. + final Attribution? blockType = node.getMetadataValue('blockType'); + final isHeaderNode = blockType == header1Attribution || + blockType == header2Attribution || + blockType == header3Attribution || + blockType == header4Attribution || + blockType == header5Attribution || + blockType == header6Attribution; + + if (!isHeaderNode) { + return null; + } + + return doSerialization(document, node); + } + + @override + String doSerialization( + Document document, + ParagraphNode node, { + NodeSelection? selection, + }) { + if (selection != null && selection is! TextNodeSelection) { + // We don't know how to handle this selection type. + return ''; + } + final textSelection = selection as TextNodeSelection?; + if (textSelection != null && textSelection.isCollapsed) { + // Selection is collapsed. Nothing is selected for copy. + return ''; + } + final textToConvert = textSelection != null // + ? node.text.copyText(textSelection.start, textSelection.end) + : node.text; + + final buffer = StringBuffer(); + + final Attribution? blockType = node.getMetadataValue('blockType'); + final String? textAlign = node.getMetadataValue('textAlign'); + + // Add the alignment token, we exclude the left alignment because it's the default. + if (markdownSyntax == MarkdownSyntax.superEditor && textAlign != null && textAlign != 'left') { + final alignmentToken = _convertAlignmentToMarkdown(textAlign); + if (alignmentToken != null) { + buffer.writeln(alignmentToken); + } + } + + if (blockType == header1Attribution) { + buffer.write('# ${textToConvert.toMarkdown()}'); + } else if (blockType == header2Attribution) { + buffer.write('## ${textToConvert.toMarkdown()}'); + } else if (blockType == header3Attribution) { + buffer.write('### ${textToConvert.toMarkdown()}'); + } else if (blockType == header4Attribution) { + buffer.write('#### ${textToConvert.toMarkdown()}'); + } else if (blockType == header5Attribution) { + buffer.write('##### ${textToConvert.toMarkdown()}'); + } else if (blockType == header6Attribution) { + buffer.write('###### ${textToConvert.toMarkdown()}'); + } + + // We're not at the end of the document yet. Add a blank line after the + // paragraph so that we can tell the difference between separate + // paragraphs vs. newlines within a single paragraph. + final nodeIndex = document.getNodeIndexById(node.id); + if (nodeIndex != document.nodeCount - 1) { + buffer.writeln(); + } + + return buffer.toString(); + } +} + +/// [DocumentNodeMarkdownSerializer] for serializing [TableBlockNode]s as the extended Markdown +/// syntax for tables. +/// +/// See https://www.markdownguide.org/extended-syntax/#tables for the specification. +class TableBlockNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { + const TableBlockNodeSerializer(); + + @override + String doSerialization( + Document document, + TableBlockNode node, { + NodeSelection? selection, + }) { + if (selection != null) { + if (selection is! UpstreamDownstreamNodeSelection) { + // We don't know how to handle this selection type. + return ''; + } + if (selection.isCollapsed) { + // This selection doesn't include the table - it's a collapsed selection + // either on the upstream or downstream edge. + return ''; + } + } + + if (node.rowCount == 0) { + // The table must have at least one row (the header row) to be serialized. + return ''; + } + + final buffer = StringBuffer(); + + final headerRow = node.getRow(0); + + // Serialize the header values. + buffer.write('|'); + for (final cell in headerRow) { + buffer.write(' '); + buffer.write(cell.text.toMarkdown()); + buffer.write(' |'); + } + + // Serialize the header separator row. + buffer.writeln(); + buffer.write('|'); + for (int i = 0; i < headerRow.length; i++) { + buffer.write(' '); + + final firstDataCell = node.rowCount > 1 // + ? node.getCell(rowIndex: 1, columnIndex: i) + : null; + + buffer.write(_getHeaderSeparatorColumnContent(firstDataCell)); + buffer.write(' |'); + } + + // Serialize the data rows. + if (node.rowCount > 1) { + for (int i = 1; i < node.rowCount; i++) { + buffer.writeln(); + final row = node.getRow(i); + + buffer.write('|'); + for (final cell in row) { + buffer.write(' '); + buffer.write(cell.text.toMarkdown()); + buffer.write(' |'); + } + } + } + + return buffer.toString(); + } + + String _getHeaderSeparatorColumnContent(TextNode? firstDataCell) { + if (firstDataCell == null) { + return '---'; + } + + final textAlign = firstDataCell.getMetadataValue('textAlign'); + return switch (textAlign) { + TextAlign.center => ':--:', + TextAlign.right => '--:', + _ => '---', + }; + } +} diff --git a/super_editor/lib/src/infrastructure/serialization/markdown/image_syntax.dart b/super_editor/lib/src/infrastructure/serialization/markdown/image_syntax.dart new file mode 100644 index 0000000000..bcb368629a --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/markdown/image_syntax.dart @@ -0,0 +1,740 @@ +import 'package:markdown/markdown.dart' as md; +import 'package:super_editor/src/default_editor/image.dart'; + +/// Matches all images. +/// +/// For example: `![My Image](https://my-image.com)` and `![My Image](https://my-image.com =500x200)` +/// +/// To define a size, use the notation `=widthxheight`. The size notation is optional and +/// it can be providade partially. For example: +/// +/// - ![alternate text](url =500x200) +/// - ![alternate text](url =500x) +/// - ![alternate text](url =x200) +/// +/// This class was modified from a copy of [md.LinkSyntax]. +class SuperEditorImageSyntax extends md.LinkSyntax { + static final _entirelyWhitespacePattern = RegExp(r'^\s*$.'); + + SuperEditorImageSyntax({md.Resolver? linkResolver}) + : super( + linkResolver: linkResolver, + pattern: r'!\[', + startCharacter: AsciiTable.exclamation, + ); + + @override + Iterable? close( + md.InlineParser parser, + covariant md.SimpleDelimiter opener, + md.Delimiter? closer, { + String? tag, + required List Function() getChildren, + }) { + var text = parser.source.substring(opener.endPos, parser.pos); + // The current character is the `]` that closed the link text. Examine the + // next character, to determine what type of link we might have (a '(' + // means a possible inline link; otherwise a possible reference link). + if (parser.pos + 1 >= parser.source.length) { + // The `]` is at the end of the document, but this may still be a valid + // shortcut reference link. + return _tryCreateReferenceLink(parser, text, getChildren: getChildren); + } + + // Peek at the next character; don't advance, so as to avoid later stepping + // backward. + var char = parser.charAt(parser.pos + 1); + + if (char == AsciiTable.leftParen) { + // Maybe an inline link, like `[text](destination)`. + parser.advanceBy(1); + var leftParenIndex = parser.pos; + var inlineLink = _parseInlineLink(parser); + if (inlineLink != null) { + return [_tryCreateInlineLink(parser, inlineLink, getChildren: getChildren)]; + } + // At this point, we've matched `[...](`, but that `(` did not pan out to + // be an inline link. We must now check if `[...]` is simply a shortcut + // reference link. + + // Reset the parser position. + parser.pos = leftParenIndex; + parser.advanceBy(-1); + return _tryCreateReferenceLink(parser, text, getChildren: getChildren); + } + + if (char == AsciiTable.leftBracket) { + parser.advanceBy(1); + // At this point, we've matched `[...][`. Maybe a *full* reference link, + // like `[foo][bar]` or a *collapsed* reference link, like `[foo][]`. + if (parser.pos + 1 < parser.source.length && parser.charAt(parser.pos + 1) == AsciiTable.rightBracket) { + // That opening `[` is not actually part of the link. Maybe a + // *shortcut* reference link (followed by a `[`). + parser.advanceBy(1); + return _tryCreateReferenceLink(parser, text, getChildren: getChildren); + } + var label = _parseReferenceLinkLabel(parser); + if (label != null) { + return _tryCreateReferenceLink(parser, label, getChildren: getChildren); + } + return null; + } + + // The link text (inside `[...]`) was not followed with a opening `(` nor + // an opening `[`. Perhaps just a simple shortcut reference link (`[...]`). + return _tryCreateReferenceLink(parser, text, getChildren: getChildren); + } + + /// Parses a size using the notation `=widthxheight`. + /// + /// Returns `null` if the size notation isn't provided. + ExpectedSize? _tryParseImageSize(md.InlineParser parser) { + if (parser.charAt(parser.pos) != AsciiTable.equal) { + // The image size should start with a "=" but the input doesn't. Fizzle. + return null; + } + + // Consume the "=". + parser.advanceBy(1); + + // Parse an optional width. + final width = _tryParseNumber(parser); + + final downstreamCharacter = parser.source.substring(parser.pos, parser.pos + 1); + if (downstreamCharacter.toLowerCase() != 'x') { + // The image size must have a "x" between the width and height, but the input doesn't. Fizzle. + return null; + } + + // Consume the "x". + parser.advanceBy(1); + + // Parse an optional height. + final height = _tryParseNumber(parser); + + return ExpectedSize(width, height); + } + + /// Tries to parse an integer number. + /// + /// Returns `null` if it can't find an integer number. + int? _tryParseNumber(md.InlineParser parser) { + StringBuffer numberCharacters = StringBuffer(); + + while (!parser.isDone && // + parser.charAt(parser.pos) >= AsciiTable.numberZero && + parser.charAt(parser.pos) <= AsciiTable.numberNine) { + // The current char is between 0-9. + numberCharacters.writeCharCode(parser.charAt(parser.pos)); + parser.advanceBy(1); + } + + if (numberCharacters.isEmpty) { + // We didn't find any digits. Fizzle. + return null; + } + + return int.parse(numberCharacters.toString()); + } + + /// Tries to create a reference link node. + /// + /// Returns the link if it was successfully created, `null` otherwise. + List? _tryCreateReferenceLink(md.InlineParser parser, String label, + {required List Function() getChildren}) { + return _resolveReferenceLink(label, parser.document.linkReferences, getChildren: getChildren); + } + + // Tries to create an inline link node. + // + /// Returns the link if it was successfully created, `null` otherwise. + md.Node _tryCreateInlineLink(md.InlineParser parser, MarkdownImage link, + {required List Function() getChildren}) { + return createNode(link.destination, link.title, size: link.size, getChildren: getChildren); + } + + /// Parse an inline [MarkdownImage] at the current position. + /// + /// At this point, we have parsed a link's (or image's) opening `[`, and then + /// a matching closing `]`, and [parser.pos] is pointing at an opening `(`. + /// This method will then attempt to parse a link destination wrapped in `<>`, + /// such as `()`, or a bare link destination, such as + /// `(http://url)`, or a link destination with a title, such as + /// `(http://url "title")`. + /// + /// Returns the [MarkdownImage] if one was parsed, or `null` if not. + MarkdownImage? _parseInlineLink(md.InlineParser parser) { + // Start walking to the character just after the opening `(`. + parser.advanceBy(1); + + _moveThroughWhitespace(parser); + if (parser.isDone) return null; // EOF. Not a link. + + if (parser.charAt(parser.pos) == AsciiTable.lessThan) { + // Maybe a `<...>`-enclosed link destination. + return _parseInlineBracketedLink(parser); + } else { + return _parseInlineBareDestinationLink(parser); + } + } + + /// Parses a link title in [parser] at it's current position. The parser's + /// current position should be a whitespace character that followed a link + /// destination. + /// + /// Returns the title if it was successfully parsed, `null` otherwise. + String? _parseTitle(md.InlineParser parser) { + _moveThroughWhitespace(parser); + if (parser.isDone) return null; + + // The whitespace should be followed by a title delimiter. + final delimiter = parser.charAt(parser.pos); + if (delimiter != AsciiTable.apostrophe && delimiter != AsciiTable.quote && delimiter != AsciiTable.leftParen) { + return null; + } + + final closeDelimiter = delimiter == AsciiTable.leftParen ? AsciiTable.rightParen : delimiter; + parser.advanceBy(1); + + // Now we look for an un-escaped closing delimiter. + final buffer = StringBuffer(); + while (true) { + final char = parser.charAt(parser.pos); + if (char == AsciiTable.backslash) { + parser.advanceBy(1); + final next = parser.charAt(parser.pos); + if (next != AsciiTable.backslash && next != closeDelimiter) { + buffer.writeCharCode(char); + } + buffer.writeCharCode(next); + } else if (char == closeDelimiter) { + break; + } else { + buffer.writeCharCode(char); + } + parser.advanceBy(1); + if (parser.isDone) return null; + } + final title = buffer.toString(); + + // Advance past the closing delimiter. + parser.advanceBy(1); + if (parser.isDone) return null; + _moveThroughWhitespace(parser); + if (parser.isDone) return null; + if (parser.charAt(parser.pos) != AsciiTable.rightParen) return null; + return title; + } + + /// Resolve a possible reference link. + /// + /// Uses [linkReferences], [linkResolver], and [createNode] to try to + /// resolve [label] into a [Node]. If [label] is defined in + /// [linkReferences] or can be resolved by [linkResolver], returns a [Node] + /// that links to the resolved URL. + /// + /// Otherwise, returns `null`. + /// + /// [label] does not need to be normalized. + List? _resolveReferenceLink( + String label, + Map linkReferences, { + required List Function() getChildren, + }) { + final linkReference = linkReferences[_normalizeLinkLabel(label)]; + if (linkReference != null) { + return [ + createNode( + linkReference.destination, + linkReference.title, + //size: linkReference.size, + getChildren: getChildren, + ) + ]; + } else { + // This link has no reference definition. But we allow users of the + // library to specify a custom resolver function ([linkResolver]) that + // may choose to handle this. Otherwise, it's just treated as plain + // text. + + // Normally, label text does not get parsed as inline Markdown. However, + // for the benefit of the link resolver, we need to at least escape + // brackets, so that, e.g. a link resolver can receive `[\[\]]` as `[]`. + final resolved = linkResolver(label.replaceAll(r'\\', r'\').replaceAll(r'\[', '[').replaceAll(r'\]', ']')); + if (resolved != null) { + getChildren(); + } + return resolved != null ? [resolved] : null; + } + } + + /// Parse a reference link label at the current position. + /// + /// Specifically, [parser.pos] is expected to be pointing at the `[` which + /// opens the link label. + /// + /// Returns the label if it could be parsed, or `null` if not. + String? _parseReferenceLinkLabel(md.InlineParser parser) { + // Walk past the opening `[`. + parser.advanceBy(1); + if (parser.isDone) return null; + + var buffer = StringBuffer(); + while (true) { + var char = parser.charAt(parser.pos); + if (char == AsciiTable.backslash) { + parser.advanceBy(1); + var next = parser.charAt(parser.pos); + if (next != AsciiTable.backslash && next != AsciiTable.rightBracket) { + buffer.writeCharCode(char); + } + buffer.writeCharCode(next); + } else if (char == AsciiTable.rightBracket) { + break; + } else { + buffer.writeCharCode(char); + } + parser.advanceBy(1); + if (parser.isDone) return null; + // TODO(srawlins): only check 999 characters, for performance reasons? + } + + var label = buffer.toString(); + + // A link label must contain at least one non-whitespace character. + if (_entirelyWhitespacePattern.hasMatch(label)) return null; + + return label; + } + + /// Parse an inline link with a bracketed destination (a destination wrapped + /// in `<...>`). The current position of the parser must be the first + /// character of the destination. + /// + /// Returns the link if it was successfully created, `null` otherwise. + MarkdownImage? _parseInlineBracketedLink(md.InlineParser parser) { + parser.advanceBy(1); + + ExpectedSize? imageSize; + + var buffer = StringBuffer(); + while (true) { + var char = parser.charAt(parser.pos); + if (char == AsciiTable.backslash) { + parser.advanceBy(1); + var next = parser.charAt(parser.pos); + // TODO: Follow the backslash spec better here. + // http://spec.commonmark.org/0.29/#backslash-escapes + if (next != AsciiTable.backslash && next != AsciiTable.greaterThan) { + buffer.writeCharCode(char); + } + buffer.writeCharCode(next); + } else if (char == AsciiTable.lineFeed || char == AsciiTable.carriageReturn || char == AsciiTable.formFeed) { + // Not a link (no line breaks allowed within `<...>`). + return null; + } else if (char == AsciiTable.space) { + buffer.write('%20'); + } else if (char == AsciiTable.greaterThan) { + break; + } else { + buffer.writeCharCode(char); + } + parser.advanceBy(1); + if (parser.isDone) return null; + } + var destination = buffer.toString(); + + parser.advanceBy(1); + var char = parser.charAt(parser.pos); + if (char == AsciiTable.space || + char == AsciiTable.lineFeed || + char == AsciiTable.carriageReturn || + char == AsciiTable.formFeed) { + if (char == AsciiTable.space) { + // We found a space, we might have a title or a size definition after it. + if (parser.isDone) { + // We are already at the end. Fizzle. + return null; + } + + if (parser.charAt(parser.pos + 1) == AsciiTable.equal) { + // We found the start of a size definition. Try to parse it. + parser.advanceBy(1); + imageSize = _tryParseImageSize(parser); + + if (imageSize != null) { + // We parsed the image size. Continue to parse the remainder of the input. + } + } + } + + var title = _parseTitle(parser); + if (title == null && parser.charAt(parser.pos) != AsciiTable.rightParen) { + // This looked like an inline link, until we found this AsciiTable.$space + // followed by mystery characters; no longer a link. + return null; + } + return MarkdownImage(destination, title: title, size: imageSize); + } else if (char == AsciiTable.rightParen) { + return MarkdownImage(destination, size: imageSize); + } else { + // We parsed something like `[foo](X`. Not a link. + return null; + } + } + + /// Parse an inline link with a "bare" destination (a destination _not_ + /// wrapped in `<...>`). The current position of the parser must be the first + /// character of the destination. + /// + /// Returns the link if it was successfully created, `null` otherwise. + MarkdownImage? _parseInlineBareDestinationLink(md.InlineParser parser) { + // According to + // [CommonMark](http://spec.commonmark.org/0.28/#link-destination): + // + // > A link destination consists of [...] a nonempty sequence of + // > characters [...], and includes parentheses only if (a) they are + // > backslash-escaped or (b) they are part of a balanced pair of + // > unescaped parentheses. + // + // We need to count the open parens. We start with 1 for the paren that + // opened the destination. + var parenCount = 1; + final buffer = StringBuffer(); + + ExpectedSize? imageSize; + + while (true) { + final char = parser.charAt(parser.pos); + switch (char) { + case AsciiTable.backslash: + parser.advanceBy(1); + if (parser.isDone) return null; // EOF. Not a link. + final next = parser.charAt(parser.pos); + // Parentheses may be escaped. + // + // http://spec.commonmark.org/0.28/#example-467 + if (next != AsciiTable.backslash && next != AsciiTable.leftParen && next != AsciiTable.rightParen) { + buffer.writeCharCode(char); + } + buffer.writeCharCode(next); + break; + + case AsciiTable.space: + case AsciiTable.lineFeed: + case AsciiTable.carriageReturn: + case AsciiTable.formFeed: + final destination = buffer.toString(); + + if (char == AsciiTable.space) { + // We found a space, we might have a title or a size definition after it. + if (parser.isDone) { + // We are already at the end. Fizzle. + return null; + } + + if (parser.charAt(parser.pos + 1) == AsciiTable.equal) { + // We found the start of a size definition. Try to parse it. + parser.advanceBy(1); + imageSize = _tryParseImageSize(parser); + + if (imageSize != null) { + // We parsed the image size. Continue to parse the remainder of the input. + continue; + } + } + } + + final title = _parseTitle(parser); + if (title == null && (parser.isDone || parser.charAt(parser.pos) != AsciiTable.rightParen)) { + // This looked like an inline link, until we found this AsciiTable.$space + // followed by mystery characters; no longer a link. + return null; + } + // [_parseTitle] made sure the title was follwed by a closing `)` + // (but it's up to the code here to examine the balance of + // parentheses). + parenCount--; + if (parenCount == 0) { + return MarkdownImage(destination, size: imageSize, title: title); + } + break; + + case AsciiTable.leftParen: + parenCount++; + buffer.writeCharCode(char); + break; + + case AsciiTable.rightParen: + parenCount--; + if (parenCount == 0) { + final destination = buffer.toString(); + return MarkdownImage(destination, size: imageSize); + } + buffer.writeCharCode(char); + break; + + default: + buffer.writeCharCode(char); + } + parser.advanceBy(1); + if (parser.isDone) return null; // EOF. Not a link. + } + } + + @override + md.Element createNode( + String destination, + String? title, { + ExpectedSize? size, + required List Function() getChildren, + }) { + final element = md.Element.empty('img'); + final children = getChildren(); + element.attributes['src'] = destination; + element.attributes['alt'] = children.map((node) => node.textContent).join(); + + if (size?.width != null) { + element.attributes['width'] = size!.width!.toString(); + } + + if (size?.height != null) { + element.attributes['height'] = size!.height!.toString(); + } + + if (title != null && title.isNotEmpty) { + title.replaceAll('&', '&'); + element.attributes['title'] = _escapeAttribute(title.replaceAll('&', '&')); + } + return element; + } + + // Walk the parser forward through any whitespace. + void _moveThroughWhitespace(md.InlineParser parser) { + while (!parser.isDone) { + final char = parser.charAt(parser.pos); + if (char != AsciiTable.space && + char != AsciiTable.tab && + char != AsciiTable.lineFeed && + char != AsciiTable.vTab && + char != AsciiTable.carriageReturn && + char != AsciiTable.formFeed) { + return; + } + parser.advanceBy(1); + } + } +} + +/// One or more whitespace, for compressing. +final _oneOrMoreWhitespacePattern = RegExp('[ \n\r\t]+'); + +/// "Normalizes" a link label, according to the [CommonMark spec]. +/// +/// Extracted from the markdown package. +String _normalizeLinkLabel(String label) => label.trim().replaceAll(_oneOrMoreWhitespacePattern, ' ').toLowerCase(); + +/// Escapes the contents of [value], so that it may be used as an HTML +/// attribute. +/// +/// Extracted from the markdown package. +String _escapeAttribute(String value) { + final result = StringBuffer(); + int ch; + for (var i = 0; i < value.codeUnits.length; i++) { + ch = value.codeUnitAt(i); + if (ch == AsciiTable.backslash) { + i++; + if (i == value.codeUnits.length) { + result.writeCharCode(ch); + break; + } + ch = value.codeUnitAt(i); + switch (ch) { + case AsciiTable.quote: + result.write('"'); + break; + case AsciiTable.exclamation: + case AsciiTable.hashSign: + case AsciiTable.dollar: + case AsciiTable.percent: + case AsciiTable.ampersand: + case AsciiTable.apostrophe: + case AsciiTable.leftParen: + case AsciiTable.rightParen: + case AsciiTable.asterisk: + case AsciiTable.plus: + case AsciiTable.comma: + case AsciiTable.dash: + case AsciiTable.dot: + case AsciiTable.slash: + case AsciiTable.colon: + case AsciiTable.semicolon: + case AsciiTable.lessThan: + case AsciiTable.equal: + case AsciiTable.greaterThan: + case AsciiTable.questionMark: + case AsciiTable.at: + case AsciiTable.leftBracket: + case AsciiTable.backslash: + case AsciiTable.rightBracket: + case AsciiTable.caret: + case AsciiTable.underscore: + case AsciiTable.backquote: + case AsciiTable.leftBrace: + case AsciiTable.pipe: + case AsciiTable.rightBrace: + case AsciiTable.tilde: + result.writeCharCode(ch); + break; + default: + result.write('%5C'); + result.writeCharCode(ch); + } + } else if (ch == AsciiTable.quote) { + result.write('%22'); + } else { + result.writeCharCode(ch); + } + } + return result.toString(); +} + +/// Codes for a set of characters in the ascii table. +class AsciiTable { + /// "Horizontal Tab" character. + static const int tab = 0x09; + + /// "Line feed" control character. + static const int lineFeed = 0x0A; + + /// "Vertical Tab" control character. + static const int vTab = 0x0B; + + /// "Form feed" control character. + static const int formFeed = 0x0C; + + /// "Carriage return" control character. + static const int carriageReturn = 0x0D; + + /// Space character. + static const int space = 0x20; + + /// Character `!`. + static const int exclamation = 0x21; + + /// Character `"`. + static const int quote = 0x22; + + /// Character `"`. + static const int doubleQuote = 0x22; // ignore: constant_identifier_names + + /// Character `#`. + static const int hashSign = 0x23; + + /// Character `$`. + static const int dollar = 0x24; + + /// Character `%`. + static const int percent = 0x25; + + /// Character `&`. + static const int ampersand = 0x26; + + /// Character `'`. + static const int apostrophe = 0x27; + + /// Character `(`. + static const int leftParen = 0x28; + + /// Character `)`. + static const int rightParen = 0x29; + + /// Character `*`. + static const int asterisk = 0x2A; + + /// Character `+`. + static const int plus = 0x2B; + + /// Character `,`. + static const int comma = 0x2C; + + /// Character `-`. + static const int dash = 0x2D; + + /// Character `.`. + static const int dot = 0x2E; + + /// Character `/`. + static const int slash = 0x2F; + + /// Character `0`. + static const int numberZero = 0x30; + + /// Character `9`. + static const int numberNine = 0x39; + + /// Character `:`. + static const int colon = 0x3A; + + /// Character `;`. + static const int semicolon = 0x3B; + + /// Character `<`. + static const int lessThan = 0x3C; + + /// Character `=`. + static const int equal = 0x3D; + + /// Character `>`. + static const int greaterThan = 0x3E; + + /// Character `?`. + static const int questionMark = 0x3F; + + /// Character `@`. + static const int at = 0x40; + + /// Character `[`. + static const int leftBracket = 0x5B; + + /// Character `\`. + static const int backslash = 0x5C; + + /// Character `]`. + static const int rightBracket = 0x5D; + + /// Character `^`. + static const int caret = 0x5E; + + /// Character `_`. + static const int underscore = 0x5F; + + /// Character `` ` ``. + static const int backquote = 0x60; + + /// Character `{`. + static const int leftBrace = 0x7B; + + /// Character `|`. + static const int pipe = 0x7C; + + /// Character `}`. + static const int rightBrace = 0x7D; + + /// Character `~`. + static const int tilde = 0x7E; +} + +/// A parsed image notation. +class MarkdownImage { + const MarkdownImage( + this.destination, { + this.title, + this.size, + }); + + final String destination; + final String? title; + final ExpectedSize? size; +} diff --git a/super_editor/lib/src/infrastructure/serialization/markdown/markdown_inline_parser.dart b/super_editor/lib/src/infrastructure/serialization/markdown/markdown_inline_parser.dart new file mode 100644 index 0000000000..b68a95990f --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/markdown/markdown_inline_parser.dart @@ -0,0 +1,188 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/infrastructure/serialization/markdown/image_syntax.dart'; +import 'package:super_editor/src/infrastructure/serialization/markdown/markdown_to_document_parsing.dart'; + +/// Parses inline markdown content. +/// +/// {@macro markdown_two_phase} +/// +/// {@macro inline_markdown_syntaxes} +/// +/// If [encodeHtml] is `true`, it escapes HTML symbols like &, <, and >. For example, +/// `&` becomes `&`, `<` becomes `<`, and `>` becomes `>`. +AttributedText parseInlineMarkdown( + String text, { + Iterable? inlineMarkdownSyntaxes, + Iterable? inlineHtmlSyntaxes, + bool encodeHtml = false, +}) { + final inlineParser = md.InlineParser( + text, + md.Document( + inlineSyntaxes: inlineMarkdownSyntaxes ?? defaultSuperEditorInlineSyntaxes, + encodeHtml: encodeHtml, + ), + ); + final inlineVisitor = _InlineMarkdownToDocument( + inlineHtmlSyntaxes: inlineHtmlSyntaxes ?? defaultInlineHtmlSyntaxes, + ); + final inlineNodes = inlineParser.parse(); + for (final inlineNode in inlineNodes) { + inlineNode.accept(inlineVisitor); + } + return inlineVisitor.attributedText; +} + +final defaultSuperEditorInlineSyntaxes = [ + SingleStrikethroughSyntax(), // this needs to be before md.StrikethroughSyntax to be recognized + md.StrikethroughSyntax(), + UnderlineSyntax(), + SuperEditorImageSyntax(), +]; + +final defaultNonSuperEditorInlineSyntaxes = [ + SingleStrikethroughSyntax(), // this needs to be before md.StrikethroughSyntax to be recognized + md.StrikethroughSyntax(), + UnderlineSyntax(), +]; + +/// Parses inline markdown content. +/// +/// Apply [_InlineMarkdownToDocument] to a text [md.Element] to +/// obtain an [AttributedText] that represents the inline +/// styles within the given text. +/// +/// [_InlineMarkdownToDocument] does not support parsing text +/// that contains image tags. If any non-image text is found, +/// the content is treated as styled text. +class _InlineMarkdownToDocument implements md.NodeVisitor { + _InlineMarkdownToDocument({ + required this.inlineHtmlSyntaxes, + }); + + final Iterable inlineHtmlSyntaxes; + + AttributedText get attributedText => _textStack.first; + + final List _textStack = [AttributedText()]; + + @override + bool visitElementBefore(md.Element element) { + _textStack.add(AttributedText()); + + return true; + } + + @override + void visitText(md.Text text) { + final attributedText = _textStack.removeLast(); + _textStack.add(attributedText.copyAndAppend(AttributedText(text.text))); + } + + @override + void visitElementAfter(md.Element element) { + // Reset to normal text style because a plain text element does + // not receive a call to visitElementBefore(). + var styledText = _textStack.removeLast(); + + for (final inlineHtmlSyntax in inlineHtmlSyntaxes) { + final finalText = inlineHtmlSyntax(element, styledText); + if (finalText != null) { + styledText = finalText; + break; + } + } + + if (_textStack.isNotEmpty) { + final surroundingText = _textStack.removeLast(); + _textStack.add(surroundingText.copyAndAppend(styledText)); + } else { + _textStack.add(styledText); + } + } +} + +const defaultInlineHtmlSyntaxes = [ + boldHtmlSyntax, + italicHtmlSyntax, + underlineHtmlSyntax, + strikethroughHtmlSyntax, + anchorHtmlSyntax, + codeInlineHtmlSyntax, +]; + +typedef InlineHtmlSyntax = AttributedText? Function(md.Element element, AttributedText text); + +AttributedText? boldHtmlSyntax(md.Element element, AttributedText text) { + if (element.tag != 'strong') { + return null; + } + + return text + ..addAttribution( + boldAttribution, + SpanRange(0, text.length - 1), + ); +} + +AttributedText? italicHtmlSyntax(md.Element element, AttributedText text) { + if (element.tag != 'em') { + return null; + } + + return text + ..addAttribution( + italicsAttribution, + SpanRange(0, text.length - 1), + ); +} + +AttributedText? underlineHtmlSyntax(md.Element element, AttributedText text) { + if (element.tag != 'u') { + return null; + } + + return text + ..addAttribution( + underlineAttribution, + SpanRange(0, text.length - 1), + ); +} + +AttributedText? strikethroughHtmlSyntax(md.Element element, AttributedText text) { + if (element.tag != 'del') { + return null; + } + + return text + ..addAttribution( + strikethroughAttribution, + SpanRange(0, text.length - 1), + ); +} + +AttributedText? anchorHtmlSyntax(md.Element element, AttributedText text) { + if (element.tag != 'a') { + return null; + } + + return text + ..addAttribution( + LinkAttribution.fromUri(Uri.parse(element.attributes['href']!)), + SpanRange(0, text.length - 1), + ); +} + +AttributedText? codeInlineHtmlSyntax(md.Element element, AttributedText text) { + if (element.tag != 'code') { + return null; + } + + return text + ..addAttribution( + codeAttribution, + SpanRange(0, text.length - 1), + ); +} diff --git a/super_editor/lib/src/infrastructure/serialization/markdown/markdown_inline_upstream_plugin.dart b/super_editor/lib/src/infrastructure/serialization/markdown/markdown_inline_upstream_plugin.dart new file mode 100644 index 0000000000..ba9f6b5881 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/markdown/markdown_inline_upstream_plugin.dart @@ -0,0 +1,660 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/text.dart'; + +/// A [SuperEditorPlugin] that finds inline Markdown syntax immediately upstream from the +/// caret and converts it into attributions. +/// +/// See [MarkdownInlineUpstreamSyntaxReaction] to learn more about how Markdown is located +/// and applied by this plugin. +/// +/// To add this plugin to a [SuperEditor] widget, provide a [MarkdownInlineUpstreamSyntaxPlugin] in +/// the `plugins` property. +/// +/// SuperEditor( +/// //... +/// plugins: { +/// markdownInlineUpstreamSyntaxPlugin, +/// }, +/// ); +/// +/// To add this plugin directly to an [Editor], without involving a [SuperEditor] +/// widget, call [attach] with the given [Editor]. When that [Editor] is no longer needed, +/// call [detach] to clean up all plugin references. +/// +/// markdownInlineUpstreamSyntaxPlugin.attach(editor); +/// +/// +class MarkdownInlineUpstreamSyntaxPlugin extends SuperEditorPlugin { + MarkdownInlineUpstreamSyntaxPlugin({ + List parsers = defaultUpstreamInlineMarkdownParsers, + }) { + _markdownInlineUpstreamSyntaxReaction = MarkdownInlineUpstreamSyntaxReaction(parsers); + } + + /// An [EditReaction] that finds and converts Markdown styling into attributed + /// styles. + late final EditReaction _markdownInlineUpstreamSyntaxReaction; + + @override + void attach(Editor editor) { + editor.reactionPipeline.insert(0, _markdownInlineUpstreamSyntaxReaction); + } + + @override + void detach(Editor editor) { + editor.reactionPipeline.remove(_markdownInlineUpstreamSyntaxReaction); + } +} + +const defaultUpstreamInlineMarkdownParsers = [ + StyleUpstreamMarkdownSyntaxParser(), +]; + +/// An [EditReaction] that finds inline Markdown syntax immediately upstream from the +/// caret and converts it into attributions. +/// +/// Inline Markdown syntax includes things like `**token**` for bold, `*token*` for +/// italics, `~token~` for strikethrough, and `[name](url)` for links. +/// +/// When this reaction finds inline Markdown syntax, that syntax is removed when the corresponding +/// attribution is applied. For example, "**bold**" becomes "bold" with a bold attribution +/// applied to it. +/// +/// This reaction only identifies spans of Markdown styles within individual [TextNode]s, which +/// immediately precedes the caret. For example, "Hello **bold**|" will apply the bold style, +/// but "Hello **bold** wo|" won't apply bold. +/// +/// Parsing of links is handled differently than all other upstream syntax. Links use a fairly +/// complicated syntax, so they're identified with a regular expression. All other upstream +/// inline syntaxes are parsed character by character, moving upstream from the caret position. +class MarkdownInlineUpstreamSyntaxReaction extends EditReaction { + const MarkdownInlineUpstreamSyntaxReaction(this._parsers); + + final List _parsers; + + @override + void react(EditContext editContext, RequestDispatcher requestDispatcher, List changeList) { + if (changeList.whereType().isEmpty) { + // No edits means no Markdown insertions. Nothing for this plugin to do. + return; + } + if (changeList.where((edit) => edit is DocumentEdit && edit.change is TextInsertionEvent).isEmpty) { + // No text insertions. Nothing for this reaction to do. + return; + } + + final document = editContext.find(Editor.documentKey); + final composer = editContext.find(Editor.composerKey); + final selection = composer.selection; + if (selection == null) { + // No selection, so no caret for us to search upstream. + return; + } + if (!selection.isCollapsed) { + // It's not clear how the user would insert a Markdown character when the + // selection is expanded. Fizzle. + return; + } + final extent = selection.extent; + + final editedTextNodeIds = _findEditedTextNodes(document, changeList); + if (!editedTextNodeIds.contains(extent.nodeId)) { + // None of the changes happened in the node where the caret sits. Therefore, + // there's no way the user added Markdown styling near the caret. + return; + } + + final editRequests = _applyInlineMarkdownBeforeCaret(document, extent); + if (editRequests.isEmpty) { + // No inline Markdown was applied. Fizzle. + return; + } + + requestDispatcher.execute(editRequests); + } + + /// Finds and returns the node IDs for every [TextNode] that was altered during this + /// transaction. + Set _findEditedTextNodes(Document document, List changeList) { + final editedTextNodes = {}; + for (final change in changeList) { + if (change is! DocumentEdit || change.change is! NodeDocumentChange) { + continue; + } + + final nodeId = (change.change as NodeDocumentChange).nodeId; + if (editedTextNodes.contains(nodeId)) { + continue; + } + + if (document.getNodeById(nodeId) is! TextNode) { + continue; + } + + editedTextNodes.add(nodeId); + } + + return editedTextNodes; + } + + List _applyInlineMarkdownBeforeCaret( + Document document, + DocumentPosition caretPosition, + ) { + final editedNode = document.getNodeById(caretPosition.nodeId) as TextNode; + final caretOffset = (caretPosition.nodePosition as TextNodePosition).offset; + final inlineParser = _UpstreamInlineMarkdownParser( + _parsers, + editedNode.text, + caretOffset: caretOffset, + ); + + final markdownRun = inlineParser.findMarkdown(); + if (markdownRun == null) { + return const []; + } + + final newCaretPosition = DocumentPosition( + nodeId: editedNode.id, + nodePosition: TextNodePosition(offset: markdownRun.start + markdownRun.replacementText.length), + ); + return [ + // Delete the whole run of Markdown text, e.g., "**my bold**". + DeleteContentRequest( + documentRange: DocumentRange( + start: DocumentPosition( + nodeId: editedNode.id, + nodePosition: TextNodePosition(offset: markdownRun.start), + ), + end: DocumentPosition( + nodeId: editedNode.id, + nodePosition: TextNodePosition(offset: markdownRun.end), + ), + ), + ), + // Insert the non-Markdown content with styles, e.g., "bold" with a bold attribution. + InsertAttributedTextRequest( + DocumentPosition( + nodeId: editedNode.id, + nodePosition: TextNodePosition(offset: markdownRun.start), + ), + markdownRun.replacementText, + ), + // Adjust the caret position to reflect any Markdown syntax characters that + // were removed. + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: newCaretPosition, + ), + SelectionChangeType.alteredContent, + SelectionReason.contentChange, + ), + ChangeComposingRegionRequest( + DocumentRange( + start: newCaretPosition, + end: newCaretPosition, + ), + ), + ]; + } +} + +/// A specialized Markdown parser that starts a given caret offset and then works its +/// way upstream to find inline Markdown tokens. +/// +/// The parser finds and returns a single [_InlineMarkdownRun], if one exists. +/// +/// The parser moves character by character upstream from the caret. Each time the +/// parser encounters a character that might be part of the end of an inline syntax, +/// that possible token is added to a set of candidates. When the upstream parser +/// locates a corresponding upstream inline syntax token, the completed syntax is +/// added to a set of completed syntaxes. +/// +/// The reason that multiple completed syntaxes are tracked is because the Markdown +/// syntax allows for ambiguities. +/// +/// For example, the parser finds +/// +/// "*word**|" +/// +/// Notice that "*word*" is a completed token, but it' very likely that if +/// the parser moves one more character upstream, it will find... +/// +/// "**word**|" +/// +/// In this case, the parser wants to apply bold, not italics. Therefore, +/// the heuristic that makes sense is to keep parsing upstream until there +/// aren't any possible matches left, and then apply whichever syntax was +/// completed last. +class _UpstreamInlineMarkdownParser { + _UpstreamInlineMarkdownParser( + this.parsers, + this.attributedText, { + required this.caretOffset, + }); + + final List parsers; + final AttributedText attributedText; + final int caretOffset; + + final _possibleSyntaxes = []; + + _InlineMarkdownRun? findMarkdown() { + if (caretOffset == 0) { + // Can't possibly have an upstream Markdown syntax when the caret is + // at the beginning of the text. + return null; + } + + // Run the special case of parsing a link. This is a special case because the syntax + // is complicated enough that we don't want to try to accumulate characters as the + // user types. + final linkRun = _tryCreateLinkRun(); + if (linkRun != null) { + return linkRun; + } + + int offset = caretOffset - 1; + + // Start visiting upstream characters by visiting the first character + // and checking for possible syntaxes. + for (final parser in parsers) { + final markdownToken = parser.startWith(attributedText[offset] as String, offset); + if (markdownToken != null) { + _possibleSyntaxes.add(markdownToken); + } + } + + final successfulParsers = []; + while (offset > 0 && _possibleSyntaxes.isNotEmpty) { + offset -= 1; + + // Update all existing possible syntaxes and remove any possible syntaxes + // that are now invalid due to the new character. + _updatePossibleSyntaxes(attributedText[offset] as String, offset); + + // Store any successful parsers on a stack. We keep searching after successful + // parsing because some parsers are essentially supersets of others, e.g., "*" + // will succeed when we really want to keep parsing and find "**". + successfulParsers.addAll(_possibleSyntaxes.where((parser) => parser.isComplete && parser.isValid)); + _possibleSyntaxes.removeWhere((parser) => parser.isComplete); + + if (offset > 0) { + // There's still at least one character upstream from this one. Make sure + // that all of our successful parsers are allowed to appear after that + // upstream character. + // + // An example where this check is needed is the following: + // + // We found "*word*" + // + // Actual text is "**word*" + // + // Finding a completed syntax isn't enough. We need to ensure that the + // immediate upstream character before the syntax doesn't invalidate it. + final upstreamCharacter = attributedText[offset - 1] as String; + successfulParsers.removeWhere((parser) => !parser.canFollowCharacter(upstreamCharacter)); + } + } + + if (successfulParsers.isEmpty) { + return null; + } + + // Select the completed syntax that we found last. + final successfulParser = successfulParsers.last; + + return _InlineMarkdownRun( + successfulParser.calculateFinalText(attributedText), + offset, + // Note: end offset is exclusive. + caretOffset, + ); + } + + _InlineMarkdownRun? _tryCreateLinkRun() { + if (caretOffset < 2) { + // There's not enough text before the caret to possibly hold a link. Fizzle. + return null; + } + + final characterAtCaret = attributedText[caretOffset - 1] as String; // -1 because caret sits after character + if (characterAtCaret != " ") { + // Don't linkify unless the user just inserted a space after the token. + return null; + } + + final endOfTokenOffset = caretOffset - 2; + if (attributedText[endOfTokenOffset] != ")") { + // All links end with a ")", therefore we know the upstream token + // isn't a link. Short-circuit return. + return null; + } + + final markdownLinkRegex = RegExp(r'\[([\w\s\d]+)]\(((?:|https?://)[\w\d./?=#]+)\)'); + final matches = markdownLinkRegex.allMatches(attributedText.toPlainText()); + if (matches.isEmpty) { + // Didn't find any links. + return null; + } + + // Found some links. See if any of them are immediately upstream. + for (final match in matches) { + if (match.end - 1 == endOfTokenOffset) { + // We found a Markdown link immediately upstream. Return it. + final linkName = match.group(1)!; + final linkUrl = match.group(2)!; + final linkAttribution = LinkAttribution(linkUrl); + + return _InlineMarkdownRun( + AttributedText( + "$linkName ", // Explicitly add the trailing space so the caret stays after the space. + AttributedSpans(attributions: [ + SpanMarker( + attribution: linkAttribution, + offset: 0, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: linkAttribution, + offset: linkName.length - 1, + markerType: SpanMarkerType.end, + ), + ]), + ), + match.start, + match.end + 1, // +1 to include the space after the link so the caret stays in same place. + ); + } + } + + // We found links, but none of them are immediately upstream. + return null; + } + + void _updatePossibleSyntaxes(String character, int characterIndex) { + // Update all existing possible syntaxes. + for (int i = _possibleSyntaxes.length - 1; i >= 0; i -= 1) { + // Add the latest character to the existing syntax parser. + _possibleSyntaxes[i].prependCharacter(character); + + if (!_possibleSyntaxes[i].isValid) { + // This syntax is no longer valid. Remove it. + _possibleSyntaxes.removeAt(i); + } + } + } +} + +/// A parser for a specific set of inline Markdown syntaxes, based on +/// the offset of a caret. +/// +/// The syntax that's parsed is determined by the implementer. +abstract interface class UpstreamMarkdownInlineSyntax { + /// Checks the given [character], and if that [character] might represent + /// the trailing end of an inline token, that token is returned, otherwise + /// `null` is returned. + /// + /// The given [atTextIndex] is the index of the [character] within the + /// larger text blob. + /// + /// For example, given a starting [character] of "*", a token might be + /// returned which is capable of identifying italics "*" and bold "**". + /// But if the [character] is "#", then `null` is returned because no + /// Markdown syntax ends with a "#". + UpstreamMarkdownToken? startWith(String character, int atTextIndex); +} + +/// A Markdown token that's assembled by a specific [UpstreamMarkdownInlineSyntax]. +/// +/// An [UpstreamMarkdownToken] grows one character at a time until it either completes +/// a valid Markdown token, or reaches a point where it's an invalid Markdown token. +abstract interface class UpstreamMarkdownToken { + /// Whether this parser still contains a valid syntax. + /// + /// Upstream parsers are told to consume one character after another with + /// [prependCharacter]. A syntax that begins valid, such as "*" might then become + /// invalid when adding another character, such as "~*". When adding a character + /// invalidates the syntax, this property switches from `true` to `false`. + bool get isValid; + + /// Whether the current text within this parser represents a complete Markdown + /// syntax. + /// + /// The parser is considered complete when it finds both opening and closing + /// syntaxes of the same form, e.g., "*italics*" or "**bold**". + bool get isComplete; + + /// Prepends the given upstream [character] to this syntax and then re-evaluates + /// the validity of this syntax. + /// + /// The following are some examples of a syntax that prepends a character and remains + /// valid: + /// + /// - "*" -> "**" + /// - "**" -> "***" + /// - "_" -> "_" + /// - "__" -> "___" + /// + /// The following are some example of a syntax that prepends a character and becomes + /// invalid: + /// + /// - "***" -> "****" + /// - "___" -> "____" + /// - "~" -> "*~" + /// + /// After prepending a character, clients should check [isValid] to ensure that this + /// syntax is still a valid Markdown syntax. + void prependCharacter(String character); + + /// Returns `true` if this completed syntax is allowed to immediately follow the given + /// [character], or `false` if following the [character] would invalidate this syntax. + /// + /// For example, it's legal to apply italics in strings like " *italics*" and "h*italics*" + /// but it's not appropriate to apply italics when there are more "*" such as "**italics*". + bool canFollowCharacter(String character); + + /// Calculates the [AttributedText] that should replace the [existingText] based on the + /// parsed Markdown. + /// + /// This should only be called when [isComplete] is `true`. + /// + /// The final text is calculated based on a given [existingText], rather than returned + /// in isolation, because the final attributions might be based on existing attributions. + /// For example, applying bold shouldn't remove existing italics, and vis-a-versa. + /// But this decision about which attributions to retain needs to be a per-parser + /// responsibility. For example, it might not make sense to retain bold or italics if + /// the user applies an inline code style. + AttributedText calculateFinalText(AttributedText existingText); +} + +/// An [UpstreamMarkdownInlineSyntax] that parses standard Markdown styles, e.g., +/// bold, italics, code, strikethrough. +class StyleUpstreamMarkdownSyntaxParser implements UpstreamMarkdownInlineSyntax { + const StyleUpstreamMarkdownSyntaxParser(); + + @override + UpstreamMarkdownToken? startWith(String character, int atTextIndex) { + if (!StyleUpstreamMarkdownToken.possibleStartCharacters.contains(character)) { + return null; + } + + switch (character) { + case "*": + case "_": + return StyleUpstreamMarkdownToken(character, 3, atTextIndex); + case "~": + case "`": + return StyleUpstreamMarkdownToken(character, 1, atTextIndex); + default: + throw Exception("Unrecognized Markdown style trigger: '$character'"); + } + } +} + +/// An [UpstreamMarkdownToken] that applies standard inline Markdown styles, +/// e.g., bold, italics, strikethrough, and code. +class StyleUpstreamMarkdownToken implements UpstreamMarkdownToken { + static const possibleStartCharacters = {"*", "_", "~", "`"}; + + static const _lookingForCloseSyntax = 1; + static const _lookingForOpenSyntax = 2; + static const _done = 3; + + StyleUpstreamMarkdownToken(this._triggerCharacter, this._maxSyntaxLength, this._triggerIndex) + : assert(_triggerCharacter.length == 1), + assert(possibleStartCharacters.contains(_triggerCharacter)), + _closingSyntax = _triggerCharacter { + if (_maxSyntaxLength == 1) { + // Only one closing character is allowed, so we start off already looking + // for the opening syntax upstream. + _phase = _lookingForOpenSyntax; + } else { + // The closing syntax might be 1+ character, so we start off by looking for + // more closing syntax characters. + _phase = _lookingForCloseSyntax; + } + + _allParsedText = _triggerCharacter; + _currentIndex = _triggerIndex; + } + + final String _triggerCharacter; + final int _triggerIndex; + final int _maxSyntaxLength; + String _allParsedText = ""; + late int _currentIndex; + + String _closingSyntax; + String _openingSyntax = ""; + late int _phase = _lookingForCloseSyntax; + + @override + bool get isValid => _isValid; + bool _isValid = true; + + @override + bool get isComplete => _isComplete; + bool _isComplete = false; + + @override + void prependCharacter(String character) { + _allParsedText = "$character$_allParsedText"; + _currentIndex -= 1; + + switch (_phase) { + case _lookingForCloseSyntax: + if (character == _triggerCharacter) { + // We found another character that belongs to our style syntax, e.g., + // from "*" to "**", from "_" to "__". + _closingSyntax = "$character$_closingSyntax"; + } else { + // We've moved from the closing syntax into the styled content. + _phase = _lookingForOpenSyntax; + } + case _lookingForOpenSyntax: + if (character == _triggerCharacter) { + // Prepend the current character to what might end up being the starting + // syntax. + _openingSyntax = "$character$_openingSyntax"; + } else { + _openingSyntax = ""; + } + + if (_openingSyntax == _closingSyntax) { + if (_allParsedText.length == _openingSyntax.length * 2) { + // We just found an opening syntax that matches our closing syntax, + // but there is no text between the opening and closing syntax. + // Therefore, this is an invalid Markdown style. + _isValid = false; + } else { + // We just found an opening syntax that matches our closing syntax. + // Therefore, we have found a complete Markdown run. + _isComplete = true; + _phase = _done; + } + } + case _done: + // More characters were added after already finding a complete Markdown + // style. This changes the syntax from valid to invalid because its now + // more than just a style. + _isValid = false; + } + } + + @override + bool canFollowCharacter(String character) { + return character == " "; + } + + @override + AttributedText calculateFinalText(AttributedText existingText) { + if (!_isComplete) { + throw Exception( + "Can't calculate inline Markdown text for a parser whose content is incomplete: '${_allParsedText.toString()}'.", + ); + } + if (!_isValid) { + throw Exception( + "Can't calculate inline Markdown text for a parser whose content is invalid: '${_allParsedText.toString()}'."); + } + + final newStyles = {}; + switch (_openingSyntax) { + case "***": + case "___": + newStyles.addAll([italicsAttribution, boldAttribution]); + case "**": + case "__": + newStyles.add(boldAttribution); + case "*": + case "_": + newStyles.add(italicsAttribution); + case "~": + newStyles.add(strikethroughAttribution); + case "`": + newStyles.add(codeAttribution); + } + + // Imagine that we've identified something like "**token**". In that case, we'd + // want to remove the opening and closing "**" and then apply bold to the rest of + // the text. We want to leave any other existing attributions alone. + final syntaxLength = _closingSyntax.length; + final appliedText = existingText.copyText( + _currentIndex + syntaxLength, + _triggerIndex - syntaxLength + 1, + ); + for (final attribution in newStyles) { + appliedText.addAttribution(attribution, SpanRange(0, appliedText.length - 1)); + } + + return appliedText; + } +} + +/// The span of text where a Markdown snippet resides, e.g., "**bold**", +/// and the [AttributedText] that should replace it, e.g., "bold" with +/// a bold attribution. +class _InlineMarkdownRun { + const _InlineMarkdownRun(this.replacementText, this.start, this.end); + + /// A snippet of text with some kind of Markdown syntax applied to it. + /// + /// The Markdown syntax is included in this value, e.g., "**word**. + final AttributedText replacementText; + + /// The index of the first character of a Markdown snippet within a larger + /// piece of text. + final int start; + + /// The index immediately after the last character of a Markdown snippet + /// within a larger piece of text. + final int end; +} diff --git a/super_editor/lib/src/infrastructure/serialization/markdown/markdown_to_attributed_text_parsing.dart b/super_editor/lib/src/infrastructure/serialization/markdown/markdown_to_attributed_text_parsing.dart new file mode 100644 index 0000000000..4be6d8006d --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/markdown/markdown_to_attributed_text_parsing.dart @@ -0,0 +1,21 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/infrastructure/serialization/markdown/markdown_to_document_parsing.dart'; + +/// Parses [markdown] and returns it as [AttributedText]. +/// +/// The [markdown] is expected to represent a single paragraph of content. If +/// the [markdown] isn't text-based (e.g., an image), or if the [markdown] includes +/// more than one paragraph of content, an exception is thrown. +AttributedText attributedTextFromMarkdown(String markdown) { + if (markdown.isEmpty) { + return AttributedText(); + } + + final document = deserializeMarkdownToDocument(markdown); + assert(document.nodeCount == 1, + "Tried to parse Markdown to AttributedText. Expected one paragraph node but ended up with ${document.nodeCount} parsed nodes."); + assert(document.first is ParagraphNode, + "Tried to parse Markdown to AttributedText. Expected text but found content type: ${document.first.runtimeType}"); + return (document.first as ParagraphNode).text; +} diff --git a/super_editor/lib/src/infrastructure/serialization/markdown/markdown_to_document_parsing.dart b/super_editor/lib/src/infrastructure/serialization/markdown/markdown_to_document_parsing.dart new file mode 100644 index 0000000000..b11b7b92dd --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/markdown/markdown_to_document_parsing.dart @@ -0,0 +1,974 @@ +import 'dart:convert'; + +import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/horizontal_rule.dart'; +import 'package:super_editor/src/default_editor/image.dart'; +import 'package:super_editor/src/default_editor/list_items.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/infrastructure/serialization/markdown/image_syntax.dart'; +import 'package:super_editor/src/infrastructure/serialization/markdown/markdown_inline_parser.dart'; +import 'package:super_editor/src/infrastructure/serialization/markdown/super_editor_syntax.dart'; +import 'package:super_editor/src/infrastructure/serialization/markdown/table.dart'; + +/// Parses the given [markdown] and deserializes it into a [MutableDocument]. +/// +/// ## Parsing +/// {@template markdown_two_phase} +/// Markdown parsing is a two-phase process: +/// 1. Parse Markdown syntax to HTML +/// 2. Convert HTML to [AttributedText] +/// {@endtemplate} +/// +/// This two-phase process is true for both block-level parsing, e.g., blockquotes, +/// code blocks, and also for inline parsing, e.g., bold, italics, links. +/// +/// ### Custom Block Parsing +/// To add support for parsing non-standard Markdown blocks, provide [customBlockSyntax]s +/// that parse Markdown text into [md.Element]s, and provide [customElementToNodeConverters] that +/// turn those [md.Element]s into [DocumentNode]s. +/// +/// ### Custom Inline Parsing +/// {@template inline_markdown_syntaxes} +/// By default, when no syntaxes are provided, this method parses Markdown to +/// HTML with [defaultSuperEditorInlineSyntaxes]. Then, this method configures +/// the [AttributedText] based on the HTML, using [defaultInlineHtmlSyntaxes]. +/// +/// To customize the supported Markdown syntaxes, provide a custom chain of +/// responsibility for [inlineMarkdownSyntaxes]. +/// +/// To customize the supported HTML, which configures the final [AttributedText], +/// provide a custom chain of responsibility for [inlineHtmlSyntaxes]. +/// {@endtemplate} +/// +/// The given [syntax] further adjusts how the Markdown is interpreted, e.g., [MarkdownSyntax.normal] +/// for strict Markdown parsing, or [MarkdownSyntax.superEditor] to use Super Editor's +/// extended syntax. +MutableDocument deserializeMarkdownToDocument( + String markdown, { + MarkdownSyntax syntax = MarkdownSyntax.superEditor, + List customBlockSyntax = const [], + List customElementToNodeConverters = const [], + Iterable? inlineMarkdownSyntaxes, + Iterable? inlineHtmlSyntaxes, + bool encodeHtml = false, +}) { + final markdownLines = const LineSplitter().convert(markdown).map( + (String l) { + return md.Line(l); + }, + ).toList(); + + // Parse markdown string to structured markdown. + final markdownDoc = md.Document( + encodeHtml: encodeHtml, + blockSyntaxes: [ + ...customBlockSyntax, + if (syntax == MarkdownSyntax.superEditor) ...[ + _HeaderWithAlignmentSyntax(), + const _ParagraphWithAlignmentSyntax(), + ], + const _EmptyLinePreservingParagraphSyntax(), + const md.UnorderedListWithCheckboxSyntax(), + const md.TableSyntax(), + ], + ); + final blockParser = md.BlockParser(markdownLines, markdownDoc); + final markdownNodes = blockParser.parseLines(); + + // Convert structured markdown to a Document. + final nodeVisitor = _MarkdownToDocument( + elementToNodeConverters: customElementToNodeConverters, + inlineMarkdownSyntaxes: inlineMarkdownSyntaxes, + inlineHtmlSyntaxes: inlineHtmlSyntaxes, + encodeHtml: encodeHtml, + syntax: syntax, + ); + for (final node in markdownNodes) { + node.accept(nodeVisitor); + } + + final documentNodes = nodeVisitor.content; + + if (documentNodes.isEmpty) { + // An empty markdown was parsed. + // For the user to be able to interact with the editor, at least one + // node is required, so we add an empty paragraph. + documentNodes.add( + ParagraphNode(id: Editor.createNodeId(), text: AttributedText()), + ); + } + + // Add 1 hanging line for every 2 blank lines at the end, need this to preserve behavior pre markdown 7.2.1 + final hangingEmptyLines = markdownLines.reversed.takeWhile((line) => _blankLinePattern.hasMatch(line.content)); + if (hangingEmptyLines.isNotEmpty && documentNodes.lastOrNull is ListItemNode) { + for (var i = 0; i < hangingEmptyLines.length ~/ 2; i++) { + documentNodes.add(ParagraphNode(id: Editor.createNodeId(), text: AttributedText())); + } + } + + return MutableDocument(nodes: documentNodes); +} + +/// Converts structured markdown to a list of [DocumentNode]s. +/// +/// To use [_MarkdownToDocument], obtain a series of markdown +/// nodes from a [BlockParser] (from the markdown package) and +/// then visit each of the nodes with a [_MarkdownToDocument]. +/// After visiting all markdown nodes, [_MarkdownToDocument] +/// contains [DocumentNode]s that correspond to the visited +/// markdown content. +class _MarkdownToDocument implements md.NodeVisitor { + _MarkdownToDocument({ + this.elementToNodeConverters = const [], + this.inlineMarkdownSyntaxes, + this.inlineHtmlSyntaxes, + this.encodeHtml = false, + this.syntax = MarkdownSyntax.normal, + }); + + final MarkdownSyntax syntax; + + final List elementToNodeConverters; + + final Iterable? inlineMarkdownSyntaxes; + final Iterable? inlineHtmlSyntaxes; + + final _content = []; + List get content => _content; + + final _listItemTypeStack = []; + + /// The count of the list items currently being visited. + /// + /// Being visited means that [visitElementBefore] was called for an element and + /// [visitElementAfter] wasn't called yet. + /// + /// A list item might contain children with tags like `p` and `h2`. When it does, + /// the list item text content is inside of its children and we only generate + /// document nodes when we visit the list item's children. + /// + /// We track the item count because when there are sublists, [visitElementBefore] is + /// called for the sublist item before [visitElementAfter] is called for the + /// main list item. + int _listItemVisitedCount = 0; + + /// If `true`, special HTML symbols are encoded with HTML escape codes, otherwise those + /// symbols are left as-is. + /// + /// Example: "&" -> "&", "<" -> "<", ">" -> ">" + final bool encodeHtml; + + @override + bool visitElementBefore(md.Element element) { + for (final converter in elementToNodeConverters) { + final node = converter.handleElement(element); + if (node != null) { + _content.add(node); + return true; + } + } + + if (_listItemVisitedCount > 0 && + !const ['li', 'ul', 'ol'].contains(element.tag) && + (element.children == null || element.children!.isEmpty || element.children!.length == 1)) { + // We are visiting the text content of a list item. Add a list item node to the document. + _addListItem( + element, + listItemType: _listItemTypeStack.last, + indent: _listItemTypeStack.length - 1, + ); + return false; + } + + // TODO: re-organize parsing such that visitElementBefore collects + // the block type info and then visitText and visitElementAfter + // take the action to create the node (#153) + switch (element.tag) { + case 'h1': + _addHeader(element, level: 1); + break; + case 'h2': + _addHeader(element, level: 2); + break; + case 'h3': + _addHeader(element, level: 3); + break; + case 'h4': + _addHeader(element, level: 4); + break; + case 'h5': + _addHeader(element, level: 5); + break; + case 'h6': + _addHeader(element, level: 6); + break; + case 'p': + final blockImage = _maybeParseBlockImage(element.textContent); + if (blockImage != null) { + _addImage(blockImage); + } else { + final attributedText = parseInlineMarkdown( + element.textContent, + inlineMarkdownSyntaxes: inlineMarkdownSyntaxes, + inlineHtmlSyntaxes: inlineHtmlSyntaxes, + encodeHtml: encodeHtml, + ); + _addParagraph(attributedText, element.attributes); + } + + break; + case 'blockquote': + _addBlockquote(element); + + // Skip child elements within a blockquote so that we don't + // add another node for the paragraph that comprises the blockquote + return false; + case 'code': + _addCodeBlock(element); + break; + case 'ul': + // A list just started. Push that list type on top of the list type stack. + _listItemTypeStack.add(ListItemType.unordered); + break; + case 'ol': + // A list just started. Push that list type on top of the list type stack. + _listItemTypeStack.add(ListItemType.ordered); + break; + case 'li': + if (_listItemTypeStack.isEmpty) { + throw Exception('Tried to parse a markdown list item but the list item type was null'); + } + + if (element.attributes['class'] == 'task-list-item') { + // We handle task deserialization using the built-in `UnorderedListWithCheckboxSyntax`. It's parsed + // as a list item with a checkbox input element. + _addTask(element); + + // Skip any child elements because we already added the task node. + return false; + } + + // Mark that we are visiting a list item. + _listItemVisitedCount += 1; + + if (element.children != null && + element.children!.isNotEmpty && + element.children!.first is! md.UnparsedContent) { + // The list item content is inside of its child's element. Wait until we visit + // the list item's children to generate a list node. + return true; + } + + // We already have the content of the list item, generate a list node. + _addListItem( + element, + listItemType: _listItemTypeStack.last, + indent: _listItemTypeStack.length - 1, + ); + break; + + case 'hr': + _addHorizontalRule(); + break; + case 'table': + _addTable(element); + + // Skip any children because we already processed the whole table. + return false; + } + + return true; + } + + @override + void visitElementAfter(md.Element element) { + switch (element.tag) { + case 'li': + _listItemVisitedCount -= 1; + break; + // A list has ended. Pop the most recent list type from the stack. + case 'ul': + case 'ol': + _listItemTypeStack.removeLast(); + break; + } + } + + @override + void visitText(md.Text text) { + // no-op: this visitor is block-level only + } + + void _addHeader(md.Element element, {required int level}) { + Attribution? headerAttribution; + switch (level) { + case 1: + headerAttribution = header1Attribution; + break; + case 2: + headerAttribution = header2Attribution; + break; + case 3: + headerAttribution = header3Attribution; + break; + case 4: + headerAttribution = header4Attribution; + break; + case 5: + headerAttribution = header5Attribution; + break; + case 6: + headerAttribution = header6Attribution; + break; + } + + final textAlign = element.attributes['textAlign']; + _content.add( + ParagraphNode( + id: Editor.createNodeId(), + text: _parseInlineText(element.textContent), + metadata: { + 'blockType': headerAttribution, + 'textAlign': textAlign, + }, + ), + ); + } + + void _addParagraph(AttributedText attributedText, Map attributes) { + final textAlign = attributes['textAlign']; + + _content.add( + ParagraphNode( + id: Editor.createNodeId(), + text: attributedText, + metadata: { + 'textAlign': textAlign, + }, + ), + ); + } + + void _addBlockquote(md.Element element) { + _content.add( + ParagraphNode( + id: Editor.createNodeId(), + text: _parseInlineText(element.textContent), + metadata: const { + 'blockType': blockquoteAttribution, + }, + ), + ); + } + + void _addCodeBlock(md.Element element) { + // TODO: we may need to replace escape characters with literals here + // CodeSampleNode( + // code: element.textContent // + // .replaceAll('<', '<') // + // .replaceAll('>', '>') // + // .trim(), + // ), + + _content.add( + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + element.textContent, + ), + metadata: const { + 'blockType': codeAttribution, + }, + ), + ); + } + + void _addImage(_MarkdownImage image) { + _content.add( + ImageNode( + id: Editor.createNodeId(), + imageUrl: image.url, + altText: image.altText ?? '', + expectedBitmapSize: image.width != null || image.height != null + ? ExpectedSize( + image.width != null ? int.tryParse(image.width!) : null, + image.height != null ? int.tryParse(image.height!) : null, + ) + : null, + ), + ); + } + + void _addHorizontalRule() { + _content.add(HorizontalRuleNode( + id: Editor.createNodeId(), + )); + } + + void _addListItem( + md.Element element, { + required ListItemType listItemType, + required int indent, + }) { + late String content; + + if (element.children != null && element.children!.isNotEmpty && element.children!.first is md.UnparsedContent) { + // The list item might contain another sub-list. In that case, the textContent + // contains the text for the whole list instead of just the current list item. + // Use the textContent for the first child, which contains only the text + // of the current list item. + content = element.children!.first.textContent; + } else { + content = element.textContent; + } + + _content.add( + ListItemNode( + id: Editor.createNodeId(), + itemType: listItemType, + indent: indent, + text: _parseInlineText(content), + ), + ); + } + + void _addTask(md.Element element) { + bool checked = false; + if (element.children != null && // + element.children!.isNotEmpty && + element.children!.first is md.Element && + (element.children!.first as md.Element).tag == 'input') { + checked = (element.children!.first as md.Element).attributes['checked'] == 'true'; + } + + _content.add( + TaskNode( + id: Editor.createNodeId(), + text: _parseInlineText(element.textContent), + isComplete: checked, + ), + ); + } + + void _addTable(md.Element element) { + _content.add(element.asTable()); + } + + AttributedText _parseInlineText(String text) { + return parseInlineMarkdown( + text, + inlineMarkdownSyntaxes: inlineMarkdownSyntaxes, + inlineHtmlSyntaxes: inlineHtmlSyntaxes, + encodeHtml: encodeHtml, + ); + } + + _MarkdownImage? _maybeParseBlockImage(String markdown) { + if (!markdown.startsWith("![")) { + // Text doesn't start with Markdown image syntax. Return. + return null; + } + + return _MarkdownBlockImageParser().maybeParseBlockImage( + markdown, + syntax: syntax, + ); + } +} + +/// Converts a deserialized Markdown element into a [DocumentNode]. +/// +/// For example, the Markdown parser might identify an element called +/// "blockquote". A corresponding [ElementToNodeConverter] would receive +/// the "blockquote" element and create an appropriate [ParagraphNode] to +/// represent that blockquote in the deserialized [Document]. +abstract class ElementToNodeConverter { + DocumentNode? handleElement(md.Element element); +} + +/// A Markdown [DelimiterSyntax] that matches underline spans of text, which are represented in +/// Markdown with surrounding `¬` tags, e.g., "this is ¬underline¬ text". +/// +/// This [DelimiterSyntax] produces `Element`s with a `u` tag. +class UnderlineSyntax extends md.DelimiterSyntax { + /// According to the docs: + /// + /// https://pub.dev/documentation/markdown/latest/markdown/DelimiterSyntax-class.html + /// + /// The DelimiterSyntax constructor takes a nullable. However, the problem is there is a bug in the underlying dart + /// library if you don't pass it. Due to these two lines, one sets it to const [] if not passed, then the next tries + /// to sort. So we have to pass something at the moment or it blows up. + /// + /// https://github.com/dart-lang/markdown/blob/d53feae0760a4f0aae5ffdfb12d8e6acccf14b40/lib/src/inline_syntaxes/delimiter_syntax.dart#L67 + /// https://github.com/dart-lang/markdown/blob/d53feae0760a4f0aae5ffdfb12d8e6acccf14b40/lib/src/inline_syntaxes/delimiter_syntax.dart#L319 + static final _tags = [md.DelimiterTag("u", 1)]; + + UnderlineSyntax() : super('¬', requiresDelimiterRun: true, allowIntraWord: true, tags: _tags); + + @override + Iterable? close( + md.InlineParser parser, + md.Delimiter opener, + md.Delimiter closer, { + required List Function() getChildren, + required String tag, + }) { + final element = md.Element('u', getChildren()); + return [element]; + } +} + +/// A Markdown [DelimiterSyntax] that matches strikethrough spans of text, which are represented in +/// Markdown with surrounding `~` tags, e.g., "this is ~strikethrough~ text". +/// +/// Markdown in library in 7.2.1 seems to not be matching single strikethroughs +/// +/// This [DelimiterSyntax] produces `Element`s with a `del` tag. +class SingleStrikethroughSyntax extends md.DelimiterSyntax { + SingleStrikethroughSyntax() + : super( + '~', + requiresDelimiterRun: true, + allowIntraWord: true, + tags: [md.DelimiterTag('del', 1)], + ); +} + +/// Parses a paragraph preceded by an alignment token. +class _ParagraphWithAlignmentSyntax extends _EmptyLinePreservingParagraphSyntax { + /// This pattern matches the text aligment notation. + /// + /// Possible values are `:---`, `:---:`, `---:` and `-::-`. + static final _alignmentNotationPattern = RegExp(r'^:-{3}|:-{3}:|-{3}:|-::-$'); + + const _ParagraphWithAlignmentSyntax(); + + @override + bool canParse(md.BlockParser parser) { + if (!_alignmentNotationPattern.hasMatch(parser.current.content)) { + return false; + } + + final nextLine = parser.peek(1); + + // We found a match for a paragraph alignment token. However, the alignment token is the last + // line of content in the document. Therefore, it's not really a paragraph alignment token, and we + // should treat it as regular content. + if (nextLine == null) { + return false; + } + + /// We found a paragraph alignment token, but the block after the alignment token isn't a paragraph. + /// Therefore, the paragraph alignment token is actually regular content. This parser doesn't need to + /// take any action. + if (_standardNonParagraphBlockSyntaxes.any((syntax) => syntax.pattern.hasMatch(nextLine.content))) { + return false; + } + + // We found a paragraph alignment token, followed by a paragraph. Therefore, this parser should + // parse the given content. + return true; + } + + @override + md.Node? parse(md.BlockParser parser) { + final match = _alignmentNotationPattern.firstMatch(parser.current.content); + + // We've parsed the alignment token on the current line. We know a paragraph starts on the + // next line. Move the parser to the next line so that we can parse the paragraph. + parser.advance(); + + // Parse the paragraph using the standard Markdown paragraph parser. + final paragraph = super.parse(parser); + + if (paragraph is md.Element) { + paragraph.attributes.addAll({'textAlign': _convertMarkdownAlignmentTokenToSuperEditorAlignment(match!.input)}); + } + + return paragraph; + } + + /// Converts a markdown alignment token to the textAlign metadata used to configure + /// the [ParagraphNode] alignment. + String _convertMarkdownAlignmentTokenToSuperEditorAlignment(String alignmentToken) { + switch (alignmentToken) { + case ':---': + return 'left'; + case ':---:': + return 'center'; + case '---:': + return 'right'; + case '-::-': + return 'justify'; + // As we already check that the input matches the notation, + // we shouldn't reach this point. + default: + return 'left'; + } + } +} + +/// A [BlockSyntax] that parses paragraphs. +/// +/// Allows empty paragraphs and paragraphs containing blank lines. +class _EmptyLinePreservingParagraphSyntax extends md.BlockSyntax { + const _EmptyLinePreservingParagraphSyntax(); + + @override + RegExp get pattern => RegExp(''); + + @override + bool canEndBlock(md.BlockParser parser) => false; + + @override + bool canParse(md.BlockParser parser) { + if (_standardNonParagraphBlockSyntaxes.any((e) => e.canParse(parser))) { + // A standard non-paragraph parser wants to parse this input. Let the other parser run. + return false; + } + + if (parser.current.content.isEmpty) { + // We consider this input to be a separator between blocks because + // it started with an empty line. We want to parse this input. + return true; + } + + if (_isAtParagraphEnd(parser, ignoreEmptyBlocks: _endsWithHardLineBreak(parser.current.content))) { + // Another parser wants to parse this input. Let the other parser run. + return false; + } + + // The input is a paragraph. We want to parse it. + return true; + } + + @override + md.Node? parse(md.BlockParser parser) { + final childLines = []; + final startsWithEmptyLine = parser.current.content.isEmpty; + + // A hard line break causes the next line to be treated + // as part of the same paragraph, except if the next line is + // the beginning of another block element. + bool hasHardLineBreak = _endsWithHardLineBreak(parser.current.content); + + if (startsWithEmptyLine) { + // The parser started at an empty line. + // Consume the line as a separator between blocks. + parser.advance(); + + if (parser.isDone) { + // The document ended with a single empty line, so we just ignore it. + // To be considered as a paragraph starting with an empty line + // we need at least two empty lines: + // one to separate the paragraph from the previous block + // and another one to be the content of the paragraph. + return null; + } + + if (!_blankLinePattern.hasMatch(parser.current.content)) { + // We found an empty line, but the following line isn't blank. + // As there is no hard line break, the first line is consumed + // as a separator between blocks. + // Therefore, we aren't looking at a paragraph with blank lines. + return null; + } + + // We found a paragraph, and the first line of that paragraph is empty. Add a + // corresponding empty line to the parsed version of the paragraph. + childLines.add(''); + + // Check for a hard line break, so we consume the next line if we found one. + hasHardLineBreak = _endsWithHardLineBreak(parser.current.content); + parser.advance(); + } + + // Consume everything until another block element is found. + // A line break will cause the parser to stop, unless the preceding line + // ends with a hard line break. + while (!_isAtParagraphEnd(parser, ignoreEmptyBlocks: hasHardLineBreak)) { + final currentLine = parser.current; + childLines.add(currentLine.content); + + hasHardLineBreak = _endsWithHardLineBreak(currentLine.content); + + parser.advance(); + } + + // We already started looking at a different block element. + // Let another syntax parse it. + if (childLines.isEmpty) { + return null; + } + + // Remove trailing whitespace from each line of the parsed paragraph + // and join them into a single string, separated by a line breaks. + final contents = md.UnparsedContent(childLines.map((e) => _removeTrailingSpaces(e)).join('\n')); + return _LineBreakSeparatedElement('p', [contents]); + } + + /// Checks if the current line ends a paragraph by verifying if another + /// block syntax can parse the current input. + /// + /// An empty line ends the paragraph, unless [ignoreEmptyBlocks] is `true`. + bool _isAtParagraphEnd(md.BlockParser parser, {required bool ignoreEmptyBlocks}) { + if (parser.isDone) { + return true; + } + for (final syntax in parser.blockSyntaxes) { + if (syntax != this && + !(syntax is md.EmptyBlockSyntax && ignoreEmptyBlocks) && + syntax.canParse(parser) && + syntax.canEndBlock(parser)) { + return true; + } + } + return false; + } + + /// Removes all whitespace characters except `"\n"`. + String _removeTrailingSpaces(String text) { + final pattern = RegExp(r'[\t ]+$'); + return text.replaceAll(pattern, ''); + } + + /// Returns `true` if [line] ends with a hard line break. + /// + /// As per the Markdown spec, a line ending with two or more spaces + /// represents a hard line break. + /// + /// A hard line break causes the next line to be part of the + /// same paragraph, except if it's the beginning of another block element. + bool _endsWithHardLineBreak(String line) { + return line.endsWith(' '); + } +} + +/// An [Element] that preserves line breaks. +/// +/// The default [Element] implementation ignores all line breaks. +class _LineBreakSeparatedElement extends md.Element { + _LineBreakSeparatedElement(String tag, List? children) : super(tag, children); + + @override + String get textContent { + return (children ?? []).map((md.Node? child) => child!.textContent).join('\n'); + } +} + +/// Parses a header preceded by an alignment token. +/// +/// Headers are represented by `_ParagraphWithAlignmentSyntax`s and therefore +/// this parser must run before a [_ParagraphWithAlignmentSyntax], so that this parser +/// can process header-specific details, such as header alignment. +class _HeaderWithAlignmentSyntax extends md.BlockSyntax { + /// This pattern matches the text alignment notation. + /// + /// Possible values are `:---`, `:---:`, `---:` and `-::-`. + static final _alignmentNotationPattern = RegExp(r'^:-{3}|:-{3}:|-{3}:|-::-$'); + + /// Use internal HeaderSyntax. + final _headerSyntax = const md.HeaderSyntax(); + + @override + RegExp get pattern => RegExp(''); + + @override + bool canEndBlock(md.BlockParser parser) => false; + + @override + bool canParse(md.BlockParser parser) { + if (!_alignmentNotationPattern.hasMatch(parser.current.content)) { + return false; + } + + final nextLine = parser.peek(1); + + // We found a match for a paragraph alignment token. However, the alignment token is the last + // line of content in the document. Therefore, it's not really a paragraph alignment token, and we + // should treat it as regular content. + if (nextLine == null) { + return false; + } + + // Only parse if the next line is header. + if (!_headerSyntax.pattern.hasMatch(nextLine.content)) { + return false; + } + + return true; + } + + @override + md.Node? parse(md.BlockParser parser) { + final match = _alignmentNotationPattern.firstMatch(parser.current.content); + + // We've parsed the alignment token on the current line. We know a header starts on the + // next line. Move the parser to the next line so that we can parse the header. + parser.advance(); + + final headerNode = _headerSyntax.parse(parser); + + if (headerNode is md.Element) { + headerNode.attributes.addAll({'textAlign': _convertMarkdownAlignmentTokenToSuperEditorAlignment(match!.input)}); + } + + return headerNode; + } + + /// Converts a markdown alignment token to the textAlign metadata used to configure + /// the [ParagraphNode] alignment. + String _convertMarkdownAlignmentTokenToSuperEditorAlignment(String alignmentToken) { + switch (alignmentToken) { + case ':---': + return 'left'; + case ':---:': + return 'center'; + case '---:': + return 'right'; + case '-::-': + return 'justify'; + // As we already check that the input matches the notation, + // we shouldn't reach this point. + default: + return 'left'; + } + } +} + +class _MarkdownImage { + _MarkdownImage({ + required this.url, + this.altText, + this.width, + this.height, + }); + + final String url; + final String? altText; + final String? width; + final String? height; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _MarkdownImage && + runtimeType == other.runtimeType && + url == other.url && + altText == other.altText && + width == other.width && + height == other.height; + + @override + int get hashCode => url.hashCode ^ altText.hashCode ^ width.hashCode ^ height.hashCode; +} + +class _MarkdownBlockImageParser { + /// Parses a block-level image from the given [markdown]. + /// + /// A block-level image is a paragraph that contains an image tag + /// and no other text. + _MarkdownImage? maybeParseBlockImage( + String markdown, { + MarkdownSyntax syntax = MarkdownSyntax.superEditor, + }) { + final inlineParser = md.InlineParser( + markdown, + md.Document( + inlineSyntaxes: [ + if (syntax == MarkdownSyntax.superEditor) // + SuperEditorImageSyntax(), + ], + ), + ); + final inlineVisitor = _InlineMarkdownImageVisitor(); + final inlineNodes = inlineParser.parse(); + for (final inlineNode in inlineNodes) { + inlineNode.accept(inlineVisitor); + } + if (!inlineVisitor.isImage) { + return null; + } + + return _MarkdownImage( + url: inlineVisitor.imageUrl!, + altText: inlineVisitor.imageAltText, + width: inlineVisitor.width, + height: inlineVisitor.height, + ); + } +} + +/// A [md.NodeVisitor] that extracts an image from inline Markdown nodes. +class _InlineMarkdownImageVisitor implements md.NodeVisitor { + _InlineMarkdownImageVisitor(); + + /// Returns `true` if the parsed image is a block-level image. + /// + /// A block-level image is an image that is not part of a paragraph. + /// It has no text content, and it is not inline with other text. + /// + // For our purposes, we only support block-level images. Therefore, + // if we find an image without any text, we're parsing an image. + // Otherwise, if there is any text, then we're parsing a paragraph + // and we ignore the image. + bool get isImage => _imageUrl != null && _textStack.first.isEmpty; + + String? _imageUrl; + String? get imageUrl => _imageUrl; + + String? _imageAltText; + String? get imageAltText => _imageAltText; + + String? get width => _width; + String? _width; + + String? get height => _height; + String? _height; + + final List _textStack = [AttributedText()]; + + @override + bool visitElementBefore(md.Element element) { + if (element.tag == 'img' && element.attributes.containsKey('src')) { + _imageUrl = element.attributes['src']!; + _imageAltText = element.attributes['alt'] ?? ''; + _width = element.attributes['width']; + _height = element.attributes['height']; + return true; + } + + _textStack.add(AttributedText()); + + return true; + } + + @override + void visitText(md.Text text) { + final attributedText = _textStack.removeLast(); + _textStack.add(attributedText.copyAndAppend(AttributedText(text.text))); + } + + @override + void visitElementAfter(md.Element element) {} +} + +/// Matches empty lines or lines containing only whitespace. +final _blankLinePattern = RegExp(r'^(?:[ \t]*)$'); + +const List _standardNonParagraphBlockSyntaxes = [ + md.HeaderSyntax(), + md.CodeBlockSyntax(), + md.FencedCodeBlockSyntax(), + md.BlockquoteSyntax(), + md.HorizontalRuleSyntax(), + md.UnorderedListWithCheckboxSyntax(), + md.UnorderedListSyntax(), + md.OrderedListSyntax(), +]; diff --git a/super_editor/lib/src/infrastructure/serialization/markdown/super_editor_paste_markdown.dart b/super_editor/lib/src/infrastructure/serialization/markdown/super_editor_paste_markdown.dart new file mode 100644 index 0000000000..60b39a0489 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/markdown/super_editor_paste_markdown.dart @@ -0,0 +1,84 @@ +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/common_editor_operations.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; +import 'package:super_editor/src/infrastructure/serialization/markdown/markdown_to_document_parsing.dart'; + +/// A [SuperEditor] keyboard action that pastes clipboard content into the document, +/// interpreting the clipboard content as Markdown. +ExecutionInstruction pasteMarkdownOnCmdAndCtrlV({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyV) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + pasteMarkdown( + editor: editContext.editor, + document: editContext.document, + composer: editContext.composer, + ); + + return ExecutionInstruction.haltExecution; +} + +/// Deletes all selected content, and then pastes the current clipboard +/// content at the given location, interpreting the clipboard content +/// as Markdown. +/// +/// The clipboard operation is asynchronous. As a result, if the user quickly +/// moves the caret, it's possible that the clipboard content will be pasted +/// at the wrong spot. +Future pasteMarkdown({ + required Editor editor, + required Document document, + required DocumentComposer composer, +}) async { + // Delete all currently selected content. + if (!composer.selection!.isCollapsed) { + final pastePosition = CommonEditorOperations.getDocumentPositionAfterExpandedDeletion( + document: document, + selection: composer.selection!, + ); + + if (pastePosition == null) { + // A null pastePosition means that the selection can't be deleted. This might happen + // when the selection contains only non-deletable nodes. Therefore, we cannot paste. + return; + } + + // Delete the selected content. + editor.execute([ + DeleteContentRequest(documentRange: composer.selection!), + ChangeSelectionRequest( + DocumentSelection.collapsed(position: pastePosition), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ]); + } + + final markdownToPaste = (await Clipboard.getData('text/plain'))?.text ?? ''; + final deserializedMarkdown = deserializeMarkdownToDocument(markdownToPaste); + + // Paste the structured content into the document. + editor.execute([ + PasteStructuredContentEditorRequest( + content: deserializedMarkdown, + pastePosition: composer.selection!.extent, + ), + ]); +} diff --git a/super_editor_markdown/lib/src/super_editor_syntax.dart b/super_editor/lib/src/infrastructure/serialization/markdown/super_editor_syntax.dart similarity index 69% rename from super_editor_markdown/lib/src/super_editor_syntax.dart rename to super_editor/lib/src/infrastructure/serialization/markdown/super_editor_syntax.dart index 05f6075dc7..5b2e5e9b0b 100644 --- a/super_editor_markdown/lib/src/super_editor_syntax.dart +++ b/super_editor/lib/src/infrastructure/serialization/markdown/super_editor_syntax.dart @@ -2,7 +2,7 @@ enum MarkdownSyntax { /// Standard markdown syntax. normal, - /// Extended syntax which supports serialization of text alignment, strikethrough and underline. + /// Extended syntax which supports serialization of text alignment, strikethrough, underline and image size. /// /// Underline text is serialized between a pair of `¬`. /// @@ -14,5 +14,10 @@ enum MarkdownSyntax { /// `:---:` represents center alignment. /// /// `---:` represents right alignment. + /// + /// `-::-` represents justify alignment. + /// + /// Image size is serialized using the notation `=widthxheight` after the url, + /// separated by a space. superEditor, } diff --git a/super_editor/lib/src/infrastructure/serialization/markdown/table.dart b/super_editor/lib/src/infrastructure/serialization/markdown/table.dart new file mode 100644 index 0000000000..88289efeac --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/markdown/table.dart @@ -0,0 +1,107 @@ +import 'dart:ui'; + +import 'package:markdown/markdown.dart' as md; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/tables/table_block.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/serialization/markdown/markdown_inline_parser.dart'; + +extension ElementTableExtension on md.Element { + /// Converts this element to a [TableBlockNode]. + /// + /// The element must have a `table` tag. + /// + /// Throws an exception if the element is not a valid table structure. + TableBlockNode asTable({ + Iterable? inlineMarkdownSyntaxes, + Iterable? inlineHtmlSyntaxes, + }) { + if (tag != 'table') { + throw Exception('Cannot parse a table from an element with tag "$tag"'); + } + + if (children == null || children!.isEmpty) { + throw Exception('A table must have at least one child element'); + } + + final cells = >[]; + + final headerElement = children![0]; + if (headerElement is! md.Element || headerElement.tag != 'thead') { + throw Exception('Table header must be a element'); + } + if (headerElement.children == null || headerElement.children!.isEmpty) { + throw Exception('Table header must have a row'); + } + + final headerRow = headerElement.children![0]; + if (headerRow is! md.Element || headerRow.tag != 'tr') { + throw Exception('Table header row must be a element'); + } + + final headerNodes = []; + for (final headerCell in headerRow.children!) { + if (headerCell is! md.Element || headerCell.tag != 'th') { + throw Exception('Table header cells must be elements'); + } + headerNodes.add( + TextNode( + id: Editor.createNodeId(), + text: parseInlineMarkdown( + headerCell.textContent, + inlineMarkdownSyntaxes: inlineMarkdownSyntaxes, + inlineHtmlSyntaxes: inlineHtmlSyntaxes, + ), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ); + } + cells.add(headerNodes); + + if (children!.length >= 2) { + // The table contains the table body element. + final bodyElement = children![1]; + if (bodyElement is! md.Element || bodyElement.tag != 'tbody') { + throw Exception('Table body must be a element'); + } + + for (final rowElement in bodyElement.children!) { + if (rowElement is! md.Element || rowElement.tag != 'tr') { + throw Exception('Table body rows must be elements'); + } + + final row = []; + for (int i = 0; i < rowElement.children!.length; i++) { + final cellElement = rowElement.children![i]; + if (cellElement is! md.Element || cellElement.tag != 'td') { + throw Exception('Table body cells must be elements'); + } + final textAlign = switch ((headerRow.children![i] as md.Element).attributes['align']) { + 'left' => TextAlign.left, + 'center' => TextAlign.center, + 'right' => TextAlign.right, + _ => TextAlign.left, + }; + + row.add(TextNode( + id: Editor.createNodeId(), + text: parseInlineMarkdown(cellElement.textContent), + metadata: { + if (textAlign != TextAlign.left) TextNodeMetadata.textAlign: textAlign, + }, + )); + } + cells.add(row); + } + } + + return TableBlockNode( + id: Editor.createNodeId(), + cells: cells, + ); + } +} diff --git a/super_editor/lib/src/infrastructure/serialization/plain_text/document_to_plain_text.dart b/super_editor/lib/src/infrastructure/serialization/plain_text/document_to_plain_text.dart new file mode 100644 index 0000000000..3e5fd3da45 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/plain_text/document_to_plain_text.dart @@ -0,0 +1,43 @@ +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; + +extension ToPlainText on Document { + /// Serializes this [Document] to a plain-text representation by writing the text + /// from every [TextNode] to a `String`. + /// + /// Non-[TextNode]s are skipped. All attributions within [TextNode]s are ignored. + /// Inline text placeholders are stripped out. + String toPlainText({DocumentSelection? selection}) { + final plainTextBuffer = StringBuffer(); + + selection = selection ?? + DocumentSelection( + base: DocumentPosition(nodeId: first.id, nodePosition: first.beginningPosition), + extent: DocumentPosition(nodeId: last.id, nodePosition: last.endPosition), + ); + final selectedRange = selection.normalize(this); + final selectedNodes = getNodesInside( + selectedRange.start, + selectedRange.end, + ); + + for (final node in selectedNodes) { + switch (node) { + case TextNode(): + final textStart = + node.id == selectedRange.start.nodeId ? (selectedRange.start.nodePosition as TextNodePosition).offset : 0; + final textEnd = node.id == selectedRange.end.nodeId + ? (selectedRange.end.nodePosition as TextNodePosition).offset + : node.text.length; + + plainTextBuffer.write( + "${node.text.copyText(textStart, textEnd).toPlainText(includePlaceholders: false)}\n", + ); + default: // We don't know how to encode non-text nodes as plain text. Ignore. + } + } + + return plainTextBuffer.toString(); + } +} diff --git a/super_editor/lib/src/infrastructure/serialization/quill/content/formatting.dart b/super_editor/lib/src/infrastructure/serialization/quill/content/formatting.dart new file mode 100644 index 0000000000..4690c8b721 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/quill/content/formatting.dart @@ -0,0 +1,33 @@ +import 'package:attributed_text/attributed_text.dart'; + +/// An [Attribution] that sets the font size of text based on a given size +/// name, e.g., "huge", "large", "normal", "small". +class NamedFontSizeAttribution implements Attribution { + const NamedFontSizeAttribution(this.fontSizeName); + + /// The ID that determines whether two overlapping attributions conflict + /// with each other (aren't allowed to overlap). + /// + /// In the case of [NamedFontSizeAttribution], this ID needs to match + /// [FontSizeAttribution] because they both impact font size in the final + /// display. + @override + String get id => "font_size"; + + /// The name of the font size to use for the text attributed with this + /// attribution, e.g., "huge", "large", "normal", "small". + final String fontSizeName; + + @override + bool canMergeWith(Attribution other) { + return this == other; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NamedFontSizeAttribution && runtimeType == other.runtimeType && fontSizeName == other.fontSizeName; + + @override + int get hashCode => fontSizeName.hashCode; +} diff --git a/super_editor/lib/src/infrastructure/serialization/quill/content/multimedia.dart b/super_editor/lib/src/infrastructure/serialization/quill/content/multimedia.dart new file mode 100644 index 0000000000..d0e5fc6619 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/quill/content/multimedia.dart @@ -0,0 +1,149 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/foundation.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/box_component.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; + +/// [DocumentNode] that represents a video at a URL. +@immutable +class VideoNode extends UrlMediaNode { + static const videoAttribution = NamedAttribution("video"); + + VideoNode({ + required super.id, + required super.url, + super.altText = '', + super.blockAttribution = videoAttribution, + }); + + @override + DocumentNode copy() { + return VideoNode(id: id, url: url, altText: altText, blockAttribution: blockquoteAttribution); + } +} + +/// [DocumentNode] that represents an audio source at a URL. +@immutable +class AudioNode extends UrlMediaNode { + static const audioAttribution = NamedAttribution("audio"); + + AudioNode({ + required super.id, + required super.url, + super.altText = '', + super.blockAttribution = audioAttribution, + }); + + @override + DocumentNode copy() { + return AudioNode(id: id, url: url, altText: altText, blockAttribution: blockquoteAttribution); + } +} + +/// [DocumentNode] that represents a file at a URL. +@immutable +class FileNode extends UrlMediaNode { + static const fileAttribution = NamedAttribution("file"); + + FileNode({ + required super.id, + required super.url, + super.altText = '', + super.blockAttribution = fileAttribution, + }); + + @override + DocumentNode copy() { + return FileNode(id: id, url: url, altText: altText, blockAttribution: blockquoteAttribution); + } +} + +/// [DocumentNode] that represents a media source that exists a given [url]. +@immutable +class UrlMediaNode extends BlockNode { + UrlMediaNode({ + required this.id, + required this.url, + this.altText = '', + required Attribution blockAttribution, + super.metadata, + }) { + initAddToMetadata({ + "blockType": blockAttribution, + }); + } + + @override + final String id; + + final String url; + + final String altText; + + @override + String? copyContent(dynamic selection) { + if (selection is! UpstreamDownstreamNodeSelection) { + throw Exception('ImageNode can only copy content from a UpstreamDownstreamNodeSelection.'); + } + + return !selection.isCollapsed ? url : null; + } + + @override + bool hasEquivalentContent(DocumentNode other) { + return other is UrlMediaNode && url == other.url && altText == other.altText; + } + + @override + UrlMediaNode copyAndReplaceMetadata(Map newMetadata) { + return copyUrlMediaWith( + metadata: newMetadata, + ); + } + + @override + UrlMediaNode copyWithAddedMetadata(Map newProperties) { + return copyUrlMediaWith(metadata: { + ...metadata, + ...newProperties, + }); + } + + UrlMediaNode copyUrlMediaWith({ + String? id, + String? url, + String? altText, + Attribution? blockAttribution, + Map? metadata, + }) { + return UrlMediaNode( + id: id ?? this.id, + url: url ?? this.url, + blockAttribution: blockAttribution ?? this.metadata[NodeMetadata.blockType], + metadata: metadata ?? this.metadata, + ); + } + + DocumentNode copy() { + return UrlMediaNode( + id: id, + url: url, + altText: altText, + blockAttribution: getMetadataValue("blockType"), + metadata: metadata, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UrlMediaNode && + runtimeType == other.runtimeType && + id == other.id && + url == other.url && + altText == other.altText; + + @override + int get hashCode => id.hashCode ^ url.hashCode ^ altText.hashCode; +} diff --git a/super_editor/lib/src/infrastructure/serialization/quill/parsing/block_formats.dart b/super_editor/lib/src/infrastructure/serialization/quill/parsing/block_formats.dart new file mode 100644 index 0000000000..ce91558826 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/quill/parsing/block_formats.dart @@ -0,0 +1,398 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/image.dart'; +import 'package:super_editor/src/default_editor/list_items.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/serialization/quill/content/multimedia.dart'; + +/// A [BlockDeltaFormat] that applies a header block type to a paragraph. +class HeaderDeltaFormat extends FilterByNameBlockDeltaFormat { + static const _header = "header"; + + const HeaderDeltaFormat() : super(_header); + + @override + List? doApplyFormat(Editor editor, Object value) { + if (value is! int) { + return null; + } + + final composer = editor.context.find(Editor.composerKey); + final level = value; + + return [ + ChangeParagraphBlockTypeRequest( + nodeId: composer.selection!.extent.nodeId, + blockType: _getHeaderAttribution(level), + ), + ]; + } + + Attribution _getHeaderAttribution(int level) { + switch (level) { + case 1: + return header1Attribution; + case 2: + return header2Attribution; + case 3: + return header3Attribution; + case 4: + return header4Attribution; + case 5: + return header5Attribution; + case 6: + default: + return header6Attribution; + } + } +} + +/// A [BlockDeltaFormat] that applies a blockquote block type to a paragraph. +class BlockquoteDeltaFormat extends FilterByNameBlockDeltaFormat { + static const _blockquote = "blockquote"; + + const BlockquoteDeltaFormat() : super(_blockquote); + + @override + List? doApplyFormat(Editor editor, Object value) { + final composer = editor.context.find(Editor.composerKey); + + return [ + ChangeParagraphBlockTypeRequest( + nodeId: composer.selection!.extent.nodeId, + blockType: blockquoteAttribution, + ), + ]; + } +} + +/// A [BlockDeltaFormat] that applies a code block type to a paragraph. +class CodeBlockDeltaFormat extends FilterByNameBlockDeltaFormat { + static const _code = "code-block"; + + const CodeBlockDeltaFormat() : super(_code); + + @override + List? doApplyFormat(Editor editor, Object value) { + final composer = editor.context.find(Editor.composerKey); + + // TODO: add support for recording the language of the code block, which comes from + // the value of the "code-block" property. + return [ + ChangeParagraphBlockTypeRequest( + nodeId: composer.selection!.extent.nodeId, + blockType: codeAttribution, + ), + ]; + } +} + +/// A [BlockDeltaFormat] that converts a paragraph to a list item or a task. +class ListDeltaFormat extends FilterByNameBlockDeltaFormat { + static const _list = "list"; + static const _listOrdered = "ordered"; + static const _listUnordered = "bullet"; + + const ListDeltaFormat() : super(_list); + + @override + List? doApplyFormat(Editor editor, Object value) { + if (value is! String) { + return null; + } + + final composer = editor.context.find(Editor.composerKey); + + if (_isTask(value)) { + return [ + ConvertParagraphToTaskRequest( + nodeId: composer.selection!.extent.nodeId, + isComplete: _isChecked(value), + ), + ]; + } + + return [ + ConvertParagraphToListItemRequest( + nodeId: composer.selection!.extent.nodeId, + type: _getListType(value), + ), + ]; + } + + bool _isTask(String name) { + return name == "checked" || name == "unchecked"; + } + + bool _isChecked(String name) { + assert(_isTask(name)); + return name == "checked"; + } + + ListItemType _getListType(String name) { + switch (name) { + case _listOrdered: + return ListItemType.ordered; + case _listUnordered: + return ListItemType.unordered; + default: + throw Exception("Unknown list item type: $name"); + } + } +} + +/// A [BlockDeltaFormat] that applies an alignment to a paragraph. +class AlignDeltaFormat extends FilterByNameBlockDeltaFormat { + static const _align = "align"; + static const _alignLeft = "left"; + static const _alignCenter = "center"; + static const _alignRight = "right"; + static const _alignJustify = "justify"; + + const AlignDeltaFormat() : super(_align); + + @override + List? doApplyFormat(Editor editor, Object value) { + if (value is! String) { + return null; + } + + final composer = editor.context.find(Editor.composerKey); + + return [ + ChangeParagraphAlignmentRequest( + nodeId: composer.selection!.extent.nodeId, + alignment: _getTextAlignment(value), + ), + ]; + } + + TextAlign _getTextAlignment(String name) { + switch (name) { + case _alignLeft: + return TextAlign.start; + case _alignCenter: + return TextAlign.center; + case _alignRight: + return TextAlign.end; + case _alignJustify: + return TextAlign.justify; + default: + throw Exception("Unknown text alignment: $name"); + } + } +} + +/// A [BlockDeltaFormat] that applies an indent to a paragraph. +class IndentParagraphDeltaFormat extends FilterByNameBlockDeltaFormat { + static const _indent = "indent"; + + const IndentParagraphDeltaFormat() : super(_indent); + + @override + List? doApplyFormat(Editor editor, Object value) { + final composer = editor.context.find(Editor.composerKey); + + return [ + SetParagraphIndentRequest( + composer.selection!.extent.nodeId, + level: value as int, + ), + ]; + } +} + +/// A [BlockDeltaFormat] that filters out any operation that doesn't have +/// an attribute with the given [name]. +abstract class FilterByNameBlockDeltaFormat implements BlockDeltaFormat { + const FilterByNameBlockDeltaFormat(this.name); + + final String name; + + @override + List? applyTo(Operation operation, Editor editor) { + if (!operation.hasAttribute(name)) { + return null; + } + + return doApplyFormat(editor, operation.attributes![name]); + } + + @protected + List? doApplyFormat(Editor editor, Object value); +} + +class ImageEmbedBlockDeltaFormat extends StandardEmbedBlockDeltaFormat { + const ImageEmbedBlockDeltaFormat(); + + @override + DocumentNode? createNodeForEmbed(Operation operation, String nodeId) { + final data = operation.data; + if (data is! Map) { + return null; + } + + final imageUrl = data['image']; + if (imageUrl is! String) { + return null; + } + + return ImageNode( + id: nodeId, + imageUrl: imageUrl, + ); + } +} + +class AudioEmbedBlockDeltaFormat extends StandardEmbedBlockDeltaFormat { + const AudioEmbedBlockDeltaFormat(); + + @override + DocumentNode? createNodeForEmbed(Operation operation, String nodeId) { + final data = operation.data; + if (data is! Map) { + return null; + } + + final audioUrl = data['audio']; + if (audioUrl is! String) { + return null; + } + + return AudioNode( + id: nodeId, + url: audioUrl, + ); + } +} + +class VideoEmbedBlockDeltaFormat extends StandardEmbedBlockDeltaFormat { + const VideoEmbedBlockDeltaFormat(); + + @override + DocumentNode? createNodeForEmbed(Operation operation, String nodeId) { + final data = operation.data; + if (data is! Map) { + return null; + } + + final videoUrl = data['video']; + if (videoUrl is! String) { + return null; + } + + return VideoNode( + id: nodeId, + url: videoUrl, + ); + } +} + +class FileEmbedBlockDeltaFormat extends StandardEmbedBlockDeltaFormat { + const FileEmbedBlockDeltaFormat(); + + @override + DocumentNode? createNodeForEmbed(Operation operation, String nodeId) { + final data = operation.data; + if (data is! Map) { + return null; + } + + final fileUrl = data['file']; + if (fileUrl is! String) { + return null; + } + + return FileNode( + id: nodeId, + url: fileUrl, + ); + } +} + +abstract class StandardEmbedBlockDeltaFormat implements BlockDeltaFormat { + const StandardEmbedBlockDeltaFormat(); + + @override + List? applyTo(Operation operation, Editor editor) { + // Check if the selected node is an empty text node. If it is, we want to replace it + // with the media that we're inserting. + final document = editor.context.find(Editor.documentKey); + final selectedNodeId = editor.context.composer.selection!.extent.nodeId; + final selectedNode = document.getNodeById(selectedNodeId); + final shouldReplaceSelectedNode = selectedNode is TextNode && selectedNode.text.isEmpty; + + final newNodeId = Editor.createNodeId(); + final newNode = createNodeForEmbed(operation, newNodeId); + if (newNode == null) { + return null; + } + + final newParagraphId = Editor.createNodeId(); + return [ + shouldReplaceSelectedNode + ? ReplaceNodeRequest( + existingNodeId: selectedNodeId, + newNode: newNode, + ) + : InsertNodeAfterNodeRequest( + existingNodeId: editor.context.composer.selection!.extent.nodeId, + newNode: newNode, + ), + // Always insert an empty paragraph after the embed block so that the user + // is able to enter text below it. + InsertNodeAfterNodeRequest( + existingNodeId: newNodeId, + newNode: ParagraphNode( + id: newParagraphId, + text: AttributedText(""), + ), + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newParagraphId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.contentChange, + ), + ]; + } + + /// Attempts to parse the given [operation] as a desired block-level embed, + /// returning a [DocumentNode] that represents the embed, or `null` if this + /// format doesn't apply to the given block-level embed. + /// + /// The returned [DocumentNode] should use the given [nodeId] as its ID. + DocumentNode? createNodeForEmbed(Operation operation, String nodeId); +} + +/// A block-level format for a text block, e.g., header, blockquote, code. +/// +/// Given a Quill Delta text insertion operation, a [BlockDeltaFormat] inspects +/// the delta attributes for a given block format property. The [BlockDeltaFormat] +/// then returns the [EditRequest]s necessary to apply that block format to the +/// currently selected [DocumentNode] in the Super Editor [Document] (which is +/// available through the [Editor]. +/// +/// For example, a header block delta format might inspect the operation attributes +/// looking for the key "header". It finds the key "header" with a value of `1`, +/// signifying a "level 1 header". That block delta format then checks the [editor] +/// for the currently selected node. Finally, that block delta format assembles an +/// [EditRequest] to apply a [header1Attribution] to the currently selected +/// [ParagraphNode]. +abstract interface class BlockDeltaFormat { + List? applyTo(Operation operation, Editor editor); +} diff --git a/super_editor/lib/src/infrastructure/serialization/quill/parsing/inline_formats.dart b/super_editor/lib/src/infrastructure/serialization/quill/parsing/inline_formats.dart new file mode 100644 index 0000000000..b66cacf09c --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/quill/parsing/inline_formats.dart @@ -0,0 +1,207 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/infrastructure/serialization/quill/content/formatting.dart'; + +/// An inline Quill Delta format that applies a color to text. +class ColorDeltaFormat extends FilterByNameInlineDeltaFormat { + static const _color = "color"; + + const ColorDeltaFormat() : super(_color); + + @override + Attribution? createAttribution(String value) { + if (!value.startsWith("#")) { + throw Exception("Unknown color value: '$value' - expected it to start with '#'"); + } + if (value.length != 7 && value.length != 9) { + throw Exception("Unknown color value: '$value' - expected either #rrggbb or #aarrggbb"); + } + + late final int colorValue; + if (value.length == 7) { + // Value is "#rrggbb" - we need to add full alpha to avoid leading zeros. + colorValue = int.parse(value.substring(1), radix: 16) + 0xFF000000; + } else { + // Value is #aarrggbb. + colorValue = int.parse(value.substring(1), radix: 16); + } + final color = Color(colorValue); + + return ColorAttribution(color); + } +} + +/// An inline Quill Delta format that applies a background color (highlight) to text. +class BackgroundColorDeltaFormat extends FilterByNameInlineDeltaFormat { + static const _background = "background"; + + const BackgroundColorDeltaFormat() : super(_background); + + @override + Attribution? createAttribution(String value) { + if (!value.startsWith("#")) { + throw Exception("Unknown color value: '$value' - expected it to start with '#'"); + } + if (value.length != 7 && value.length != 9) { + throw Exception("Unknown color value: '$value' (length ${value.length}) - expected either #rrggbb or #aarrggbb"); + } + + late final int colorValue; + if (value.length == 7) { + // Value is "#rrggbb" - we need to add full alpha to avoid leading zeros. + colorValue = int.parse(value.substring(1), radix: 16) + 0xFF000000; + } else { + // Value is #aarrggbb. + colorValue = int.parse(value.substring(1), radix: 16); + } + final color = Color(colorValue); + + return BackgroundColorAttribution(color); + } +} + +/// An inline Quill Delta format that makes text superscript or subscript. +class ScriptDeltaFormat extends FilterByNameInlineDeltaFormat { + static const _script = "script"; + static const _superscript = "super"; + static const _subscript = "sub"; + + const ScriptDeltaFormat() : super(_script); + + @override + Attribution? createAttribution(String value) { + if (value == _superscript) { + return superscriptAttribution; + } + if (value == _subscript) { + return subscriptAttribution; + } + + // TODO: log that we received an unknown script value. + return null; + } +} + +/// An inline Quill Delta format that applies a font family to text. +class FontFamilyDeltaFormat extends FilterByNameInlineDeltaFormat { + static const _font = "font"; + + const FontFamilyDeltaFormat() : super(_font); + + @override + Attribution? createAttribution(Object value) { + return FontFamilyAttribution(value as String); + } +} + +/// An inline Quill Delta format that applies a named or numerical size to text. +class SizeDeltaFormat extends FilterByNameInlineDeltaFormat { + static const _size = "size"; + + const SizeDeltaFormat() : super(_size); + + @override + Attribution? createAttribution(Object value) { + if (value is num) { + final size = value.toDouble(); + return FontSizeAttribution(size); + } + + if (value is String) { + return NamedFontSizeAttribution(value); + } + + // TODO: log unknown size value. + return null; + } +} + +/// An inline Quill Delta format that applies a link to text. +class LinkDeltaFormat extends FilterByNameInlineDeltaFormat { + static const _link = "link"; + + const LinkDeltaFormat() : super(_link); + + @override + Attribution? createAttribution(String value) { + return LinkAttribution(value); + } +} + +/// An [InlineDeltaFormat] that filters out any operation that doesn't have +/// an attribute with the given [name]. +abstract class FilterByNameInlineDeltaFormat implements InlineDeltaFormat { + const FilterByNameInlineDeltaFormat(this.name); + + final String name; + + @override + Attribution? from(Operation operation) { + if (!operation.hasAttribute(name)) { + return null; + } + + return createAttribution(operation.attributes![name]); + } + + @protected + Attribution? createAttribution(String value); +} + +/// An [InlineDeltaFormat] that applies a given [attribution] to text whenever +/// that text insertion includes an attribute with the given [name]. +/// +/// This class removes verbosity when writing [InlineDeltaFormat]s where the +/// existence of an attribute name means that a known attribution should be +/// applied. +class NamedInlineDeltaFormat implements InlineDeltaFormat { + const NamedInlineDeltaFormat(this.name, this.attribution); + + final String name; + final Attribution attribution; + + @override + Attribution? from(Operation operation) { + if (!operation.hasAttribute(name)) { + return null; + } + + return attribution; + } +} + +/// Given a Quill Delta text insertion operation, inspects the delta's attributes and then +/// returns an attribution that should be applied to the [AttributedText] created by the +/// insertion operation. +/// +/// For example, a bold inline delta format might inspect an operation for an attribute +/// called "bold". Upon finding an attribute called "bold", that inline delta format +/// would return a [boldAttribution], which the parser would then apply to the Super Editor +/// document. +abstract interface class InlineDeltaFormat { + Attribution? from(Operation operation); +} + +abstract interface class InlineEmbedFormat { + bool insert(Editor editor, DocumentComposer composer, Map embed); +} + +class InlineEmbed { + const InlineEmbed(this.text, this.data); + + final AttributedText text; + final Object? data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is InlineEmbed && runtimeType == other.runtimeType && text == other.text && data == other.data; + + @override + int get hashCode => text.hashCode ^ data.hashCode; +} diff --git a/super_editor/lib/src/infrastructure/serialization/quill/parsing/parser.dart b/super_editor/lib/src/infrastructure/serialization/quill/parsing/parser.dart new file mode 100644 index 0000000000..191cb8c0c1 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/quill/parsing/parser.dart @@ -0,0 +1,617 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/default_document_editor.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/serialization/quill/parsing/block_formats.dart'; +import 'package:super_editor/src/infrastructure/serialization/quill/parsing/inline_formats.dart'; + +/// Parses a fully formed Quill Delta document (as JSON) into a [MutableDocument]. +/// +/// The format of a Delta document looks like: +/// +/// { +/// "ops": [ +/// ... +/// ] +/// } +/// +/// {@template parse_deltas_custom_editor} +/// An [Editor] is used to insert content in the final document. For typical Delta +/// formats, the default configuration for an [Editor] should work fine, and that's +/// what this method uses. However, some apps need to run custom commands, especially +/// for custom inline embeds. In that case, you can provide a [customEditor], which +/// is configured however you'd like. The [customEditor] must contain a [MutableDocument] +/// and a [MutableComposer]. The document must be empty. +/// {@endtemplate} +/// +/// {@template merge_consecutive_blocks} +/// ### Merging consecutive blocks +/// +/// The Delta format creates some ambiguity around when multiple lines should +/// be combined into a single block vs one block per line. E.g., a code block +/// with multiple lines of code vs a series of independent code blocks. +/// +/// [blockMergeRules] explicitly tells the parser which consecutive +/// [DocumentNode]s should be merged together when not separated by an unstyled +/// newline in the given deltas. +/// +/// Example of consecutive code blocks that would be merged (if requested): +/// +/// [ +/// { "insert": "Code line one" }, +/// { "insert": "\n", "attributed": { "code-block": "plain"} }, +/// { "insert": "Code line two" }, +/// { "insert": "\n", "attributed": { "code-block": "plain"} }, +/// ] +/// +/// Example of code blocks, separated by an unstyled newline, that wouldn't be merged: +/// +/// [ +/// { "insert": "Code line one" }, +/// { "insert": "\n", "attributed": { "code-block": "plain"} }, +/// { "insert": "\n" }, +/// { "insert": "Code line two" }, +/// { "insert": "\n", "attributed": { "code-block": "plain"} }, +/// ] +/// +/// {@endtemplate} +/// +/// For more information about the Quill Delta format, see the official +/// documentation: https://quilljs.com/docs/delta/ +MutableDocument parseQuillDeltaDocument( + Map deltaDocument, { + Editor? customEditor, + List blockFormats = defaultBlockFormats, + List blockMergeRules = defaultBlockMergeRules, + List inlineFormats = defaultInlineFormats, + List inlineEmbedFormats = const [], + List embedBlockFormats = defaultEmbedBockFormats, +}) { + return parseQuillDeltaOps( + deltaDocument["ops"], + customEditor: customEditor, + blockMergeRules: blockMergeRules, + blockFormats: blockFormats, + inlineFormats: inlineFormats, + inlineEmbedFormats: inlineEmbedFormats, + embedBlockFormats: embedBlockFormats, + ); +} + +/// Parses a list of Quill Delta operations (as JSON) into a [MutableDocument]. +/// +/// This parser is the same as [parseQuillDeltaDocument] except that this method +/// directly accepts the operations list instead of the whole document map. This +/// method is provided for convenience because in some situations only the +/// operations are exchanged, rather than the whole document object. +/// +/// {@macro parse_deltas_custom_editor} +/// +/// {@macro merge_consecutive_blocks} +MutableDocument parseQuillDeltaOps( + List deltaOps, { + Editor? customEditor, + List blockFormats = defaultBlockFormats, + List blockMergeRules = defaultBlockMergeRules, + List inlineFormats = defaultInlineFormats, + List inlineEmbedFormats = const [], + List embedBlockFormats = defaultEmbedBockFormats, +}) { + // Deserialize the delta operations JSON into a Dart data structure. + final deltaDocument = Delta.fromJson(deltaOps); + + late final MutableDocument document; + late final MutableDocumentComposer composer; + late final Editor editor; + if (customEditor != null) { + // Use the provided custom editor. + if (customEditor.context.maybeDocument == null) { + throw Exception("The provided customEditor must contain a MutableDocument in its editables."); + } + if (customEditor.context.maybeComposer == null) { + throw Exception("The provided customEditor must contain a MutableDocumentComposer in its editables."); + } + + editor = customEditor; + document = editor.context.document; + composer = editor.context.composer; + + if (document.nodeCount > 1 || + document.first is! ParagraphNode || + (document.first as ParagraphNode).text.length > 0) { + throw Exception("The customEditor document must be empty (contain a single, empty ParagraphNode)."); + } + } else { + // Create a new, empty Super Editor document. + document = MutableDocument.empty(); + composer = MutableDocumentComposer(); + editor = Editor( + editables: { + Editor.documentKey: document, + Editor.composerKey: composer, + }, + requestHandlers: List.from(defaultRequestHandlers), + // No reactions. Follow the delta operations exactly. + reactionPipeline: [], + ); + } + + // Place the caret in the (only) empty paragraph so we can begin applying + // deltas to the document. + final firstParagraph = document.first as ParagraphNode; + composer.setSelectionWithReason( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: firstParagraph.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionReason.contentChange, + ); + + // Run every Quill Delta operation on the empty document. At the end of this + // process the Super Editor document will reflect the desired Quill Delta + // document state. + for (final delta in deltaDocument.operations) { + delta.applyToDocument( + editor, + blockFormats: blockFormats, + blockMergeRules: blockMergeRules, + inlineFormats: inlineFormats, + inlineEmbedFormats: inlineEmbedFormats, + embedBlockFormats: embedBlockFormats, + ); + } + + return document; +} + +/// The standard block-level text formats that are parsed from Quill Deltas, +/// e.g., headers, blockquotes, list items. +const defaultBlockFormats = [ + HeaderDeltaFormat(), + BlockquoteDeltaFormat(), + CodeBlockDeltaFormat(), + ListDeltaFormat(), + AlignDeltaFormat(), + IndentParagraphDeltaFormat(), +]; + +/// The standard inline text formats that are parsed from Quill Deltas, e.g., +/// bold, italics, underline, links. +const defaultInlineFormats = [ + // Named inline attributes (no parsing). + NamedInlineDeltaFormat("bold", boldAttribution), + NamedInlineDeltaFormat("italic", italicsAttribution), + NamedInlineDeltaFormat("underline", underlineAttribution), + NamedInlineDeltaFormat("strike", strikethroughAttribution), + NamedInlineDeltaFormat("code", codeAttribution), + + // Inline attributes with parsed values. + ColorDeltaFormat(), + BackgroundColorDeltaFormat(), + ScriptDeltaFormat(), + FontFamilyDeltaFormat(), + SizeDeltaFormat(), + LinkDeltaFormat(), +]; + +/// The standard block-level embed formats that are parsed from Quill Deltas, +/// e.g., images, audio, video. +const defaultEmbedBockFormats = [ + ImageEmbedBlockDeltaFormat(), + VideoEmbedBlockDeltaFormat(), + AudioEmbedBlockDeltaFormat(), + FileEmbedBlockDeltaFormat(), +]; + +/// An extension on Quill Delta [Operation]s that adds the ability for an operation to +/// apply itself to a Super Editor document through an [Editor]. +extension OperationParser on Operation { + /// Applies this operation to a Super Editor document by sending requests + /// through the given [editor]. + /// + /// To configure how a given Quill Delta attribute impacts text blocks and text spans, + /// provide the desired [blockFormats] and [inlineFormats]. For example, the recognition + /// of an attribute called "bold", and the application of a [boldAttribution] to the + /// Super Editor document, is implemented by the [BlockDeltaFormat], which should be + /// included in [inlineFormats]. + void applyToDocument( + Editor editor, { + required List blockFormats, + List blockMergeRules = defaultBlockMergeRules, + required List inlineFormats, + required List inlineEmbedFormats, + required List embedBlockFormats, + }) { + final document = editor.context.find(Editor.documentKey); + final composer = editor.context.find(Editor.composerKey); + + switch (type) { + case DeltaOperationType.insert: + if (data is String) { + // This is a text insertion delta. + _doInsertText(editor, composer, blockFormats, inlineFormats); + } + if (data is Object) { + // This is an embed insertion delta. + _doInsertMedia(editor, composer, inlineEmbedFormats, embedBlockFormats); + } + + // Merge consecutive blocks as desired by the given node types. + final document = editor.context.find(Editor.documentKey); + if (document.nodeCount < 3) { + // Minimum of 3 nodes: block, block, newline. + break; + } + + // Beginning with the last non-empty node, move backwards, collecting all + // nodes that should be merged into one. + final nodeBeforeTrailingNewline = document.getNodeBefore(document.last)!; + final blockTypeToMerge = nodeBeforeTrailingNewline.getMetadataValue(NodeMetadata.blockType); + var blocksToMerge = []; + for (int i = document.nodeCount - 2; i >= 0; i -= 1) { + final node = document.getNodeAt(i)!; + if (node is! ParagraphNode) { + break; + } + + var shouldMerge = false; + for (final rule in blockMergeRules) { + final ruleShouldMerge = rule.shouldMerge(blockTypeToMerge, node.getMetadataValue(NodeMetadata.blockType)); + if (ruleShouldMerge == true) { + // The rule says we definitely want to merge. + shouldMerge = true; + break; + } + if (ruleShouldMerge == false) { + // The rule says we definitely don't want to merge. + shouldMerge = false; + break; + } + } + if (!shouldMerge) { + // Our merge rules don't want us to merge this node. + break; + } + + blocksToMerge.add(node); + } + + if (blocksToMerge.length < 2) { + break; + } + + blocksToMerge = blocksToMerge.reversed.toList(); + final mergeNode = blocksToMerge.first; + var nodeContentToMove = blocksToMerge[1].text.insertString(textToInsert: "\n", startOffset: 0); + for (int i = 2; i < blocksToMerge.length; i += 1) { + nodeContentToMove = + nodeContentToMove.copyAndAppend(blocksToMerge[i].text.insertString(textToInsert: "\n", startOffset: 0)); + } + + editor.execute([ + InsertAttributedTextRequest( + DocumentPosition(nodeId: mergeNode.id, nodePosition: mergeNode.endPosition), + nodeContentToMove, + ), + for (int i = 1; i < blocksToMerge.length; i += 1) // + DeleteNodeRequest(nodeId: blocksToMerge[i].id), + ]); + + case DeltaOperationType.retain: + final count = data as int; + final newPosition = _findPositionDownstream(document, composer, count); + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed(position: newPosition), + SelectionChangeType.pushCaret, + SelectionReason.contentChange, + ), + ]); + + case DeltaOperationType.delete: + final count = data as int; + final newPosition = _findPositionDownstream(document, composer, count); + editor.execute([ + DeleteContentRequest( + documentRange: DocumentRange( + start: composer.selection!.extent, + end: newPosition, + ), + ), + ]); + } + } + + void _doInsertText( + Editor editor, + DocumentComposer composer, + List blockFormats, + List inlineFormats, + ) { + final changeRequests = []; + + // Apply block attributes *before* inserting the new delta text. + // + // For example, consider the following deltas: + // + // ops: [ + // { insert: 'Welcome Header' }, + // { insert: '\n', attributes: { "header": 1 } }, + // { insert: 'This is the content' } + // ] + // + // Notice that the "header" attribute is included in the delta that follows + // the actual header text. That's the behavior we're implementing by applying + // block formats here *before* inserting any new text. + for (final blockFormat in blockFormats) { + final blockChanges = blockFormat.applyTo(this, editor); + if (blockChanges != null) { + changeRequests.addAll(blockChanges); + + // We found a format that handled this delta. Ignore the remaining + // formats. + // + // If a situation is found where multiple formats need to act on the same + // delta, please file an issue with an explanation. + break; + } + } + + // Insert new delta text and apply inline attributes. + // + // The process of inserting text also requires that we handle newline characters. + // Each newline needs to add a new paragraph. Newlines can appear at the beginning, + // the end, and in the middle of a single text insertion. + var text = data as String; + var currentNodeId = composer.selection!.extent.nodeId; + var currentTextPosition = composer.selection!.extent.nodePosition as TextNodePosition; + + // The included inline attributes apply to all text within this insert operation. + final inlineAttributions = {}; + for (final inlineFormat in inlineFormats) { + final attribution = inlineFormat.from(this); + if (attribution != null) { + inlineAttributions.add(attribution); + } + } + + // Break the insertion text at every newline so we can insert paragraphs. + final textPerLine = text.split("\n"); + for (int i = 0; i < textPerLine.length; i += 1) { + final line = textPerLine[i]; + final newNodeId = Editor.createNodeId(); + + changeRequests.addAll([ + // Insert a line of text. + InsertTextRequest( + documentPosition: DocumentPosition( + nodeId: currentNodeId, + nodePosition: currentTextPosition, + ), + textToInsert: line, + attributions: line.isNotEmpty ? inlineAttributions : {}, + ), + // Every line of text is followed by a newline, except the last + // line. If this isn't the last line, add a new paragraph. + if (i < textPerLine.length - 1) ...[ + InsertNodeAfterNodeRequest( + existingNodeId: currentNodeId, + newNode: ParagraphNode( + id: newNodeId, + text: AttributedText(""), + ), + ), + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.contentChange, + ), + ], + ]); + + if (i < textPerLine.length - 1) { + // This isn't the last line, so we added a new paragraph. Update the node ID. + currentNodeId = newNodeId; + currentTextPosition = const TextNodePosition(offset: 0); + } + } + + // Execute the block changes and inline text insertions. + editor.execute(changeRequests); + } + + void _doInsertMedia( + Editor editor, + DocumentComposer composer, + List inlineEmbedFormats, + List embedBlockFormats, + ) { + final content = data; + if (content is! Map) { + // Quill Deltas expect embeds to be a map, but the data isn't a map. + return; + } + + // First, try to interpret this operation as an inline embed and insert it. + final didInlineInsert = _maybeInsertInlineEmbed(editor, composer, inlineEmbedFormats, content); + if (didInlineInsert) { + return; + } + + // This operation wasn't a known inline embed. Try inserting as a block embed. + _maybeInsertBlockEmbed(editor, composer, embedBlockFormats); + } + + /// Attempts to interpret this operation as an inline embed and insert it, returning `true` + /// if successful, or `false` if this operation isn't a known inline embed. + bool _maybeInsertInlineEmbed( + Editor editor, + DocumentComposer composer, + List inlineEmbedFormats, + Map data, + ) { + for (final inlineEmbedFormat in inlineEmbedFormats) { + final didInsert = inlineEmbedFormat.insert(editor, composer, data); + if (didInsert) { + // We found a format that handled this inline embed. Ignore the remaining + // formats. + // + // If a situation is found where multiple formats need to act on the same + // embed, please file an issue with an explanation. + return true; + } + } + + return false; + } + + /// Attempts to interpret this operation as a block embed and insert it, returning `true` + /// if successful, or `false` if this operation isn't a known block embed. + bool _maybeInsertBlockEmbed( + Editor editor, + DocumentComposer composer, + List embedBlockFormats, + ) { + for (final embedBlockFormat in embedBlockFormats) { + final editorOperations = embedBlockFormat.applyTo(this, editor); + if (editorOperations == null) { + // This block format doesn't apply to this operation. Check the next one. + continue; + } + + // This block format parsed this operation and gave us a list of editor + // operations to insert the embed. Execute them and return. + editor.execute(editorOperations); + return true; + } + + // This operation wasn't recognized as a block-level embed and nothing was deserialized. + return false; + } + + /// Moves [count] units downstream from the current caret position. + /// + /// The distance of each unit in [count] is defined by the Delta spec. In text, a unit + /// is a character. In media, such as videos or images, a unit is the whole media item. + DocumentPosition _findPositionDownstream(Document document, DocumentComposer composer, int count) { + var caretPosition = composer.selection!.extent; + var selectedNode = document.getNodeById(caretPosition.nodeId)!; + int unitsToMove = count; + + while (unitsToMove > 0) { + if (selectedNode is TextNode) { + final currentPosition = caretPosition.nodePosition as TextNodePosition; + + if (selectedNode.text.length - currentPosition.offset >= unitsToMove) { + // The caret wants to move somewhere in this paragraph. Return that position. + return DocumentPosition( + nodeId: selectedNode.id, + nodePosition: TextNodePosition(offset: currentPosition.offset + unitsToMove), + ); + } + + // The caret wants to move beyond this paragraph. + unitsToMove -= selectedNode.text.length - currentPosition.offset; + selectedNode = document.getNodeAfterById(selectedNode.id)!; + caretPosition = DocumentPosition( + nodeId: selectedNode.id, + nodePosition: selectedNode.beginningPosition, + ); + } else { + // This is a block node. The caret either sits on the upstream side or + // the downstream side. + if (unitsToMove == 1) { + // The deltas want to move across this block to the downstream side. + // Return that position. + return DocumentPosition( + nodeId: selectedNode.id, + nodePosition: selectedNode.endPosition, + ); + } + + // The deltas want to retain more beyond this node. + unitsToMove -= 1; + selectedNode = document.getNodeAfterById(selectedNode.id)!; + caretPosition = DocumentPosition( + nodeId: selectedNode.id, + nodePosition: selectedNode.beginningPosition, + ); + } + } + + return caretPosition; + } + + /// Returns the [DeltaOperationType] of this operation. + /// + /// The [DeltaOperationType] is provided so that developers can use a `switch` + /// statement to handle all operation types, rather than repeated if-statements + /// on [isInsert], [isRetain], and [isDelete]. + DeltaOperationType get type { + if (isInsert) { + return DeltaOperationType.insert; + } else if (isRetain) { + return DeltaOperationType.retain; + } else if (isDelete) { + return DeltaOperationType.delete; + } else { + throw Exception("Unknown operation type: $this"); + } + } +} + +enum DeltaOperationType { + insert, + retain, + delete, +} + +/// The standard set of [DeltaBlockMergeRule]s used when parsing Quill Deltas. +const defaultBlockMergeRules = [ + MergeBlock(blockquoteAttribution), + MergeBlock(codeAttribution), +]; + +/// A rule that decides whether a given [DocumentNode] should be merged into +/// the node before it, when creating a [Document] from Quill Deltas. +/// +/// This is useful, for example, to place multiple lines of code within a +/// single code block. +abstract interface class DeltaBlockMergeRule { + /// Returns `true` if two consecutive blocks with the given types should merge, + /// `false` if they shouldn't, or `null` if this rule has no opinion about the merge. + bool? shouldMerge(Attribution block1, Attribution block2); +} + +/// A [DeltaBlockMergeRule] that chooses to merge blocks whose type `==` +/// the given block type. +class MergeBlock implements DeltaBlockMergeRule { + const MergeBlock(this._blockType); + + final Attribution _blockType; + + @override + bool? shouldMerge(Attribution block1, Attribution block2) { + if (block1 == _blockType && block2 == _blockType) { + // Yes, try to merge them. + return true; + } + + // This isn't our block type. We don't have an opinion. + return null; + } +} diff --git a/super_editor/lib/src/infrastructure/serialization/quill/serializing/serializers.dart b/super_editor/lib/src/infrastructure/serialization/quill/serializing/serializers.dart new file mode 100644 index 0000000000..fe645eb266 --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/quill/serializing/serializers.dart @@ -0,0 +1,524 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:collection/collection.dart'; +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:flutter/foundation.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/image.dart'; +import 'package:super_editor/src/default_editor/list_items.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/serialization/quill/content/formatting.dart'; +import 'package:super_editor/src/infrastructure/serialization/quill/content/multimedia.dart'; + +/// A [DeltaSerializer] that serializes [ParagraphNode]s into deltas. +const paragraphDeltaSerializer = ParagraphDeltaSerializer(); + +class ParagraphDeltaSerializer extends TextBlockDeltaSerializer { + const ParagraphDeltaSerializer({ + super.inlineEmbedDeltaSerializers = const [], + }); + + @override + bool shouldSerialize(DocumentNode node) => node is ParagraphNode; + + @override + Map getBlockFormats(TextNode textBlock) { + if (textBlock is! ParagraphNode) { + // This shouldn't happen, but we do a sane thing if it does. + return super.getBlockFormats(textBlock); + } + + final formats = super.getBlockFormats(textBlock); + if (textBlock.indent != 0) { + formats["indent"] = textBlock.indent; + } + return formats; + } +} + +/// A [DataSerializer] that serializes [ListItemNode]s into deltas. +const listItemDeltaSerializer = ListItemDeltaSerializer(); + +class ListItemDeltaSerializer extends TextBlockDeltaSerializer { + const ListItemDeltaSerializer({ + super.inlineEmbedDeltaSerializers = const [], + }); + + @override + bool shouldSerialize(DocumentNode node) => node is ListItemNode; + + @override + Map getBlockFormats(TextNode textBlock) { + if (textBlock is! ListItemNode) { + // This shouldn't happen, but we do a sane thing if it does. + return super.getBlockFormats(textBlock); + } + + final formats = super.getBlockFormats(textBlock); + switch (textBlock.type) { + case ListItemType.ordered: + formats["list"] = "ordered"; + case ListItemType.unordered: + formats["list"] = "bullet"; + } + return formats; + } +} + +/// A [DeltaSerializer] that serializes [TaskNode]s into deltas. +const taskDeltaSerializer = TaskDeltaSerializer(); + +class TaskDeltaSerializer extends TextBlockDeltaSerializer { + const TaskDeltaSerializer({ + super.inlineEmbedDeltaSerializers = const [], + }); + + @override + bool shouldSerialize(DocumentNode node) => node is TaskNode; + + @override + Map getBlockFormats(TextNode textBlock) { + if (textBlock is! TaskNode) { + // This shouldn't happen, but we do a sane thing if it does. + return super.getBlockFormats(textBlock); + } + + final formats = super.getBlockFormats(textBlock); + formats["list"] = textBlock.isComplete ? "checked" : "unchecked"; + return formats; + } +} + +/// A [DeltaSerializer] that serializes [ImageNode]s into deltas. +const imageDeltaSerializer = FunctionalDeltaSerializer(_serializeImage); +bool _serializeImage(DocumentNode node, Delta deltas) { + if (node is! ImageNode) { + return false; + } + + deltas.operations.add( + Operation.insert({ + "image": node.imageUrl, + }), + ); + + return true; +} + +/// A [DeltaSerializer] that serializes [VideoNode]s into deltas. +const videoDeltaSerializer = FunctionalDeltaSerializer(_serializeVideo); +bool _serializeVideo(DocumentNode node, Delta deltas) { + if (node is! VideoNode) { + return false; + } + + deltas.operations.add( + Operation.insert({ + "video": node.url, + }), + ); + + return true; +} + +/// A [DeltaSerializer] that serializes [AudioNode]s to deltas. +const audioDeltaSerializer = FunctionalDeltaSerializer(_serializeAudio); +bool _serializeAudio(DocumentNode node, Delta deltas) { + if (node is! AudioNode) { + return false; + } + + deltas.operations.add( + Operation.insert({ + "audio": node.url, + }), + ); + + return true; +} + +/// A [DeltaSerializer] that serializes [FileNode]s into deltas. +const fileDeltaSerializer = FunctionalDeltaSerializer(_serializeFile); +bool _serializeFile(DocumentNode node, Delta deltas) { + if (node is! FileNode) { + return false; + } + + deltas.operations.add( + Operation.insert({ + "file": node.url, + }), + ); + + return true; +} + +/// A [DeltaSerializer] that includes standard Quill Delta rules for +/// serializing text blocks, e.g., paragraphs, lists, and tasks. +class TextBlockDeltaSerializer implements DeltaSerializer { + const TextBlockDeltaSerializer({ + this.inlineEmbedDeltaSerializers = const [], + }); + + final List inlineEmbedDeltaSerializers; + + @override + bool serialize(DocumentNode node, Delta deltas) { + if (!shouldSerialize(node)) { + return false; + } + final textBlock = node as TextNode; + + final blockFormats = getBlockFormats(textBlock); + + final textByLine = textBlock.text.split("\n"); + for (int i = 0; i < textByLine.length; i += 1) { + _serializeLine(deltas, blockFormats, inlineEmbedDeltaSerializers, textByLine[i]); + } + + return true; + } + + void _serializeLine( + Delta deltas, + Map blockFormats, + List inlineEmbedDeltaSerializers, + AttributedText line, + ) { + var spans = line.computeAttributionSpans().toList(); + if (spans.isEmpty) { + // The text is empty. Inject a span so that our loop below doesn't + // violate list bounds. + spans = [const MultiAttributionSpan(attributions: {}, start: 0, end: 0)]; + } + + for (int i = 0; i < spans.length; i += 1) { + final span = spans[i]; + final spanText = line.copyText(span.start, line.isNotEmpty ? span.end + 1 : span.end); + final spanPlainText = line.toPlainText().substring(span.start, line.isNotEmpty ? span.end + 1 : span.end); + + // Attempt to serialize this text span as an inline embed. + bool didSerializeAsInlineEmbed = false; + for (final inlineEmbedSerializer in inlineEmbedDeltaSerializers) { + didSerializeAsInlineEmbed = inlineEmbedSerializer.serializeText(spanPlainText, span.attributions, deltas); + if (didSerializeAsInlineEmbed) { + // This span was successfully serialized as an inline embed. Skip remaining + // inline embed serializers. + break; + } + } + if (didSerializeAsInlineEmbed) { + // This span was successfully interpreted as an inline embed and serialized. + // Move to the next span. + continue; + } + + // This span doesn't refer to an inline embed - it's just inline text with some styles. + // Serialize the text and styles. + final placeholderIndices = spanText.placeholders.keys.toList(); + final textRunsAndPlaceholders = []; + int start = 0; + for (final placeholderIndex in placeholderIndices) { + if (placeholderIndex >= spanText.length) { + continue; + } + + final textRun = spanText.substring(start, placeholderIndex); + if (textRun.isNotEmpty) { + textRunsAndPlaceholders.add(textRun); + } + textRunsAndPlaceholders.add(spanText.placeholders[placeholderIndex]!); + + start = placeholderIndex + 1; + } + if (start != spanText.length) { + textRunsAndPlaceholders.add(spanText.substring(start)); + } + + final inlineAttributes = getInlineAttributesFor(span.attributions); + for (final item in textRunsAndPlaceholders) { + if (item is! String) { + // This is an inline placeholder. Try to embed it. + for (final inlineSerializer in inlineEmbedDeltaSerializers) { + final didSerialize = inlineSerializer.serializeInlinePlaceholder(item, inlineAttributes, deltas); + if (didSerialize) { + // We successfully serialized the placeholder. We're done with this item. + continue; + } + } + + // We failed to serialize this placeholder. Ignore it and continue + // processing items. + continue; + } + + // This is a text run. + final newDelta = Operation.insert( + item, + inlineAttributes.isNotEmpty ? inlineAttributes : null, + ); + + final previousDelta = deltas.operations.lastOrNull; + if (previousDelta != null && !previousDelta.hasBlockFormats && newDelta.canMergeWith(previousDelta)) { + deltas.operations[deltas.operations.length - 1] = newDelta.mergeWith(previousDelta); + continue; + } + + deltas.operations.add(newDelta); + } + } + + if (line.isNotEmpty && line.last == "\n") { + // There's already a trailing newline. No need to add another one. + return; + } + + // We didn't have a natural trailing newline. Insert a newline as per the + // Delta spec. + final newlineDelta = Operation.insert("\n", blockFormats.isNotEmpty ? blockFormats : null); + final previousDelta = deltas.operations.isNotEmpty ? deltas.operations[deltas.operations.length - 1] : null; + if (previousDelta != null && newlineDelta.canMergeWith(previousDelta)) { + deltas.operations[deltas.operations.length - 1] = newlineDelta.mergeWith(previousDelta); + } else { + deltas.operations.add(newlineDelta); + } + } + + @protected + bool shouldSerialize(DocumentNode node) { + return node is TextNode; + } + + /// Given the [textBlock], decides what combination of block-level attributes + /// should be applied to the Quill Delta for this text block. + @protected + Map getBlockFormats(TextNode textBlock) { + final blockAttributes = {}; + + // Add all the block-level formats that aren't mutually exclusive. + if (textBlock.metadata["textAlign"] != null) { + blockAttributes["align"] = textBlock.metadata["textAlign"]; + } + + final blockType = textBlock.metadata["blockType"] as Attribution?; + if (blockType == null) { + return blockAttributes; + } + + // Add the mutually exclusive block format. + switch (blockType) { + case header1Attribution: + blockAttributes["header"] = 1; + case header2Attribution: + blockAttributes["header"] = 2; + case header3Attribution: + blockAttributes["header"] = 3; + case header4Attribution: + blockAttributes["header"] = 4; + case header5Attribution: + blockAttributes["header"] = 5; + case header6Attribution: + blockAttributes["header"] = 6; + case blockquoteAttribution: + blockAttributes["blockquote"] = true; + case codeAttribution: + blockAttributes["code-block"] = "plain"; + } + + return blockAttributes; + } + + /// Given a set of [superEditorAttributions], serializes those into Quill Delta + /// inline text attributes, returning all attributes in a map that should be set as + /// the "attributes" in an insertion delta. + @protected + Map getInlineAttributesFor(Set superEditorAttributions) { + final attributes = {}; + + for (final attribution in superEditorAttributions) { + if (attribution == boldAttribution) { + attributes["bold"] = true; + continue; + } + if (attribution == italicsAttribution) { + attributes["italic"] = true; + continue; + } + if (attribution == strikethroughAttribution) { + attributes["strike"] = true; + continue; + } + if (attribution == underlineAttribution) { + attributes["underline"] = true; + continue; + } + if (attribution == superscriptAttribution) { + attributes["script"] = "super"; + continue; + } + if (attribution == subscriptAttribution) { + attributes["script"] = "sub"; + continue; + } + if (attribution is ColorAttribution) { + attributes["color"] = "#${attribution.color.value.toRadixString(16).substring(2)}"; + continue; + } + if (attribution is BackgroundColorAttribution) { + attributes["background"] = "#${attribution.color.value.toRadixString(16).substring(2)}"; + continue; + } + if (attribution is FontFamilyAttribution) { + attributes["font"] = attribution.fontFamily; + continue; + } + if (attribution is NamedFontSizeAttribution) { + attributes["size"] = attribution.fontSizeName; + continue; + } + if (attribution is FontSizeAttribution) { + attributes["size"] = attribution.fontSize; + continue; + } + if (attribution is LinkAttribution) { + attributes["link"] = attribution.plainTextUri; + continue; + } + } + + return attributes; + } +} + +// TODO: Move to AttributedText +extension Split on AttributedText { + List split(String pattern) { + final segments = []; + int segmentStart = 0; + int searchIndex = 0; + // FIXME: This doesn't account for space taken up by placeholders + final plainText = toPlainText(); + + int patternIndex = plainText.indexOf(pattern, searchIndex); + while (patternIndex >= 0) { + segments.add(copyText(segmentStart, patternIndex)); + segmentStart = patternIndex + pattern.length; + searchIndex = segmentStart; + + patternIndex = plainText.indexOf(pattern, searchIndex); + } + + // Copy the final segment that appears after the last instance of the pattern. + segments.add(copyText(segmentStart, length)); + + return segments; + } +} + +/// A [DeltaSerializer] that forwards to a given delegate function. +class FunctionalDeltaSerializer implements DeltaSerializer { + const FunctionalDeltaSerializer(this._delegate); + + final DeltaSerializerDelegate _delegate; + + @override + bool serialize(DocumentNode node, Delta deltas) => _delegate(node, deltas); +} + +typedef DeltaSerializerDelegate = bool Function(DocumentNode node, Delta deltas); + +/// Serializes some part of a [MutableDocument] to a Quill Delta document. +/// +/// For example, a [DeltaSerializer] might serialize a [ParagraphNode], or +/// an [ImageNode]. +abstract interface class DeltaSerializer { + /// Tries to serialize the given [DocumentNode] into the given [deltas], + /// returning `true` if this serializer was able to serialize the [node], + /// or `false` if this serializer wasn't made to serialize this kind of [node]. + /// + /// For example, serializing a [ParagraphNode], or an [ImageNode], into + /// an insertion operation. + bool serialize(DocumentNode node, Delta deltas); +} + +/// Serializes pieces of text to Quill Deltas. +abstract interface class InlineEmbedDeltaSerializer { + /// Tries to serialize the given [text] into the given [deltas]. + /// + /// If this serializer doesn't apply to the given [text], nothing happens. + bool serializeText(String text, Set attributions, Delta deltas); + + /// Tries to serialize the given inline [placeholder] into the given [deltas]. + /// + /// If this serialize doesn't apply to the given [placeholder], nothing happens. + bool serializeInlinePlaceholder(Object placeholder, Map attributes, Delta deltas); +} + +extension DeltaSerialization on Operation { + // TODO: make this query extensible + bool get hasBlockFormats { + const blockFormats = { + 'header', + 'blockquote', + 'code-block', + }; + + if (attributes == null || attributes!.isEmpty) { + return false; + } + + final formats = attributes!.keys; + for (final blockFormat in blockFormats) { + if (formats.contains(blockFormat)) { + return true; + } + } + + return false; + } + + bool canMergeWith(Operation previousDelta) { + if (!isInsert) { + // We've only implement this for insertions, for now. + // TODO: Add support for retain/delete. + return false; + } + + if (value is! String || previousDelta.value is! String) { + // One or both of the deltas aren't text. Only text can be merged. + return false; + } + + // If the attributes are equivalent then we can merge the text deltas. + if (const DeepCollectionEquality().equals(previousDelta.attributes, attributes)) { + return true; + } + if (previousDelta.attributes == null && attributes!.isEmpty) { + return true; + } + if (attributes == null && previousDelta.attributes!.isEmpty) { + return true; + } + + // There's a difference in the attributes. We need separate deltas. + return false; + } + + Operation mergeWith(Operation previousDelta) { + if (!canMergeWith(previousDelta)) { + throw Exception( + "Tried to merge two deltas that can't be merged. Previous delta: $previousDelta. Next delta: $this"); + } + + return Operation.insert( + "${previousDelta.value as String}${value as String}", + previousDelta.attributes, + ); + } +} + +extension NewlineCharacter on String { + String toNewlineString() => toString().replaceAll("\n", "⏎"); +} diff --git a/super_editor/lib/src/infrastructure/serialization/quill/serializing/serializing.dart b/super_editor/lib/src/infrastructure/serialization/quill/serializing/serializing.dart new file mode 100644 index 0000000000..7bc10940ab --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/quill/serializing/serializing.dart @@ -0,0 +1,59 @@ +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/infrastructure/serialization/quill/serializing/serializers.dart'; + +/// Extensions on [MutableDocument] for serializing a [MutableDocument] +/// to a Quill Delta document. +extension QuillDelta on MutableDocument { + /// Serializes this [MutableDocument] to a Quill [Delta] document. + /// + /// The [MutableDocument] is converted to deltas, node-by-node. For + /// each type of [DocumentNode] there is a [DeltaSerializer]. The + /// serializers that are used to convert [DocumentNode]s into deltas + /// can be configured by providing a custom list of [serializers]. + Delta toQuillDeltas({ + List serializers = defaultDeltaSerializers, + }) { + final deltaDocument = Delta(); + + for (final node in this) { + if (node is ParagraphNode && node == last && node.text.isEmpty && nodeCount > 1) { + // This final, empty paragraph in the document represents the final + // newline "\n" in the Delta document. But, due to how we serialize + // deltas, the node/delta before this one already inserted a newline, + // so we don't need to do anything with this empty node. Ignore it. + continue; + } + + // Try out each serializer until we find one that successfully serializes + // this document node. + bool didSerialize = false; + for (int i = 0; i < serializers.length; i += 1) { + didSerialize = serializers[i].serialize(node, deltaDocument); + if (didSerialize) { + // Go to next Super Editor document node. + break; + } + } + + if (!didSerialize) { + throw Exception("Failed to serialize Document to Quill Deltas. Couldn't find a serializer for node: $node"); + } + } + + return deltaDocument; + } +} + +/// The serializers the are used by default to convert a Super Editor [Document] to a +/// Quill [Delta] document. +const defaultDeltaSerializers = [ + paragraphDeltaSerializer, + listItemDeltaSerializer, + taskDeltaSerializer, + imageDeltaSerializer, + videoDeltaSerializer, + audioDeltaSerializer, + fileDeltaSerializer, +]; diff --git a/super_editor/lib/src/infrastructure/serialization/quill/testing/quill_delta_comparison.dart b/super_editor/lib/src/infrastructure/serialization/quill/testing/quill_delta_comparison.dart new file mode 100644 index 0000000000..0fa83fd9bd --- /dev/null +++ b/super_editor/lib/src/infrastructure/serialization/quill/testing/quill_delta_comparison.dart @@ -0,0 +1,121 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:matcher/matcher.dart'; +import 'package:text_table/text_table.dart'; + +/// Creates a [Matcher] that compares an actual Quill [Delta] document +/// to the given [expectedDocument]. +Matcher quillDocumentEquivalentTo(Delta expectedDocument) => EquivalentQuillDocumentMatcher(expectedDocument); + +class EquivalentQuillDocumentMatcher extends Matcher { + const EquivalentQuillDocumentMatcher(this._expectedDocument); + + final Delta _expectedDocument; + + @override + Description describe(Description description) { + return description.add("a Quill document that looks like:\n$_expectedDocument"); + } + + @override + bool matches(covariant Object item, Map matchState) { + return _calculateMismatchReason(item, matchState) == null; + } + + @override + Description describeMismatch( + covariant Object item, + Description mismatchDescription, + Map matchState, + bool verbose, + ) { + final mismatchReason = _calculateMismatchReason(item, matchState); + if (mismatchReason != null) { + mismatchDescription.add(mismatchReason); + } + return mismatchDescription; + } + + String? _calculateMismatchReason( + Object target, + Map matchState, + ) { + if (target is! Delta) { + return "the given document isn't a Delta document: $target"; + } + final actualDocument = target; + + final messages = []; + bool nodeCountMismatch = false; + bool nodeTypeOrContentMismatch = false; + + if (_expectedDocument.operations.length != actualDocument.operations.length) { + messages.add( + "expected ${_expectedDocument.operations.length} document operations but found ${actualDocument.operations.length}"); + nodeCountMismatch = true; + } else { + messages.add("documents have the same number of operations"); + } + + final maxOpCount = max(_expectedDocument.operations.length, actualDocument.operations.length); + final opComparisons = List.generate(maxOpCount, (index) => ["", "", " "]); + for (int i = 0; i < maxOpCount; i += 1) { + if (i < _expectedDocument.operations.length && i < actualDocument.operations.length) { + opComparisons[i][0] = _expectedDocument.operations[i].describe(); + opComparisons[i][1] = actualDocument.operations[i].describe(); + + if (_expectedDocument.operations[i].runtimeType != actualDocument.operations[i].runtimeType) { + opComparisons[i][2] = "Wrong Type"; + nodeTypeOrContentMismatch = true; + } else if (_expectedDocument.operations[i] != actualDocument.operations[i]) { + if (_expectedDocument.operations[i].value != actualDocument.operations[i].value) { + opComparisons[i][2] = "Different value"; + } else { + opComparisons[i][2] = + "Different attributes - Expected: ${_expectedDocument.operations[i].attributes}, Actual: ${actualDocument.operations[i].value}"; + } + nodeTypeOrContentMismatch = true; + } else if (!const DeepCollectionEquality() + .equals(_expectedDocument.operations[i].attributes, actualDocument.operations[i].attributes)) { + opComparisons[i][2] = "Different attributes"; + nodeTypeOrContentMismatch = true; + } + } else if (i < _expectedDocument.operations.length) { + opComparisons[i][0] = _expectedDocument.operations[i].describe(); + opComparisons[i][1] = "NA"; + opComparisons[i][2] = "Missing Node"; + } else if (i < actualDocument.operations.length) { + opComparisons[i][0] = "NA"; + opComparisons[i][1] = actualDocument.operations[i].describe(); + opComparisons[i][2] = "Missing Node"; + } + } + + if (nodeCountMismatch || nodeTypeOrContentMismatch) { + String messagesList = messages.join(", "); + messagesList += "\n"; + messagesList += const TableRenderer().render(opComparisons, columns: ["Expected", "Actual", "Difference"]); + return messagesList; + } + + return null; + } +} + +extension on Operation { + String describe() { + final writtenValue = + "${value.toString().replaceAll("\n", "⏎")}, Atts: ${attributes?.entries.map((entry) => "${entry.key}: ${entry.value}").join(", ") ?? "None"}"; + if (isInsert) { + return "Insert: $writtenValue"; + } else if (isRetain) { + return "Retain: $writtenValue"; + } else if (isDelete) { + return "Delete: $writtenValue"; + } + + throw Exception("Unknown operation type: $this"); + } +} diff --git a/super_editor/lib/src/infrastructure/signal_notifier.dart b/super_editor/lib/src/infrastructure/signal_notifier.dart new file mode 100644 index 0000000000..239e662504 --- /dev/null +++ b/super_editor/lib/src/infrastructure/signal_notifier.dart @@ -0,0 +1,14 @@ +import 'package:flutter/foundation.dart'; + +/// A [ChangeNotifier] that allows clients to send a change signal to listeners. +/// +/// The difference between [SignalNotifier] and other listenables, like `ValueNotifier` +/// is that [SignalNotifier] doesn't hold a value, it only notifies listener that +/// something has changed. +class SignalNotifier extends ChangeNotifier { + @override + // ignore: unnecessary_overrides + void notifyListeners() { + super.notifyListeners(); + } +} diff --git a/super_editor/lib/src/infrastructure/sliver_hybrid_stack.dart b/super_editor/lib/src/infrastructure/sliver_hybrid_stack.dart new file mode 100644 index 0000000000..cf5f85c17c --- /dev/null +++ b/super_editor/lib/src/infrastructure/sliver_hybrid_stack.dart @@ -0,0 +1,206 @@ +import "package:flutter/rendering.dart"; +import "package:flutter/widgets.dart"; + +/// Component that allows mixing RenderSliver child with other RenderBox +/// children. The RenderSliver child will be laid out first, and then the +/// RenderBox children will be laid out to cover the entire scroll extent +/// of the RenderSliver child. +class SliverHybridStack extends MultiChildRenderObjectWidget { + /// Creates a SliverHybridStack. The [children] must contain exactly one + /// child that a RenderSliver, and zero or more RenderBox children. + /// The [fillViewport] flag controls whether the RenderBox children should + /// be stretched if necessary to fill the entire viewport. + const SliverHybridStack({ + super.key, + this.fillViewport = false, + super.children, + }); + + final bool fillViewport; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSliverHybridStack(fillViewport: fillViewport); + } + + @override + void updateRenderObject(BuildContext context, covariant RenderSliver renderObject) { + (renderObject as _RenderSliverHybridStack).fillViewport = fillViewport; + } +} + +class _ChildParentData extends SliverLogicalParentData with ContainerParentDataMixin {} + +class _RenderSliverHybridStack extends RenderSliver + with ContainerRenderObjectMixin>, RenderSliverHelpers { + _RenderSliverHybridStack({required this.fillViewport}); + + bool fillViewport; + + @override + void performLayout() { + RenderSliver? sliver; + var child = firstChild; + while (child != null) { + if (child is RenderSliver) { + assert(sliver == null, "There can only be one sliver in a SliverHybridStack"); + sliver = child; + break; + } + child = childAfter(child); + } + if (sliver == null) { + geometry = SliverGeometry.zero; + return; + } + + (sliver.parentData! as SliverLogicalParentData).layoutOffset = 0.0; + sliver.layout(constraints, parentUsesSize: true); + final SliverGeometry sliverLayoutGeometry = sliver.geometry!; + if (sliverLayoutGeometry.scrollOffsetCorrection != null) { + geometry = SliverGeometry( + scrollOffsetCorrection: sliverLayoutGeometry.scrollOffsetCorrection, + ); + return; + } + + geometry = SliverGeometry( + scrollExtent: sliverLayoutGeometry.scrollExtent, + paintExtent: sliverLayoutGeometry.paintExtent, + maxPaintExtent: sliverLayoutGeometry.maxPaintExtent, + maxScrollObstructionExtent: sliverLayoutGeometry.maxScrollObstructionExtent, + cacheExtent: sliverLayoutGeometry.cacheExtent, + hasVisualOverflow: sliverLayoutGeometry.hasVisualOverflow, + ); + + final boxConstraints = ScrollingBoxConstraints( + minWidth: constraints.crossAxisExtent, + maxWidth: constraints.crossAxisExtent, + minHeight: sliverLayoutGeometry.scrollExtent, + maxHeight: sliverLayoutGeometry.scrollExtent, + scrollOffset: constraints.scrollOffset, + ); + + child = firstChild; + while (child != null) { + if (child is RenderBox) { + final childParentData = child.parentData! as SliverLogicalParentData; + childParentData.layoutOffset = -constraints.scrollOffset; + if (constraints.scrollOffset == 0.0 && constraints.viewportMainAxisExtent.isFinite && fillViewport) { + child.layout( + BoxConstraints.tightFor( + width: constraints.crossAxisExtent, + height: constraints.viewportMainAxisExtent, + ), + parentUsesSize: true, + ); + } else { + child.layout(boxConstraints, parentUsesSize: true); + } + } + child = childAfter(child); + } + } + + @override + bool hitTest(SliverHitTestResult result, {required double mainAxisPosition, required double crossAxisPosition}) { + if (mainAxisPosition >= 0.0 && crossAxisPosition >= 0.0 && crossAxisPosition < constraints.crossAxisExtent) { + if (hitTestChildren(result, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition) || + hitTestSelf(mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition)) { + result.add(SliverHitTestEntry( + this, + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + )); + return true; + } + } + return false; + } + + @override + bool hitTestChildren( + SliverHitTestResult result, { + required double mainAxisPosition, + required double crossAxisPosition, + }) { + var child = lastChild; + while (child != null) { + if (child is RenderSliver) { + final isHit = child.hitTest( + result, + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + ); + if (isHit) { + return true; + } + } else if (child is RenderBox) { + final boxResult = BoxHitTestResult.wrap(result); + final isHit = hitTestBoxChild( + boxResult, + child, + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + ); + if (isHit) { + return true; + } + } + child = childBefore(child); + } + return false; + } + + @override + void setupParentData(covariant RenderObject child) { + child.parentData = _ChildParentData(); + } + + @override + void paint(PaintingContext context, Offset offset) { + var child = firstChild; + while (child != null) { + final childParentData = child.parentData! as SliverLogicalParentData; + context.paintChild( + child, + offset + Offset(0, childParentData.layoutOffset!), + ); + child = childAfter(child); + } + } + + @override + void applyPaintTransform(covariant RenderObject child, Matrix4 transform) { + final childParentData = child.parentData! as SliverLogicalParentData; + transform.translate(0.0, childParentData.layoutOffset!); + } + + @override + double childMainAxisPosition(covariant RenderObject child) { + final childParentData = child.parentData! as SliverLogicalParentData; + return childParentData.layoutOffset!; + } +} + +// Box constraints that will cause relayout when the scroll offset changes. +class ScrollingBoxConstraints extends BoxConstraints { + const ScrollingBoxConstraints({ + super.minWidth, + super.maxWidth, + super.minHeight, + super.maxHeight, + required this.scrollOffset, + }); + + final double scrollOffset; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ScrollingBoxConstraints && super == other && scrollOffset == other.scrollOffset; + } + + @override + int get hashCode => Object.hash(super.hashCode, scrollOffset); +} diff --git a/super_editor/lib/src/infrastructure/strings.dart b/super_editor/lib/src/infrastructure/strings.dart index f7d1e88cf4..1db1433424 100644 --- a/super_editor/lib/src/infrastructure/strings.dart +++ b/super_editor/lib/src/infrastructure/strings.dart @@ -142,3 +142,10 @@ extension CharacterMovement on String { return range.current.length; } } + +/// Characters that are difficult to represent literally, and therefore are provided as constants. +class SpecialCharacters { + static const emDash = '—'; + + const SpecialCharacters._(); +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/android/android_textfield.dart b/super_editor/lib/src/infrastructure/super_textfield/android/android_textfield.dart deleted file mode 100644 index f2ed94d99b..0000000000 --- a/super_editor/lib/src/infrastructure/super_textfield/android/android_textfield.dart +++ /dev/null @@ -1,581 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; -import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'package:super_editor/src/infrastructure/focus.dart'; -import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/android/_editing_controls.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/android/_user_interaction.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/fill_width_if_constrained.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/hint_text.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/text_scrollview.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; -import 'package:super_text_layout/super_text_layout.dart'; - -import '../../_logging.dart'; -import '../metrics.dart'; -import '../styles.dart'; -import 'android_textfield.dart'; - -export '../../platforms/android/selection_handles.dart'; -export '../../platforms/android/toolbar.dart'; -export '_caret.dart'; - -final _log = androidTextFieldLog; - -class SuperAndroidTextField extends StatefulWidget { - const SuperAndroidTextField({ - Key? key, - this.focusNode, - this.textController, - this.textAlign = TextAlign.left, - this.textStyleBuilder = defaultTextFieldStyleBuilder, - this.hintBehavior = HintBehavior.displayHintUntilFocus, - this.hintBuilder, - this.minLines, - this.maxLines = 1, - this.lineHeight, - required this.caretColor, - required this.selectionColor, - required this.handlesColor, - this.textInputAction = TextInputAction.done, - this.popoverToolbarBuilder = _defaultAndroidToolbarBuilder, - this.showDebugPaint = false, - this.padding, - }) : super(key: key); - - /// [FocusNode] attached to this text field. - final FocusNode? focusNode; - - /// Controller that owns the text content and text selection for - /// this text field. - final ImeAttributedTextEditingController? textController; - - /// The alignment to use for text in this text field. - final TextAlign textAlign; - - /// Text style factory that creates styles for the content in - /// [textController] based on the attributions in that content. - final AttributionStyleBuilder textStyleBuilder; - - /// Policy for when the hint should be displayed. - final HintBehavior hintBehavior; - - /// Builder that creates the hint widget, when a hint is displayed. - /// - /// To easily build a hint with styled text, see [StyledHintBuilder]. - final WidgetBuilder? hintBuilder; - - /// Color of the caret. - final Color caretColor; - - /// Color of the selection rectangle for selected text. - final Color selectionColor; - - /// Color of the selection handles. - final Color handlesColor; - - /// The minimum height of this text field, represented as a - /// line count. - /// - /// If [minLines] is non-null and greater than `1`, [lineHeight] - /// must also be provided because there is no guarantee that all - /// lines of text have the same height. - /// - /// See also: - /// - /// * [maxLines] - /// * [lineHeight] - final int? minLines; - - /// The maximum height of this text field, represented as a - /// line count. - /// - /// If text exceeds the maximum line height, scrolling dynamics - /// are added to accommodate the overflowing text. - /// - /// If [maxLines] is non-null and greater than `1`, [lineHeight] - /// must also be provided because there is no guarantee that all - /// lines of text have the same height. - /// - /// See also: - /// - /// * [minLines] - /// * [lineHeight] - final int? maxLines; - - /// The height of a single line of text in this text scroll view, used - /// with [minLines] and [maxLines] to size the text field. - /// - /// If a [lineHeight] is provided, the text field viewport is sized as a - /// multiple of that [lineHeight]. If no [lineHeight] is provided, the - /// text field viewport is sized as a multiple of the line-height of the - /// first line of text. - final double? lineHeight; - - /// The type of action associated with the action button on the mobile - /// keyboard. - final TextInputAction textInputAction; - - /// Whether to paint debug guides. - final bool showDebugPaint; - - /// Builder that creates the popover toolbar widget that appears when text is selected. - final Widget Function(BuildContext, AndroidEditingOverlayController) popoverToolbarBuilder; - - /// Padding placed around the text content of this text field, but within the - /// scrollable viewport. - final EdgeInsets? padding; - - @override - State createState() => SuperAndroidTextFieldState(); -} - -class SuperAndroidTextFieldState extends State - with TickerProviderStateMixin, WidgetsBindingObserver - implements ProseTextBlock, ImeInputOwner { - static const Duration _autoScrollAnimationDuration = Duration(milliseconds: 100); - static const Curve _autoScrollAnimationCurve = Curves.fastOutSlowIn; - - final _textFieldKey = GlobalKey(); - final _textFieldLayerLink = LayerLink(); - final _textContentLayerLink = LayerLink(); - final _scrollKey = GlobalKey(); - final _textContentKey = GlobalKey(); - - late FocusNode _focusNode; - - late ImeAttributedTextEditingController _textEditingController; - - final _magnifierLayerLink = LayerLink(); - late AndroidEditingOverlayController _editingOverlayController; - - late TextScrollController _textScrollController; - - // OverlayEntry that displays the toolbar and magnifier, and - // positions the invisible touch targets for base/extent - // dragging. - OverlayEntry? _controlsOverlayEntry; - - @override - void initState() { - super.initState(); - _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); - - _textEditingController = (widget.textController ?? ImeAttributedTextEditingController()) - ..addListener(_onTextOrSelectionChange) - ..onPerformActionPressed ??= _onPerformActionPressed; - - _textScrollController = TextScrollController( - textController: _textEditingController, - tickerProvider: this, - )..addListener(_onTextScrollChange); - - _editingOverlayController = AndroidEditingOverlayController( - textController: _textEditingController, - magnifierFocalPoint: _magnifierLayerLink, - ); - - WidgetsBinding.instance.addObserver(this); - } - - @override - void didUpdateWidget(SuperAndroidTextField oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.focusNode != oldWidget.focusNode) { - _focusNode.removeListener(_onFocusChange); - _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); - } - - if (widget.textInputAction != oldWidget.textInputAction && _textEditingController.isAttachedToIme) { - _textEditingController.updateTextInputConfiguration( - textInputAction: widget.textInputAction, - textInputType: _isMultiline ? TextInputType.multiline : TextInputType.text, - ); - } - - if (widget.textController != oldWidget.textController) { - _textEditingController.removeListener(_onTextOrSelectionChange); - if (_textEditingController.onPerformActionPressed == _onPerformActionPressed) { - _textEditingController.onPerformActionPressed = null; - } - if (widget.textController != null) { - _textEditingController = widget.textController!; - } else { - _textEditingController = ImeAttributedTextEditingController(); - } - _textEditingController - ..addListener(_onTextOrSelectionChange) - ..onPerformActionPressed ??= _onPerformActionPressed; - } - - if (widget.showDebugPaint != oldWidget.showDebugPaint) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _rebuildEditingOverlayControls(); - }); - } - } - - @override - void reassemble() { - super.reassemble(); - - // On Hot Reload we need to remove any visible overlay controls and then - // bring them back a frame later to avoid having the controls attempt - // to access the layout of the text. The text layout is not immediately - // available upon Hot Reload. Accessing it results in an exception. - _removeEditingOverlayControls(); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _showEditingControlsOverlay(); - }); - } - - @override - void dispose() { - _removeEditingOverlayControls(); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // Dispose after the current frame so that other widgets have - // time to remove their listeners. - _editingOverlayController.dispose(); - }); - - _textEditingController.removeListener(_onTextOrSelectionChange); - if (widget.textController == null) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // Dispose after the current frame so that other widgets have - // time to remove their listeners. - _textEditingController.dispose(); - }); - } - - _focusNode.removeListener(_onFocusChange); - if (widget.focusNode == null) { - _focusNode.dispose(); - } - - _textScrollController - ..removeListener(_onTextScrollChange) - ..dispose(); - - WidgetsBinding.instance.removeObserver(this); - - super.dispose(); - } - - @override - void didChangeMetrics() { - // The available screen dimensions may have changed, e.g., due to keyboard - // appearance/disappearance. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted && _focusNode.hasFocus) { - _autoScrollToKeepTextFieldVisible(); - } - }); - } - - @override - ProseTextLayout get textLayout => _textContentKey.currentState!.textLayout; - - @override - DeltaTextInputClient get imeClient => _textEditingController; - - bool get _isMultiline => (widget.minLines ?? 1) != 1 || widget.maxLines != 1; - - void _onFocusChange() { - if (_focusNode.hasFocus) { - if (!_textEditingController.isAttachedToIme) { - _log.info('Attaching TextInputClient to TextInput'); - setState(() { - if (!_textEditingController.selection.isValid) { - _textEditingController.selection = TextSelection.collapsed(offset: _textEditingController.text.text.length); - } - - _textEditingController.attachToIme( - textInputAction: widget.textInputAction, - textInputType: _isMultiline ? TextInputType.multiline : TextInputType.text, - ); - - _autoScrollToKeepTextFieldVisible(); - _showEditingControlsOverlay(); - }); - } - } else { - _log.info('Lost focus. Detaching TextInputClient from TextInput.'); - setState(() { - _textEditingController.detachFromIme(); - _textEditingController.selection = const TextSelection.collapsed(offset: -1); - _removeEditingOverlayControls(); - }); - } - } - - void _onTextOrSelectionChange() { - if (_textEditingController.selection.isCollapsed) { - _editingOverlayController.hideToolbar(); - } - _textScrollController.ensureExtentIsVisible(); - } - - void _onTextScrollChange() { - if (_controlsOverlayEntry != null) { - _rebuildEditingOverlayControls(); - } - } - - /// Displays [AndroidEditingOverlayControls] in the app's [Overlay], if not already - /// displayed. - void _showEditingControlsOverlay() { - if (_controlsOverlayEntry == null) { - _controlsOverlayEntry = OverlayEntry(builder: (overlayContext) { - return AndroidEditingOverlayControls( - editingController: _editingOverlayController, - textScrollController: _textScrollController, - textFieldLayerLink: _textFieldLayerLink, - textFieldKey: _textFieldKey, - textContentLayerLink: _textContentLayerLink, - textContentKey: _textContentKey, - handleColor: widget.handlesColor, - popoverToolbarBuilder: widget.popoverToolbarBuilder, - showDebugPaint: widget.showDebugPaint, - ); - }); - - Overlay.of(context)!.insert(_controlsOverlayEntry!); - } - } - - /// Rebuilds the [AndroidEditingControls] in the app's [Overlay], if - /// they're currently displayed. - void _rebuildEditingOverlayControls() { - _controlsOverlayEntry?.markNeedsBuild(); - } - - /// Removes [AndroidEditingControls] from the app's [Overlay], if they're - /// currently displayed. - void _removeEditingOverlayControls() { - if (_controlsOverlayEntry != null) { - _controlsOverlayEntry!.remove(); - _controlsOverlayEntry = null; - } - } - - /// Handles actions from the IME - void _onPerformActionPressed(TextInputAction action) { - switch (action) { - case TextInputAction.done: - _focusNode.unfocus(); - break; - case TextInputAction.next: - _focusNode.nextFocus(); - break; - case TextInputAction.previous: - _focusNode.previousFocus(); - break; - default: - _log.warning("User pressed unhandled action button: $action"); - } - } - - /// Handles key presses - /// - /// Some third party keyboards report backspace as a key press - /// rather than a deletion delta, so we need to handle them manually - KeyEventResult _onKeyPressed(FocusNode focusNode, RawKeyEvent keyEvent) { - _log.finer('_onKeyPressed - keyEvent: ${keyEvent.character}'); - if (keyEvent is! RawKeyDownEvent) { - _log.finer('_onKeyPressed - not a "down" event. Ignoring.'); - return KeyEventResult.ignored; - } - if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { - return KeyEventResult.ignored; - } - - if (_textEditingController.selection.isCollapsed) { - _textEditingController.deletePreviousCharacter(); - } else { - _textEditingController.deleteSelection(); - } - - return KeyEventResult.handled; - } - - /// Scrolls the ancestor [Scrollable], if any, so [SuperTextField] - /// is visible on the viewport when it's focused - void _autoScrollToKeepTextFieldVisible() { - // If we are not inside a [Scrollable] we don't autoscroll - final ancestorScrollable = _findAncestorScrollable(context); - if (ancestorScrollable == null) { - return; - } - - // Compute the text field offset that should be visible to the user - final textFieldFocalPoint = widget.maxLines == null && _textEditingController.selection.isValid - ? _textContentKey.currentState!.textLayout.getOffsetAtPosition( - TextPosition(offset: _textEditingController.selection.extentOffset), - ) - : Offset.zero; - - final lineHeight = _textContentKey.currentState!.textLayout.getLineHeightAtPosition( - TextPosition(offset: _textEditingController.selection.extentOffset), - ); - final fieldBox = context.findRenderObject() as RenderBox; - - // The area of the text field that should be revealed. - // We add a small margin to leave some space between the text field and the keyboard. - final textFieldFocalRect = Rect.fromLTWH( - textFieldFocalPoint.dx, - textFieldFocalPoint.dy, - fieldBox.size.width, - lineHeight + gapBetweenCaretAndKeyboard, - ); - - fieldBox.showOnScreen( - rect: textFieldFocalRect, - duration: _autoScrollAnimationDuration, - curve: _autoScrollAnimationCurve, - ); - } - - ScrollableState? _findAncestorScrollable(BuildContext context) { - final ancestorScrollable = Scrollable.of(context); - if (ancestorScrollable == null) { - return null; - } - - final direction = ancestorScrollable.axisDirection; - // If the direction is horizontal, then we are inside a widget like a TabBar - // or a horizontal ListView, so we can't use the ancestor scrollable - if (direction == AxisDirection.left || direction == AxisDirection.right) { - return null; - } - - return ancestorScrollable; - } - - @override - Widget build(BuildContext context) { - return NonReparentingFocus( - key: _textFieldKey, - focusNode: _focusNode, - onKey: _onKeyPressed, - child: CompositedTransformTarget( - link: _textFieldLayerLink, - child: AndroidTextFieldTouchInteractor( - focusNode: _focusNode, - textKey: _textContentKey, - textFieldLayerLink: _textFieldLayerLink, - textController: _textEditingController, - editingOverlayController: _editingOverlayController, - textScrollController: _textScrollController, - isMultiline: _isMultiline, - handleColor: widget.handlesColor, - showDebugPaint: widget.showDebugPaint, - child: TextScrollView( - key: _scrollKey, - textScrollController: _textScrollController, - textKey: _textContentKey, - textEditingController: _textEditingController, - textAlign: widget.textAlign, - minLines: widget.minLines, - maxLines: widget.maxLines, - lineHeight: widget.lineHeight, - perLineAutoScrollDuration: const Duration(milliseconds: 100), - showDebugPaint: widget.showDebugPaint, - padding: widget.padding, - child: ListenableBuilder( - listenable: _textEditingController, - builder: (context) { - final isTextEmpty = _textEditingController.text.text.isEmpty; - final showHint = widget.hintBuilder != null && - ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || - (isTextEmpty && - !_focusNode.hasFocus && - widget.hintBehavior == HintBehavior.displayHintUntilFocus)); - - return CompositedTransformTarget( - link: _textContentLayerLink, - child: Stack( - children: [ - if (showHint) widget.hintBuilder!(context), - _buildSelectableText(), - ], - ), - ); - }, - ), - ), - ), - ), - ); - } - - Widget _buildSelectableText() { - final textSpan = _textEditingController.text.text.isNotEmpty - ? _textEditingController.text.computeTextSpan(widget.textStyleBuilder) - : TextSpan(text: "", style: widget.textStyleBuilder({})); - - return FillWidthIfConstrained( - child: SuperTextWithSelection.single( - key: _textContentKey, - richText: textSpan, - textAlign: widget.textAlign, - userSelection: UserSelection( - highlightStyle: SelectionHighlightStyle( - color: widget.selectionColor, - ), - caretStyle: CaretStyle( - color: widget.caretColor, - ), - selection: _textEditingController.selection, - hasCaret: _focusNode.hasFocus, - ), - ), - ); - } -} - -Widget _defaultAndroidToolbarBuilder(BuildContext context, AndroidEditingOverlayController controller) { - return AndroidTextEditingFloatingToolbar( - onCutPressed: () { - final textController = controller.textController; - - final selection = textController.selection; - if (selection.isCollapsed) { - return; - } - - final selectedText = selection.textInside(textController.text.text); - - textController.deleteSelectedText(); - - Clipboard.setData(ClipboardData(text: selectedText)); - }, - onCopyPressed: () { - final textController = controller.textController; - final selection = textController.selection; - final selectedText = selection.textInside(textController.text.text); - - Clipboard.setData(ClipboardData(text: selectedText)); - }, - onPastePressed: () async { - final clipboardContent = await Clipboard.getData('text/plain'); - if (clipboardContent == null || clipboardContent.text == null) { - return; - } - - final textController = controller.textController; - final selection = textController.selection; - if (selection.isCollapsed) { - textController.insertAtCaret(text: clipboardContent.text!); - } else { - textController.replaceSelectionWithUnstyledText(replacementText: clipboardContent.text!); - } - }, - onSelectAllPressed: () { - controller.textController.selectAll(); - }, - ); -} diff --git a/super_editor/lib/src/infrastructure/super_textfield/desktop/desktop_textfield.dart b/super_editor/lib/src/infrastructure/super_textfield/desktop/desktop_textfield.dart deleted file mode 100644 index 4249830273..0000000000 --- a/super_editor/lib/src/infrastructure/super_textfield/desktop/desktop_textfield.dart +++ /dev/null @@ -1,1718 +0,0 @@ -import 'dart:math'; -import 'dart:ui' as ui; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart' hide SelectableText; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; -import 'package:super_editor/src/core/document_layout.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; -import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'package:super_editor/src/infrastructure/focus.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_controller.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/hint_text.dart'; -import 'package:super_text_layout/super_text_layout.dart'; - -import '../../keyboard.dart'; -import '../../multi_tap_gesture.dart'; -import '../infrastructure/fill_width_if_constrained.dart'; -import '../styles.dart'; - -final _log = textFieldLog; - -/// Highly configurable text field intended for web and desktop uses. -/// -/// [SuperDesktopTextField] provides two advantages over a typical [TextField]. -/// First, [SuperDesktopTextField] is based on [AttributedText], which is a far -/// more useful foundation for styled text display than [TextSpan]. Second, -/// [SuperDesktopTextField] provides deeper control over various visual properties -/// including selection painting, caret painting, hint display, and keyboard -/// interaction. -/// -/// If [SuperDesktopTextField] does not provide the desired level of configuration, -/// look at its implementation. Unlike Flutter's [TextField], [SuperDesktopTextField] -/// is composed of a few widgets that you can recompose to create your own -/// flavor of a text field. -class SuperDesktopTextField extends StatefulWidget { - const SuperDesktopTextField({ - Key? key, - this.focusNode, - this.textController, - this.textStyleBuilder = defaultTextFieldStyleBuilder, - this.textAlign = TextAlign.left, - this.hintBehavior = HintBehavior.displayHintUntilFocus, - this.hintBuilder, - this.selectionHighlightStyle = const SelectionHighlightStyle( - color: Color(0xFFACCEF7), - ), - this.caretStyle = const CaretStyle( - color: Colors.black, - width: 1, - borderRadius: BorderRadius.zero, - ), - this.padding = EdgeInsets.zero, - this.minLines, - this.maxLines = 1, - this.decorationBuilder, - this.onRightClick, - this.keyboardHandlers = defaultTextFieldKeyboardHandlers, - }) : super(key: key); - - final FocusNode? focusNode; - - final AttributedTextEditingController? textController; - - /// Text style factory that creates styles for the content in - /// [textController] based on the attributions in that content. - final AttributionStyleBuilder textStyleBuilder; - - /// Policy for when the hint should be displayed. - final HintBehavior hintBehavior; - - /// Builder that creates the hint widget, when a hint is displayed. - /// - /// To easily build a hint with styled text, see [StyledHintBuilder]. - final WidgetBuilder? hintBuilder; - - /// The alignment to use for text in this text field. - final TextAlign textAlign; - - /// The visual representation of the user's selection highlight. - final SelectionHighlightStyle selectionHighlightStyle; - - /// The visual representation of the caret in this `SelectableText` widget. - final CaretStyle caretStyle; - - final EdgeInsetsGeometry padding; - - final int? minLines; - final int? maxLines; - - final DecorationBuilder? decorationBuilder; - - final RightClickListener? onRightClick; - - /// Priority list of handlers that process all physical keyboard - /// key presses, for text input, deletion, caret movement, etc. - final List keyboardHandlers; - - @override - SuperDesktopTextFieldState createState() => SuperDesktopTextFieldState(); -} - -class SuperDesktopTextFieldState extends State implements ProseTextBlock { - final _textKey = GlobalKey(); - final _textScrollKey = GlobalKey(); - late FocusNode _focusNode; - bool _hasFocus = false; // cache whether we have focus so we know when it changes - - late AttributedTextEditingController _controller; - late ScrollController _scrollController; - - double? _viewportHeight; - - final _estimatedLineHeight = _EstimatedLineHeight(); - - @override - void initState() { - super.initState(); - - _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); - _hasFocus = _focusNode.hasFocus; - - _controller = (widget.textController ?? AttributedTextEditingController()) - ..addListener(_onSelectionOrContentChange); - _scrollController = ScrollController(); - } - - @override - void didUpdateWidget(SuperDesktopTextField oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.focusNode != oldWidget.focusNode) { - _focusNode.removeListener(_onFocusChange); - if (oldWidget.focusNode == null) { - _focusNode.dispose(); - } - _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); - _hasFocus = _focusNode.hasFocus; - } - - if (widget.textController != oldWidget.textController) { - _controller.removeListener(_onSelectionOrContentChange); - if (oldWidget.textController == null) { - _controller.dispose(); - } - _controller = (widget.textController ?? AttributedTextEditingController()) - ..addListener(_onSelectionOrContentChange); - } - - if (widget.padding != oldWidget.padding || - widget.minLines != oldWidget.minLines || - widget.maxLines != oldWidget.maxLines) { - _onSelectionOrContentChange(); - } - } - - @override - void dispose() { - _scrollController.dispose(); - _focusNode.removeListener(_onFocusChange); - if (widget.focusNode == null) { - _focusNode.dispose(); - } - _controller.removeListener(_onSelectionOrContentChange); - if (widget.textController == null) { - _controller.dispose(); - } - - super.dispose(); - } - - @override - ProseTextLayout get textLayout => _textKey.currentState!.textLayout; - - FocusNode get focusNode => _focusNode; - - void requestFocus() { - _focusNode.requestFocus(); - } - - void _onFocusChange() { - // If our FocusNode just received focus, automatically set our - // controller's text position to the end of the available content. - // - // This behavior matches Flutter's standard behavior. - if (_focusNode.hasFocus && !_hasFocus && _controller.selection.extentOffset == -1) { - _controller.selection = TextSelection.collapsed(offset: _controller.text.text.length); - } - _hasFocus = _focusNode.hasFocus; - } - - AttributedTextEditingController get controller => _controller; - - void _onSelectionOrContentChange() { - // Use a post-frame callback to "ensure selection extent is visible" - // so that any pending visual content changes can happen before - // attempting to calculate the visual position of the selection extent. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - _updateViewportHeight(); - } - }); - } - - /// Returns true if the viewport height changed, false otherwise. - bool _updateViewportHeight() { - final estimatedLineHeight = _getEstimatedLineHeight(); - final estimatedLinesOfText = _getEstimatedLinesOfText(); - final estimatedContentHeight = (estimatedLinesOfText * estimatedLineHeight) + widget.padding.vertical; - final minHeight = widget.minLines != null ? widget.minLines! * estimatedLineHeight + widget.padding.vertical : null; - final maxHeight = widget.maxLines != null ? widget.maxLines! * estimatedLineHeight + widget.padding.vertical : null; - double? viewportHeight; - if (maxHeight != null && estimatedContentHeight > maxHeight) { - viewportHeight = maxHeight; - } else if (minHeight != null && estimatedContentHeight < minHeight) { - viewportHeight = minHeight; - } - - if (viewportHeight == _viewportHeight) { - // The height of the viewport hasn't changed. Return. - return false; - } - - setState(() { - _viewportHeight = viewportHeight; - }); - - return true; - } - - int _getEstimatedLinesOfText() { - if (_controller.text.text.isEmpty) { - return 0; - } - - if (_textKey.currentState == null) { - return 0; - } - - final offsetAtEndOfText = textLayout.getOffsetAtPosition(TextPosition(offset: _controller.text.text.length)); - int lineCount = (offsetAtEndOfText.dy / _getEstimatedLineHeight()).ceil(); - - if (_controller.text.text.endsWith('\n')) { - lineCount += 1; - } - - return lineCount; - } - - double _getEstimatedLineHeight() { - // After hot reloading, the text layout might be null, so we can't - // directly use _textKey.currentState!.textLayout because using it - // we can't check for null. - final textLayout = RenderSuperTextLayout.textLayoutFrom(_textKey); - final lineHeight = _controller.text.text.isEmpty || textLayout == null - ? 0.0 - : textLayout.getLineHeightAtPosition(const TextPosition(offset: 0)); - if (lineHeight > 0) { - return lineHeight; - } - final defaultStyle = widget.textStyleBuilder({}); - return _estimatedLineHeight.calculate(defaultStyle); - } - - @override - Widget build(BuildContext context) { - if (_textKey.currentContext == null) { - // The text hasn't been laid out yet, which means our calculations - // for text height is probably wrong. Schedule a post frame callback - // to re-calculate the height after initial layout. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - setState(() { - _updateViewportHeight(); - }); - } - }); - } - - final isMultiline = widget.minLines != 1 || widget.maxLines != 1; - - return SuperTextFieldKeyboardInteractor( - focusNode: _focusNode, - textController: _controller, - textKey: _textKey, - keyboardActions: widget.keyboardHandlers, - child: SuperTextFieldGestureInteractor( - focusNode: _focusNode, - textController: _controller, - textKey: _textKey, - textScrollKey: _textScrollKey, - isMultiline: isMultiline, - onRightClick: widget.onRightClick, - child: MultiListenableBuilder( - listenables: { - _focusNode, - _controller, - }, - builder: (context) { - final isTextEmpty = _controller.text.text.isEmpty; - final showHint = widget.hintBuilder != null && - ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || - (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); - - return _buildDecoration( - child: SuperTextFieldScrollview( - key: _textScrollKey, - textKey: _textKey, - textController: _controller, - textAlign: widget.textAlign, - scrollController: _scrollController, - viewportHeight: _viewportHeight, - estimatedLineHeight: _getEstimatedLineHeight(), - padding: widget.padding, - isMultiline: isMultiline, - child: Stack( - children: [ - if (showHint) widget.hintBuilder!(context), - _buildSelectableText(), - ], - ), - ), - ); - }, - ), - ), - ); - } - - Widget _buildDecoration({ - required Widget child, - }) { - return widget.decorationBuilder != null ? widget.decorationBuilder!(context, child) : child; - } - - Widget _buildSelectableText() { - return FillWidthIfConstrained( - child: SuperTextWithSelection.single( - key: _textKey, - richText: _controller.text.computeTextSpan(widget.textStyleBuilder), - textAlign: widget.textAlign, - userSelection: UserSelection( - highlightStyle: widget.selectionHighlightStyle, - caretStyle: widget.caretStyle, - selection: _controller.selection, - hasCaret: _focusNode.hasFocus, - ), - ), - ); - } -} - -typedef DecorationBuilder = Widget Function(BuildContext, Widget child); - -/// Handles all user gesture interactions for text entry. -/// -/// [SuperTextFieldGestureInteractor] is intended to operate as a piece within -/// a larger composition that behaves as a text field. [SuperTextFieldGestureInteractor] -/// is defined on its own so that it can be replaced with a widget that handles -/// gestures differently. -/// -/// The gestures are applied to a [SuperSelectableText] widget that is -/// tied to [textKey]. -/// -/// A [SuperTextFieldScrollview] must sit between this [SuperTextFieldGestureInteractor] -/// and the underlying [SuperSelectableText]. That [SuperTextFieldScrollview] must -/// be tied to [textScrollKey]. -class SuperTextFieldGestureInteractor extends StatefulWidget { - const SuperTextFieldGestureInteractor({ - Key? key, - required this.focusNode, - required this.textController, - required this.textKey, - required this.textScrollKey, - required this.isMultiline, - this.onRightClick, - required this.child, - }) : super(key: key); - - /// [FocusNode] for this text field. - final FocusNode focusNode; - - /// [TextController] for the text/selection within this text field. - final AttributedTextEditingController textController; - - /// [GlobalKey] that links this [SuperTextFieldGestureInteractor] to - /// the [ProseTextLayout] widget that paints the text for this text field. - final GlobalKey textKey; - - /// [GlobalKey] that links this [SuperTextFieldGestureInteractor] to - /// the [SuperTextFieldScrollview] that's responsible for scrolling - /// content that exceeds the available space within this text field. - final GlobalKey textScrollKey; - - /// Whether or not this text field supports multiple lines of text. - final bool isMultiline; - - /// Callback invoked when the user right clicks on this text field. - final RightClickListener? onRightClick; - - /// The rest of the subtree for this text field. - final Widget child; - - @override - State createState() => _SuperTextFieldGestureInteractorState(); -} - -class _SuperTextFieldGestureInteractorState extends State { - _SelectionType _selectionType = _SelectionType.position; - Offset? _dragStartInViewport; - Offset? _dragStartInText; - Offset? _dragEndInViewport; - Offset? _dragEndInText; - Rect? _dragRectInViewport; - - final _dragGutterExtent = 24; - final _maxDragSpeed = 20; - - ProseTextLayout get _textLayout => widget.textKey.currentState!.textLayout; - - SuperTextFieldScrollviewState get _textScroll => widget.textScrollKey.currentState!; - - void _onTapDown(TapDownDetails details) { - _log.fine('Tap down on SuperTextField'); - _selectionType = _SelectionType.position; - - final textOffset = _getTextOffset(details.localPosition); - final tapTextPosition = _getPositionNearestToTextOffset(textOffset); - _log.finer("Tap text position: $tapTextPosition"); - - final expandSelection = RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft) || - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftRight) || - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shift); - - setState(() { - widget.textController.selection = expandSelection - ? TextSelection( - baseOffset: widget.textController.selection.baseOffset, - extentOffset: tapTextPosition.offset, - ) - : TextSelection.collapsed(offset: tapTextPosition.offset); - - _log.finer("New text field selection: ${widget.textController.selection}"); - }); - - widget.focusNode.requestFocus(); - } - - void _onDoubleTapDown(TapDownDetails details) { - _selectionType = _SelectionType.word; - - _log.finer('_onDoubleTapDown - EditableDocument: onDoubleTap()'); - - final tapTextPosition = _getPositionAtOffset(details.localPosition); - - if (tapTextPosition != null) { - setState(() { - widget.textController.selection = _textLayout.getWordSelectionAt(tapTextPosition); - }); - } else { - _clearSelection(); - } - - widget.focusNode.requestFocus(); - } - - void _onDoubleTap() { - _selectionType = _SelectionType.position; - } - - void _onTripleTapDown(TapDownDetails details) { - _selectionType = _SelectionType.paragraph; - - _log.finer('_onTripleTapDown - EditableDocument: onTripleTapDown()'); - - final tapTextPosition = _getPositionAtOffset(details.localPosition); - - if (tapTextPosition != null) { - setState(() { - widget.textController.selection = _getParagraphSelectionAt(tapTextPosition, TextAffinity.downstream); - }); - } else { - _clearSelection(); - } - - widget.focusNode.requestFocus(); - } - - void _onTripleTap() { - _selectionType = _SelectionType.position; - } - - void _onRightClick(TapUpDetails details) { - widget.onRightClick?.call(context, widget.textController, details.localPosition); - } - - void _onPanStart(DragStartDetails details) { - _log.fine("User started pan"); - _dragStartInViewport = details.localPosition; - _dragStartInText = _getTextOffset(_dragStartInViewport!); - - _dragRectInViewport = Rect.fromLTWH(_dragStartInViewport!.dx, _dragStartInViewport!.dy, 1, 1); - - widget.focusNode.requestFocus(); - } - - void _onPanUpdate(DragUpdateDetails details) { - _log.finer("User moved during pan"); - setState(() { - _dragEndInViewport = details.localPosition; - _dragEndInText = _getTextOffset(_dragEndInViewport!); - _dragRectInViewport = Rect.fromPoints(_dragStartInViewport!, _dragEndInViewport!); - _log.finer('_onPanUpdate - drag rect: $_dragRectInViewport'); - _updateDragSelection(); - - _scrollIfNearBoundary(); - }); - } - - void _onPanEnd(DragEndDetails details) { - _log.finer("User ended a pan"); - setState(() { - _dragStartInText = null; - _dragEndInText = null; - _dragRectInViewport = null; - }); - - _textScroll.stopScrollingToStart(); - _textScroll.stopScrollingToEnd(); - } - - void _onPanCancel() { - _log.finer("User cancelled a pan"); - setState(() { - _dragStartInText = null; - _dragEndInText = null; - _dragRectInViewport = null; - }); - - _textScroll.stopScrollingToStart(); - _textScroll.stopScrollingToEnd(); - } - - void _updateDragSelection() { - if (_dragStartInText == null || _dragEndInText == null) { - return; - } - - setState(() { - final startDragOffset = _getPositionNearestToTextOffset(_dragStartInText!).offset; - final endDragOffset = _getPositionNearestToTextOffset(_dragEndInText!).offset; - final affinity = startDragOffset <= endDragOffset ? TextAffinity.downstream : TextAffinity.upstream; - - if (_selectionType == _SelectionType.paragraph) { - final baseParagraphSelection = _getParagraphSelectionAt(TextPosition(offset: startDragOffset), affinity); - final extentParagraphSelection = _getParagraphSelectionAt(TextPosition(offset: endDragOffset), affinity); - - widget.textController.selection = _combineSelections( - baseParagraphSelection, - extentParagraphSelection, - affinity, - ); - } else if (_selectionType == _SelectionType.word) { - final baseParagraphSelection = _textLayout.getWordSelectionAt(TextPosition(offset: startDragOffset)); - final extentParagraphSelection = _textLayout.getWordSelectionAt(TextPosition(offset: endDragOffset)); - - widget.textController.selection = _combineSelections( - baseParagraphSelection, - extentParagraphSelection, - affinity, - ); - } else { - widget.textController.selection = TextSelection( - baseOffset: startDragOffset, - extentOffset: endDragOffset, - ); - } - }); - } - - TextSelection _combineSelections( - TextSelection selection1, - TextSelection selection2, - TextAffinity affinity, - ) { - return affinity == TextAffinity.downstream - ? TextSelection( - baseOffset: min(selection1.start, selection2.start), - extentOffset: max(selection1.end, selection2.end), - ) - : TextSelection( - baseOffset: max(selection1.end, selection2.end), - extentOffset: min(selection1.start, selection2.start), - ); - } - - void _clearSelection() { - setState(() { - widget.textController.selection = const TextSelection.collapsed(offset: -1); - }); - } - - /// We prevent SingleChildScrollView from processing mouse events because - /// it scrolls by drag by default, which we don't want. However, we do - /// still want mouse scrolling. This method re-implements a primitive - /// form of mouse scrolling. - void _onPointerSignal(PointerSignalEvent event) { - if (event is PointerScrollEvent) { - // TODO: remove access to _textScroll.widget - final newScrollOffset = (_textScroll.widget.scrollController.offset + event.scrollDelta.dy) - .clamp(0.0, _textScroll.widget.scrollController.position.maxScrollExtent); - _textScroll.widget.scrollController.jumpTo(newScrollOffset); - - _updateDragSelection(); - } - } - - void _scrollIfNearBoundary() { - if (_dragEndInViewport == null) { - _log.finer("_scrollIfNearBoundary - Can't scroll near boundary because _dragEndInViewport is null"); - assert(_dragEndInViewport != null); - return; - } - - if (!widget.isMultiline) { - _scrollIfNearHorizontalBoundary(); - } else { - _scrollIfNearVerticalBoundary(); - } - } - - void _scrollIfNearHorizontalBoundary() { - final editorBox = context.findRenderObject() as RenderBox; - - if (_dragEndInViewport!.dx < _dragGutterExtent) { - _startScrollingToStart(); - } else { - _stopScrollingToStart(); - } - if (editorBox.size.width - _dragEndInViewport!.dx < _dragGutterExtent) { - _startScrollingToEnd(); - } else { - _stopScrollingToEnd(); - } - } - - void _scrollIfNearVerticalBoundary() { - final editorBox = context.findRenderObject() as RenderBox; - - if (_dragEndInViewport!.dy < _dragGutterExtent) { - _startScrollingToStart(); - return; - } else { - _stopScrollingToStart(); - } - - if (editorBox.size.height - _dragEndInViewport!.dy < _dragGutterExtent) { - _startScrollingToEnd(); - return; - } else { - _stopScrollingToEnd(); - } - } - - void _startScrollingToStart() { - if (_dragEndInViewport == null) { - _log.finer("_scrollUp - Can't scroll up because _dragEndInViewport is null"); - assert(_dragEndInViewport != null); - return; - } - - final gutterAmount = _dragEndInViewport!.dy.clamp(0.0, _dragGutterExtent); - final speedPercent = 1.0 - (gutterAmount / _dragGutterExtent); - final scrollAmount = ui.lerpDouble(0, _maxDragSpeed, speedPercent)!; - - _textScroll.startScrollingToStart(amountPerFrame: scrollAmount); - } - - void _stopScrollingToStart() { - _textScroll.stopScrollingToStart(); - } - - void _startScrollingToEnd() { - if (_dragEndInViewport == null) { - _log.finer("_scrollDown - Can't scroll down because _dragEndInViewport is null"); - assert(_dragEndInViewport != null); - return; - } - - final editorBox = context.findRenderObject() as RenderBox; - final gutterAmount = (editorBox.size.height - _dragEndInViewport!.dy).clamp(0.0, _dragGutterExtent); - final speedPercent = 1.0 - (gutterAmount / _dragGutterExtent); - final scrollAmount = ui.lerpDouble(0, _maxDragSpeed, speedPercent)!; - - _textScroll.startScrollingToEnd(amountPerFrame: scrollAmount); - } - - void _stopScrollingToEnd() { - _textScroll.stopScrollingToEnd(); - } - - TextPosition? _getPositionAtOffset(Offset textFieldOffset) { - final textOffset = _getTextOffset(textFieldOffset); - final textBox = widget.textKey.currentContext!.findRenderObject() as RenderBox; - - return textBox.size.contains(textOffset) ? _textLayout.getPositionAtOffset(textOffset) : null; - } - - TextSelection _getParagraphSelectionAt(TextPosition textPosition, TextAffinity affinity) { - return _textLayout.expandSelection(textPosition, paragraphExpansionFilter, affinity); - } - - TextPosition _getPositionNearestToTextOffset(Offset textOffset) { - return _textLayout.getPositionNearestToOffset(textOffset); - } - - bool _isTextAtOffset(Offset textFieldOffset) { - final textOffset = _getTextOffset(textFieldOffset); - return _textLayout.isTextAtOffset(textOffset); - } - - Offset _getTextOffset(Offset textFieldOffset) { - final textFieldBox = context.findRenderObject() as RenderBox; - final textBox = widget.textKey.currentContext!.findRenderObject() as RenderBox; - return textBox.globalToLocal(textFieldOffset, ancestor: textFieldBox); - } - - @override - Widget build(BuildContext context) { - return Listener( - onPointerSignal: _onPointerSignal, - child: GestureDetector( - onSecondaryTapUp: _onRightClick, - child: RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapDown = _onTapDown - ..onDoubleTapDown = _onDoubleTapDown - ..onDoubleTap = _onDoubleTap - ..onTripleTapDown = _onTripleTapDown - ..onTripleTap = _onTripleTap; - }, - ), - PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (PanGestureRecognizer recognizer) { - recognizer - ..onStart = _onPanStart - ..onUpdate = _onPanUpdate - ..onEnd = _onPanEnd - ..onCancel = _onPanCancel; - }, - ), - }, - child: MouseRegion( - cursor: SystemMouseCursors.text, - child: widget.child, - ), - ), - ), - ); - } -} - -/// Handles all keyboard interactions for text entry in a text field. -/// -/// [SuperTextFieldKeyboardInteractor] is intended to operate as a piece within -/// a larger composition that behaves as a text field. [SuperTextFieldKeyboardInteractor] -/// is defined on its own so that it can be replaced with a widget that handles -/// key events differently. -/// -/// The key events are passed down the [keyboardActions] Chain of Responsibility. -/// Each handler is given a reference to the [textController], to manipulate the -/// text content, and a [TextLayout] via the [textKey], which can be used to make -/// decisions about manipulations, such as moving the caret to the beginning/end -/// of a line. -class SuperTextFieldKeyboardInteractor extends StatefulWidget { - const SuperTextFieldKeyboardInteractor({ - Key? key, - required this.focusNode, - required this.textController, - required this.textKey, - required this.keyboardActions, - required this.child, - }) : super(key: key); - - /// [FocusNode] for this text field. - final FocusNode focusNode; - - /// [TextController] for the text/selection within this text field. - final AttributedTextEditingController textController; - - /// [GlobalKey] that links this [SuperTextFieldGestureInteractor] to - /// the [ProseTextLayout] widget that paints the text for this text field. - final GlobalKey textKey; - - /// Ordered list of actions that correspond to various key events. - /// - /// Each handler in the list may be given a key event from the keyboard. That - /// handler chooses to take an action, or not. A handler must respond with - /// a [TextFieldKeyboardHandlerResult], which indicates how the key event was handled, - /// or not. - /// - /// When a handler reports [TextFieldKeyboardHandlerResult.notHandled], the key event - /// is sent to the next handler. - /// - /// As soon as a handler reports [TextFieldKeyboardHandlerResult.handled], no other - /// handler is executed and the key event is prevented from propagating up - /// the widget tree. - /// - /// When a handler reports [TextFieldKeyboardHandlerResult.blocked], no other - /// handler is executed, but the key event **continues** to propagate up - /// the widget tree for other listeners to act upon. - /// - /// If all handlers report [TextFieldKeyboardHandlerResult.notHandled], the key - /// event propagates up the widget tree for other listeners to act upon. - final List keyboardActions; - - /// The rest of the subtree for this text field. - final Widget child; - - @override - State createState() => _SuperTextFieldKeyboardInteractorState(); -} - -class _SuperTextFieldKeyboardInteractorState extends State { - KeyEventResult _onKeyPressed(FocusNode focusNode, RawKeyEvent keyEvent) { - _log.fine('_onKeyPressed - keyEvent: ${keyEvent.logicalKey}, character: ${keyEvent.character}'); - if (keyEvent is! RawKeyDownEvent) { - _log.finer('_onKeyPressed - not a "down" event. Ignoring.'); - return KeyEventResult.ignored; - } - - TextFieldKeyboardHandlerResult result = TextFieldKeyboardHandlerResult.notHandled; - int index = 0; - while (result == TextFieldKeyboardHandlerResult.notHandled && index < widget.keyboardActions.length) { - result = widget.keyboardActions[index]( - controller: widget.textController, - textLayout: widget.textKey.currentState!.textLayout, - keyEvent: keyEvent, - ); - index += 1; - } - - _log.finest("Key handler result: $result"); - return result == TextFieldKeyboardHandlerResult.handled ? KeyEventResult.handled : KeyEventResult.ignored; - } - - @override - Widget build(BuildContext context) { - return NonReparentingFocus( - focusNode: widget.focusNode, - onKey: _onKeyPressed, - child: widget.child, - ); - } -} - -/// Handles all scrolling behavior for a text field. -/// -/// [SuperTextFieldScrollview] is intended to operate as a piece within -/// a larger composition that behaves as a text field. [SuperTextFieldScrollview] -/// is defined on its own so that it can be replaced with a widget that handles -/// scrolling differently. -/// -/// [SuperTextFieldScrollview] determines when and where to scroll by working -/// with a corresponding [SuperSelectableText] widget that is tied to [textKey]. -class SuperTextFieldScrollview extends StatefulWidget { - const SuperTextFieldScrollview({ - Key? key, - required this.textKey, - required this.textController, - required this.scrollController, - required this.padding, - required this.viewportHeight, - required this.estimatedLineHeight, - required this.isMultiline, - this.textAlign = TextAlign.left, - required this.child, - }) : super(key: key); - - /// [TextController] for the text/selection within this text field. - final AttributedTextEditingController textController; - - /// [GlobalKey] that links this [SuperTextFieldScrollview] to - /// the [ProseTextLayout] widget that paints the text for this text field. - final GlobalKey textKey; - - /// [ScrollController] that controls the scroll offset of this [SuperTextFieldScrollview]. - final ScrollController scrollController; - - /// Padding placed around the text content of this text field, but within the - /// scrollable viewport. - final EdgeInsetsGeometry padding; - - /// The height of the viewport for this text field. - /// - /// If [null] then the viewport is permitted to grow/shrink to any desired height. - final double? viewportHeight; - - /// An estimate for the height in pixels of a single line of text within this - /// text field. - final double estimatedLineHeight; - - /// Whether or not this text field allows multiple lines of text. - final bool isMultiline; - - /// The text alignment within the scrollview. - final TextAlign textAlign; - - /// The rest of the subtree for this text field. - final Widget child; - - @override - SuperTextFieldScrollviewState createState() => SuperTextFieldScrollviewState(); -} - -class SuperTextFieldScrollviewState extends State with SingleTickerProviderStateMixin { - bool _scrollToStartOnTick = false; - bool _scrollToEndOnTick = false; - double _scrollAmountPerFrame = 0; - late Ticker _ticker; - - @override - void initState() { - super.initState(); - _ticker = createTicker(_onTick); - - widget.textController.addListener(_onSelectionOrContentChange); - } - - @override - void didUpdateWidget(SuperTextFieldScrollview oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.textController != oldWidget.textController) { - oldWidget.textController.removeListener(_onSelectionOrContentChange); - widget.textController.addListener(_onSelectionOrContentChange); - } - - if (widget.viewportHeight != oldWidget.viewportHeight) { - // After the current layout, ensure that the current text - // selection is visible. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - _ensureSelectionExtentIsVisible(); - } - }); - } - } - - @override - void dispose() { - _ticker.dispose(); - super.dispose(); - } - - ProseTextLayout get _textLayout => widget.textKey.currentState!.textLayout; - - void _onSelectionOrContentChange() { - // Use a post-frame callback to "ensure selection extent is visible" - // so that any pending visual content changes can happen before - // attempting to calculate the visual position of the selection extent. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - _ensureSelectionExtentIsVisible(); - } - }); - } - - void _ensureSelectionExtentIsVisible() { - if (!widget.isMultiline) { - _ensureSelectionExtentIsVisibleInSingleLineTextField(); - } else { - _ensureSelectionExtentIsVisibleInMultilineTextField(); - } - } - - void _ensureSelectionExtentIsVisibleInSingleLineTextField() { - final selection = widget.textController.selection; - if (selection.extentOffset == -1) { - return; - } - - final extentOffset = _textLayout.getOffsetAtPosition(selection.extent); - - const gutterExtent = 0; // _dragGutterExtent - - final myBox = context.findRenderObject() as RenderBox; - final beyondLeftExtent = min(extentOffset.dx - widget.scrollController.offset - gutterExtent, 0).abs(); - final beyondRightExtent = max( - extentOffset.dx - myBox.size.width - widget.scrollController.offset + gutterExtent + widget.padding.horizontal, - 0); - - if (beyondLeftExtent > 0) { - final newScrollPosition = (widget.scrollController.offset - beyondLeftExtent) - .clamp(0.0, widget.scrollController.position.maxScrollExtent); - - widget.scrollController.animateTo( - newScrollPosition, - duration: const Duration(milliseconds: 100), - curve: Curves.easeOut, - ); - } else if (beyondRightExtent > 0) { - final newScrollPosition = (beyondRightExtent + widget.scrollController.offset) - .clamp(0.0, widget.scrollController.position.maxScrollExtent); - - widget.scrollController.animateTo( - newScrollPosition, - duration: const Duration(milliseconds: 100), - curve: Curves.easeOut, - ); - } - } - - void _ensureSelectionExtentIsVisibleInMultilineTextField() { - final selection = widget.textController.selection; - if (selection.extentOffset == -1) { - return; - } - - final extentOffset = _textLayout.getOffsetAtPosition(selection.extent); - - const gutterExtent = 0; // _dragGutterExtent - final extentLineIndex = (extentOffset.dy / widget.estimatedLineHeight).round(); - - final myBox = context.findRenderObject() as RenderBox; - final beyondTopExtent = min(extentOffset.dy - widget.scrollController.offset - gutterExtent, 0).abs(); - final beyondBottomExtent = max( - ((extentLineIndex + 1) * widget.estimatedLineHeight) - - myBox.size.height - - widget.scrollController.offset + - gutterExtent + - (widget.estimatedLineHeight / 2) + // manual adjustment to avoid line getting half cut off - widget.padding.vertical / 2, - 0); - - _log.finer('_ensureSelectionExtentIsVisible - Ensuring extent is visible.'); - _log.finer('_ensureSelectionExtentIsVisible - interaction size: ${myBox.size}'); - _log.finer('_ensureSelectionExtentIsVisible - scroll extent: ${widget.scrollController.offset}'); - _log.finer('_ensureSelectionExtentIsVisible - extent rect: $extentOffset'); - _log.finer('_ensureSelectionExtentIsVisible - beyond top: $beyondTopExtent'); - _log.finer('_ensureSelectionExtentIsVisible - beyond bottom: $beyondBottomExtent'); - - if (beyondTopExtent > 0) { - final newScrollPosition = (widget.scrollController.offset - beyondTopExtent) - .clamp(0.0, widget.scrollController.position.maxScrollExtent); - - widget.scrollController.animateTo( - newScrollPosition, - duration: const Duration(milliseconds: 100), - curve: Curves.easeOut, - ); - } else if (beyondBottomExtent > 0) { - final newScrollPosition = (beyondBottomExtent + widget.scrollController.offset) - .clamp(0.0, widget.scrollController.position.maxScrollExtent); - - widget.scrollController.animateTo( - newScrollPosition, - duration: const Duration(milliseconds: 100), - curve: Curves.easeOut, - ); - } - } - - void startScrollingToStart({required double amountPerFrame}) { - assert(amountPerFrame > 0); - - if (_scrollToStartOnTick) { - _scrollAmountPerFrame = amountPerFrame; - return; - } - - _scrollToStartOnTick = true; - _log.finer("Starting Ticker to auto-scroll up"); - _ticker.start(); - } - - void stopScrollingToStart() { - if (!_scrollToStartOnTick) { - return; - } - - _scrollToStartOnTick = false; - _scrollAmountPerFrame = 0; - _log.finer("Stopping Ticker after auto-scroll up"); - _ticker.stop(); - } - - void scrollToStart() { - if (widget.scrollController.offset <= 0) { - return; - } - - widget.scrollController.position.jumpTo(widget.scrollController.offset - _scrollAmountPerFrame); - } - - void startScrollingToEnd({required double amountPerFrame}) { - assert(amountPerFrame > 0); - - if (_scrollToEndOnTick) { - _scrollAmountPerFrame = amountPerFrame; - return; - } - - _scrollToEndOnTick = true; - _log.finer("Starting Ticker to auto-scroll down"); - _ticker.start(); - } - - void stopScrollingToEnd() { - if (!_scrollToEndOnTick) { - return; - } - - _scrollToEndOnTick = false; - _scrollAmountPerFrame = 0; - _log.finer("Stopping Ticker after auto-scroll down"); - _ticker.stop(); - } - - void scrollToEnd() { - if (widget.scrollController.offset >= widget.scrollController.position.maxScrollExtent) { - return; - } - - widget.scrollController.position.jumpTo(widget.scrollController.offset + _scrollAmountPerFrame); - } - - void _onTick(elapsedTime) { - if (_scrollToStartOnTick) { - scrollToStart(); - } - if (_scrollToEndOnTick) { - scrollToEnd(); - } - } - - Alignment _getAlignment() { - switch (widget.textAlign) { - case TextAlign.left: - case TextAlign.justify: - return Alignment.topLeft; - case TextAlign.right: - return Alignment.topRight; - case TextAlign.center: - return Alignment.topCenter; - case TextAlign.start: - return Directionality.of(context) == TextDirection.ltr ? Alignment.topLeft : Alignment.topRight; - case TextAlign.end: - return Directionality.of(context) == TextDirection.ltr ? Alignment.topRight : Alignment.topLeft; - } - } - - @override - Widget build(BuildContext context) { - return SizedBox( - height: widget.viewportHeight, - child: SingleChildScrollView( - controller: widget.scrollController, - physics: const NeverScrollableScrollPhysics(), - scrollDirection: widget.isMultiline ? Axis.vertical : Axis.horizontal, - child: Padding( - padding: widget.padding, - child: widget.child, - ), - ), - ); - } -} - -typedef RightClickListener = void Function( - BuildContext textFieldContext, AttributedTextEditingController textController, Offset textFieldOffset); - -enum _SelectionType { - /// The selection bound is set on a per-character basis. - /// - /// This is standard text selection behavior. - position, - - /// The selection bound expands to include any word that the - /// cursor touches. - word, - - /// The selection bound expands to include any paragraph that - /// the cursor touches. - paragraph, -} - -enum TextFieldKeyboardHandlerResult { - /// The handler recognized the key event and chose to - /// take an action. - /// - /// No other handler should receive the key event. - /// - /// The key event **shouldn't** bubble up the tree. - handled, - - /// The handler recognized the key event but chose to - /// take no action. - /// - /// No other handler should receive the key event. - /// - /// The key event **should** bubble up the tree to - /// (possibly) be handled by other keyboard/shortcut - /// listeners. - blocked, - - /// The handler has no relation to the key event and - /// took no action. - /// - /// Other handlers should be given a chance to act on - /// the key press. - notHandled, -} - -typedef TextFieldKeyboardHandler = TextFieldKeyboardHandlerResult Function({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, -}); - -/// A [TextFieldKeyboardHandler] that reports [TextFieldKeyboardHandlerResult.blocked] -/// for any key combination that matches one of the given [keys]. -TextFieldKeyboardHandler ignoreTextFieldKeyCombos(List keys) { - return ({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, - }) { - for (final key in keys) { - if (key.accepts(keyEvent, RawKeyboard.instance)) { - return TextFieldKeyboardHandlerResult.blocked; - } - } - return TextFieldKeyboardHandlerResult.notHandled; - }; -} - -/// The keyboard actions that a [SuperTextField] uses by default. -/// -/// It's common for developers to want all of these actions, but also -/// want to add more actions that take priority. To achieve that, -/// add the new actions to the front of the list: -/// -/// ``` -/// SuperTextField( -/// keyboardActions: [ -/// myNewAction1, -/// myNewAction2, -/// ...defaultTextfieldKeyboardActions, -/// ], -/// ); -/// ``` -const defaultTextFieldKeyboardHandlers = [ - DefaultSuperTextFieldKeyboardHandlers.copyTextWhenCmdCIsPressed, - DefaultSuperTextFieldKeyboardHandlers.pasteTextWhenCmdVIsPressed, - DefaultSuperTextFieldKeyboardHandlers.selectAllTextFieldWhenCmdAIsPressed, - DefaultSuperTextFieldKeyboardHandlers.moveCaretToStartOrEnd, - DefaultSuperTextFieldKeyboardHandlers.moveUpDownLeftAndRightWithArrowKeys, - DefaultSuperTextFieldKeyboardHandlers.moveToLineStartWithHome, - DefaultSuperTextFieldKeyboardHandlers.moveToLineEndWithEnd, - DefaultSuperTextFieldKeyboardHandlers.deleteWordWhenAltBackSpaceIsPressedOnMac, - DefaultSuperTextFieldKeyboardHandlers.deleteWordWhenCtlBackSpaceIsPressedOnWindowsAndLinux, - DefaultSuperTextFieldKeyboardHandlers.deleteTextOnLineBeforeCaretWhenShortcutKeyAndBackspaceIsPressed, - DefaultSuperTextFieldKeyboardHandlers.deleteTextWhenBackspaceOrDeleteIsPressed, - DefaultSuperTextFieldKeyboardHandlers.insertNewlineWhenEnterIsPressed, - DefaultSuperTextFieldKeyboardHandlers.insertCharacterWhenKeyIsPressed, -]; - -class DefaultSuperTextFieldKeyboardHandlers { - /// [copyTextWhenCmdCIsPressed] copies text to clipboard when primary shortcut key - /// (CMD on Mac, CTL on Windows) + C is pressed. - static TextFieldKeyboardHandlerResult copyTextWhenCmdCIsPressed({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, - }) { - if (!keyEvent.isPrimaryShortcutKeyPressed) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (keyEvent.logicalKey != LogicalKeyboardKey.keyC) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (controller.selection.extentOffset == -1) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - controller.copySelectedTextToClipboard(); - - return TextFieldKeyboardHandlerResult.handled; - } - - /// [pasteTextWhenCmdVIsPressed] pastes text from clipboard to document when primary shortcut key - /// (CMD on Mac, CTL on Windows) + V is pressed. - static TextFieldKeyboardHandlerResult pasteTextWhenCmdVIsPressed({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, - }) { - if (!keyEvent.isPrimaryShortcutKeyPressed) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (keyEvent.logicalKey != LogicalKeyboardKey.keyV) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (controller.selection.extentOffset == -1) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - if (!controller.selection.isCollapsed) { - controller.deleteSelectedText(); - } - - controller.pasteClipboard(); - - return TextFieldKeyboardHandlerResult.handled; - } - - /// [selectAllTextFieldWhenCmdAIsPressed] selects all text when primary shortcut key - /// (CMD on Mac, CTL on Windows) + A is pressed. - static TextFieldKeyboardHandlerResult selectAllTextFieldWhenCmdAIsPressed({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, - }) { - if (!keyEvent.isPrimaryShortcutKeyPressed) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (keyEvent.logicalKey != LogicalKeyboardKey.keyA) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - controller.selectAll(); - - return TextFieldKeyboardHandlerResult.handled; - } - - /// [moveCaretToStartOrEnd] moves caret to start (using CTL+A) or end of line (using CTL+E) - /// on MacOS platforms. This is part of expected behavior on MacOS. Not applicable to Windows. - static TextFieldKeyboardHandlerResult moveCaretToStartOrEnd({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, - }) { - bool moveLeft = false; - if (!keyEvent.isControlPressed) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (defaultTargetPlatform != TargetPlatform.macOS) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (keyEvent.logicalKey != LogicalKeyboardKey.keyA && keyEvent.logicalKey != LogicalKeyboardKey.keyE) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (controller.selection.extentOffset == -1) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - keyEvent.logicalKey == LogicalKeyboardKey.keyA - ? moveLeft = true - : keyEvent.logicalKey == LogicalKeyboardKey.keyE - ? moveLeft = false - : null; - - controller.moveCaretHorizontally( - textLayout: textLayout!, - expandSelection: false, - moveLeft: moveLeft, - movementModifier: MovementModifier.line, - ); - - return TextFieldKeyboardHandlerResult.handled; - } - - /// [moveUpDownLeftAndRightWithArrowKeys] moves caret according to the directional key which was pressed. - /// If there is no caret selection. it does nothing. - static TextFieldKeyboardHandlerResult moveUpDownLeftAndRightWithArrowKeys({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, - }) { - const arrowKeys = [ - LogicalKeyboardKey.arrowLeft, - LogicalKeyboardKey.arrowRight, - LogicalKeyboardKey.arrowUp, - LogicalKeyboardKey.arrowDown, - ]; - if (!arrowKeys.contains(keyEvent.logicalKey)) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (controller.selection.extentOffset == -1) { - // The result is reported as "handled" because an arrow - // key was pressed, but we return early because there is - // nowhere to move without a selection. - return TextFieldKeyboardHandlerResult.handled; - } - - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - if (defaultTargetPlatform == TargetPlatform.linux && - keyEvent.isAltPressed && - (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp || keyEvent.logicalKey == LogicalKeyboardKey.arrowDown)) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - if (keyEvent.logicalKey == LogicalKeyboardKey.arrowLeft) { - _log.finer('moveUpDownLeftAndRightWithArrowKeys - handling left arrow key'); - - MovementModifier? movementModifier; - if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && - keyEvent.isControlPressed) { - movementModifier = MovementModifier.word; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isMetaPressed) { - movementModifier = MovementModifier.line; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isAltPressed) { - movementModifier = MovementModifier.word; - } - - controller.moveCaretHorizontally( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, - moveLeft: true, - movementModifier: movementModifier, - ); - } else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowRight) { - _log.finer('moveUpDownLeftAndRightWithArrowKeys - handling right arrow key'); - - MovementModifier? movementModifier; - if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && - keyEvent.isControlPressed) { - movementModifier = MovementModifier.word; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isMetaPressed) { - movementModifier = MovementModifier.line; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isAltPressed) { - movementModifier = MovementModifier.word; - } - - controller.moveCaretHorizontally( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, - moveLeft: false, - movementModifier: movementModifier, - ); - } else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp) { - _log.finer('moveUpDownLeftAndRightWithArrowKeys - handling up arrow key'); - controller.moveCaretVertically( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, - moveUp: true, - ); - } else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowDown) { - _log.finer('moveUpDownLeftAndRightWithArrowKeys - handling down arrow key'); - controller.moveCaretVertically( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, - moveUp: false, - ); - } - - return TextFieldKeyboardHandlerResult.handled; - } - - static TextFieldKeyboardHandlerResult moveToLineStartWithHome({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, - }) { - if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - if (keyEvent.logicalKey == LogicalKeyboardKey.home) { - controller.moveCaretHorizontally( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, - moveLeft: true, - movementModifier: MovementModifier.line, - ); - return TextFieldKeyboardHandlerResult.handled; - } - - return TextFieldKeyboardHandlerResult.notHandled; - } - - static TextFieldKeyboardHandlerResult moveToLineEndWithEnd({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, - }) { - if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - if (keyEvent.logicalKey == LogicalKeyboardKey.end) { - controller.moveCaretHorizontally( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, - moveLeft: false, - movementModifier: MovementModifier.line, - ); - return TextFieldKeyboardHandlerResult.handled; - } - - return TextFieldKeyboardHandlerResult.notHandled; - } - - /// [insertCharacterWhenKeyIsPressed] adds any character when that key is pressed. - /// Certain keys are currently checked against a blacklist of characters for web - /// since their behavior is unexpected. Check definition for more details. - static TextFieldKeyboardHandlerResult insertCharacterWhenKeyIsPressed({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, - }) { - if (keyEvent.isMetaPressed || keyEvent.isControlPressed) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - if (keyEvent.character == null || keyEvent.character == '') { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (LogicalKeyboardKey.isControlCharacter(keyEvent.character!)) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - // On web, keys like shift and alt are sending their full name - // as a character, e.g., "Shift" and "Alt". This check prevents - // those keys from inserting their name into content. - // - // This filter is a blacklist, and therefore it will fail to - // catch any key that isn't explicitly listed. The eventual solution - // to this is for the web to honor the standard key event contract, - // but that's out of our control. - if (isKeyEventCharacterBlacklisted(keyEvent.character)) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - controller.insertCharacter(keyEvent.character!); - - return TextFieldKeyboardHandlerResult.handled; - } - - /// Deletes text between the beginning of the line and the caret, when the user - /// presses CMD + Backspace, or CTL + Backspace. - static TextFieldKeyboardHandlerResult deleteTextOnLineBeforeCaretWhenShortcutKeyAndBackspaceIsPressed({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, - }) { - if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (controller.selection.extentOffset < 0) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - if (!controller.selection.isCollapsed) { - controller.deleteSelection(); - return TextFieldKeyboardHandlerResult.handled; - } - - if (textLayout.getPositionAtStartOfLine(controller.selection.extent).offset == controller.selection.extentOffset) { - // The caret is sitting at the beginning of a line. There's nothing for us to - // delete upstream on this line. But we also don't want a regular BACKSPACE to - // run, either. Report this key combination as handled. - return TextFieldKeyboardHandlerResult.handled; - } - - controller.deleteTextOnLineBeforeCaret(textLayout: textLayout); - - return TextFieldKeyboardHandlerResult.handled; - } - - /// [deleteTextWhenBackspaceOrDeleteIsPressed] deletes single characters when delete or backspace is pressed. - static TextFieldKeyboardHandlerResult deleteTextWhenBackspaceOrDeleteIsPressed({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, - }) { - final isBackspace = keyEvent.logicalKey == LogicalKeyboardKey.backspace; - final isDelete = keyEvent.logicalKey == LogicalKeyboardKey.delete; - if (!isBackspace && !isDelete) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (controller.selection.extentOffset < 0) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - if (controller.selection.isCollapsed) { - controller.deleteCharacter(isBackspace ? TextAffinity.upstream : TextAffinity.downstream); - } else { - controller.deleteSelectedText(); - } - - return TextFieldKeyboardHandlerResult.handled; - } - - /// [deleteWordWhenAltBackSpaceIsPressedOnMac] deletes single words when Alt+Backspace is pressed on Mac. - static TextFieldKeyboardHandlerResult deleteWordWhenAltBackSpaceIsPressedOnMac({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, - }) { - if (defaultTargetPlatform != TargetPlatform.macOS) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - if (keyEvent.logicalKey != LogicalKeyboardKey.backspace || !keyEvent.isAltPressed) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (controller.selection.extentOffset < 0) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - _deleteUpstreamWord(controller, textLayout); - - return TextFieldKeyboardHandlerResult.handled; - } - - /// [deleteWordWhenAltBackSpaceIsPressedOnMac] deletes single words when Ctl+Backspace is pressed on Windows/Linux. - static TextFieldKeyboardHandlerResult deleteWordWhenCtlBackSpaceIsPressedOnWindowsAndLinux({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, - }) { - if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - if (keyEvent.logicalKey != LogicalKeyboardKey.backspace || !keyEvent.isControlPressed) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (controller.selection.extentOffset < 0) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - _deleteUpstreamWord(controller, textLayout); - - return TextFieldKeyboardHandlerResult.handled; - } - - static void _deleteUpstreamWord(AttributedTextEditingController controller, ProseTextLayout textLayout) { - if (!controller.selection.isCollapsed) { - controller.deleteSelectedText(); - return; - } - - controller.moveCaretHorizontally( - textLayout: textLayout, - expandSelection: true, - moveLeft: true, - movementModifier: MovementModifier.word, - ); - controller.deleteSelectedText(); - } - - /// [insertNewlineWhenEnterIsPressed] inserts a new line character when the enter key is pressed. - static TextFieldKeyboardHandlerResult insertNewlineWhenEnterIsPressed({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, - }) { - if (keyEvent.logicalKey != LogicalKeyboardKey.enter) { - return TextFieldKeyboardHandlerResult.notHandled; - } - if (!controller.selection.isCollapsed) { - return TextFieldKeyboardHandlerResult.notHandled; - } - - controller.insertNewline(); - - return TextFieldKeyboardHandlerResult.handled; - } - - DefaultSuperTextFieldKeyboardHandlers._(); -} - -/// Computes the estimated line height of a [TextStyle]. -class _EstimatedLineHeight { - /// Last computed line height. - double? _lastLineHeight; - - /// TextStyle used to compute [_lastLineHeight]. - TextStyle? _lastComputedStyle; - - /// Computes the estimated line height for the given [style]. - /// - /// The height is computed by laying out a [Paragraph] with an arbitrary - /// character and inspecting it's height. - /// - /// The result is cached for the last [style] used, so it's not computed - /// at each call. - double calculate(TextStyle style) { - if (_lastComputedStyle == style && _lastLineHeight != null) { - return _lastLineHeight!; - } - - final builder = ui.ParagraphBuilder(style.getParagraphStyle()) - ..pushStyle(style.getTextStyle()) - ..addText('A'); - - final paragraph = builder.build(); - paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); - - _lastLineHeight = paragraph.height; - _lastComputedStyle = style; - return _lastLineHeight!; - } -} diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/fill_width_if_constrained.dart b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/fill_width_if_constrained.dart deleted file mode 100644 index 1ec184062f..0000000000 --- a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/fill_width_if_constrained.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -/// Forces [child] to take up all available width when the -/// incoming width constraint is bounded, otherwise the [child] -/// is sized by its intrinsic width. -/// -/// This widget is used to correctly align the text of a multiline -/// [SuperTextWithSelection] with a constrained width. -class FillWidthIfConstrained extends SingleChildRenderObjectWidget { - const FillWidthIfConstrained({ - required Widget child, - }) : super(child: child); - - @override - RenderObject createRenderObject(BuildContext context) { - return RenderFillWidthIfConstrained( - minWidth: _getViewportWidth(context), - ); - } - - @override - void updateRenderObject(BuildContext context, RenderFillWidthIfConstrained renderObject) { - renderObject.minWidth = _getViewportWidth(context); - } - - double? _getViewportWidth(BuildContext context) { - final scrollable = Scrollable.of(context); - if (scrollable == null) { - return null; - } - - final direction = scrollable.axisDirection; - // We only need to specify the width if we are inside a horizontal scrollable, - // because in this case we might have an infinity maxWidth. - if (direction == AxisDirection.up || direction == AxisDirection.down) { - return null; - } - return (scrollable.context.findRenderObject() as RenderBox?)?.size.width; - } -} - -class RenderFillWidthIfConstrained extends RenderProxyBox { - RenderFillWidthIfConstrained({ - double? minWidth, - }) : _minWidth = minWidth; - - /// Sets the minimum width the child widget needs to be. - /// - /// This is needed when this widget is inside a horizontal Scrollable. - /// In this case, we might have an infinity maxWidth, so we need - /// to specify the Scrollable's width to force the child to - /// be at least this width. - set minWidth(double? value) { - _minWidth = value; - markNeedsLayout(); - } - - double? _minWidth; - - @override - void performLayout() { - BoxConstraints childConstraints = constraints; - - // If the available width is bounded, - // force the child to be as wide as the available width. - if (constraints.hasBoundedWidth) { - childConstraints = BoxConstraints( - minWidth: constraints.maxWidth, - minHeight: constraints.minHeight, - maxWidth: constraints.maxWidth, - maxHeight: constraints.maxHeight, - ); - } else if (_minWidth != null) { - // If a minWidth is given, force the child to be at least this width. - // This is the case when this widget is placed inside an Scrollable. - childConstraints = BoxConstraints( - minWidth: _minWidth!, - minHeight: constraints.minHeight, - maxHeight: constraints.maxHeight, - ); - } - - child!.layout(childConstraints, parentUsesSize: true); - size = child!.size; - } -} diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/magnifier.dart b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/magnifier.dart deleted file mode 100644 index adf7fde8b1..0000000000 --- a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/magnifier.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; - -/// A magnifying glass that enlarges the content beneath it. -/// -/// Magnifies the content beneath this [MagnifyingGlass] at a level of -/// [magnificationScale] and displays that content in a [shape] of -/// the given [size]. -/// -/// By default, [MagnifyingGlass] expects to be placed directly on top -/// of the content that it magnifies. Due to the way that magnification -/// works, if [MagnifyingGlass] is displayed with an offset from the -/// content that it magnifies, that offset must be provided as -/// [offsetFromFocalPoint]. -/// -/// [MagnifyingGlass] was designed to operate across the entire screen. -/// Using a [MagnifyingGlass] in a confined region may result in the -/// magnifier mis-aligning the content that is magnifies. -class MagnifyingGlass extends StatelessWidget { - const MagnifyingGlass({ - Key? key, - this.offsetFromFocalPoint = Offset.zero, - required this.shape, - required this.size, - required this.magnificationScale, - }) : super(key: key); - - /// The offset from where the magnification is applied, to where this - /// magnifier is displayed. - /// - /// An [offsetFromFocalPoint] of `Offset.zero` would indicate that this - /// [MagnifyingGlass] is displayed directly over the point of magnification. - final Offset offsetFromFocalPoint; - - /// The shape of the magnifying glass. - final ShapeBorder shape; - - /// The size of the magnifying glass. - final Size size; - - /// The level of magnification applied to the content beneath this - /// [MagnifyingGlass], expressed as a multiple of the natural dimensions. - final double magnificationScale; - - @override - Widget build(BuildContext context) { - return ClipPath.shape( - shape: shape, - child: BackdropFilter( - filter: _createMagnificationFilter(), - child: SizedBox.fromSize( - size: size, - ), - ), - ); - } - - ImageFilter _createMagnificationFilter() { - final magnifierMatrix = Matrix4.identity() - ..translate(offsetFromFocalPoint.dx * magnificationScale, offsetFromFocalPoint.dy * magnificationScale) - ..scale(magnificationScale, magnificationScale); - - return ImageFilter.matrix(magnifierMatrix.storage); - } -} diff --git a/super_editor/lib/src/infrastructure/super_textfield/ios/_user_interaction.dart b/super_editor/lib/src/infrastructure/super_textfield/ios/_user_interaction.dart deleted file mode 100644 index f72de8129f..0000000000 --- a/super_editor/lib/src/infrastructure/super_textfield/ios/_user_interaction.dart +++ /dev/null @@ -1,439 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; -import 'package:super_text_layout/super_text_layout.dart'; - -import '_editing_controls.dart'; - -final _log = iosTextFieldLog; - -/// iOS text field touch interaction surface. -/// -/// This widget is intended to be displayed in the foreground of -/// a [SuperSelectableText] widget. -/// -/// This widget recognizes and acts upon various user interactions: -/// -/// * Tap: Place a collapsed text selection at the tapped location -/// in text. -/// * Double-Tap: Select the word surrounding the tapped location -/// * Triple-Tap: Select the paragraph surrounding the tapped location -/// * Drag: Move a collapsed selection wherever the user drags, while -/// displaying a magnifying glass. -/// -/// Drag handles, a magnifying glass, and an editing toolbar are displayed -/// based on how the user interacts with this widget. Those UI elements -/// are controller via the given [editingOverlayController]. -/// -/// The text is auto-scrolled when the user drags a collapsed caret in -/// this widget. The auto-scrolling is handled by the given [textScrollController]. -/// -/// Selection changes are made via the given [textController]. -class IOSTextFieldTouchInteractor extends StatefulWidget { - const IOSTextFieldTouchInteractor({ - Key? key, - required this.focusNode, - required this.textFieldLayerLink, - required this.textController, - required this.editingOverlayController, - required this.textScrollController, - required this.selectableTextKey, - required this.isMultiline, - required this.handleColor, - this.showDebugPaint = false, - required this.child, - }) : super(key: key); - - /// [FocusNode] for the text field that contains this [IOSTextFieldInteractor]. - /// - /// [IOSTextFieldInteractor] only shows editing controls, and listens for drag - /// events when [focusNode] has focus. - /// - /// [IOSTextFieldInteractor] requests focus when the user taps on it. - final FocusNode focusNode; - - /// [LayerLink] that follows the text field that contains this - /// [IOSExtFieldInteractor]. - /// - /// [textFieldLayerLink] is used to anchor the editing controls. - final LayerLink textFieldLayerLink; - - /// [TextController] used to read the current selection to display - /// editing controls, and used to update the selection based on - /// user interactions. - final ImeAttributedTextEditingController textController; - - final IOSEditingOverlayController editingOverlayController; - - final TextScrollController textScrollController; - - /// [GlobalKey] that references the widget that contains the field's - /// text. - final GlobalKey selectableTextKey; - - /// Whether the text field that owns this [IOSTextFieldInteractor] is - /// a multiline text field. - final bool isMultiline; - - /// The color of expanded selection drag handles. - final Color handleColor; - - /// Whether to paint debugging guides and regions. - final bool showDebugPaint; - - /// The child widget. - final Widget child; - - @override - IOSTextFieldTouchInteractorState createState() => IOSTextFieldTouchInteractorState(); -} - -class IOSTextFieldTouchInteractorState extends State with TickerProviderStateMixin { - final _textViewportOffsetLink = LayerLink(); - - // Whether the user is dragging a collapsed selection. - bool _isDraggingCaret = false; - - // The latest offset during a user's drag gesture. - Offset? _globalDragOffset; - Offset? _dragOffset; - - @override - void initState() { - super.initState(); - - widget.textScrollController.addListener(_onScrollChange); - } - - @override - void didUpdateWidget(IOSTextFieldTouchInteractor oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.textScrollController != oldWidget.textScrollController) { - oldWidget.textScrollController.removeListener(_onScrollChange); - widget.textScrollController.addListener(_onScrollChange); - } - } - - @override - void dispose() { - widget.textScrollController.removeListener(_onScrollChange); - super.dispose(); - } - - ProseTextLayout get _textLayout => widget.selectableTextKey.currentState!.textLayout; - - void _onTapDown(TapDownDetails details) { - _log.fine("User tapped down"); - if (!widget.focusNode.hasFocus) { - _log.finer("Field isn't focused. Ignoring press."); - return; - } - - // When the user drags, the toolbar should not be visible. - // A drag can begin with a tap down, so we hide the toolbar - // preemptively. - widget.editingOverlayController.hideToolbar(); - - _selectAtOffset(details.localPosition); - } - - void _onTapUp(TapUpDetails details) { - _log.fine('User released a tap'); - - if (widget.focusNode.hasFocus && widget.textController.isAttachedToIme) { - widget.textController.showKeyboard(); - } else if (widget.focusNode.hasFocus) { - // This situation can happen on iOS web when the user taps outside the field - // or clicks on the OK button of the software keyboard. - // In this situation, the IME connection is closed but the field remains focused. - // We need to attach to IME so the keyboard is displayed again. - widget.textController.attachToIme(); - } else { - widget.focusNode.requestFocus(); - } - - // If the user tapped on a collapsed caret, or tapped on an - // expanded selection, toggle the toolbar appearance. - final tapTextPosition = _getTextPositionAtOffset(details.localPosition); - if (tapTextPosition == null) { - // Place the caret based on the tap offset. In this case, the caret will - // be placed at the end of text because the user tapped in empty space. - _selectAtOffset(details.localPosition); - return; - } - - final previousSelection = widget.textController.selection; - final didTapOnExistingSelection = previousSelection.isCollapsed - ? tapTextPosition == previousSelection.extent - : tapTextPosition.offset >= previousSelection.start && tapTextPosition.offset <= previousSelection.end; - - if (didTapOnExistingSelection) { - // Toggle the toolbar display when the user taps on the collapsed caret, - // or on top of an existing selection. - widget.editingOverlayController.toggleToolbar(); - } else { - // The user tapped somewhere in the text outside any existing selection. - // Hide the toolbar. - widget.editingOverlayController.hideToolbar(); - - // Place the caret based on the tap offset. - _selectAtOffset(details.localPosition); - } - } - - /// Places the caret in the field's text based on the given [localOffset], - /// and displays the drag handle. - void _selectAtOffset(Offset localOffset) { - final tapTextPosition = _getTextPositionAtOffset(localOffset); - if (tapTextPosition == null) { - // This situation indicates the user tapped in empty space - widget.textController.selection = TextSelection.collapsed(offset: widget.textController.text.text.length); - return; - } - - // Update the text selection to a collapsed selection where the user tapped. - widget.textController.selection = TextSelection.collapsed(offset: tapTextPosition.offset); - } - - void _onDoubleTapDown(TapDownDetails details) { - _log.fine('Double tap'); - widget.focusNode.requestFocus(); - - // When the user released the first tap, the toolbar was set - // to visible. At the beginning of a double-tap, make it invisible - // again. - widget.editingOverlayController.hideToolbar(); - - final tapTextPosition = _getTextPositionAtOffset(details.localPosition); - if (tapTextPosition != null) { - setState(() { - final wordSelection = _getWordSelectionAt(tapTextPosition); - - widget.textController.selection = wordSelection; - - if (!wordSelection.isCollapsed) { - widget.editingOverlayController.showToolbar(); - } - }); - } - } - - void _onTripleTapDown(TapDownDetails details) { - final textLayout = _textLayout; - final tapTextPosition = textLayout.getPositionAtOffset(details.localPosition)!; - - widget.textController.selection = - textLayout.expandSelection(tapTextPosition, paragraphExpansionFilter, TextAffinity.downstream); - } - - void _onTextPanStart(DragStartDetails details) { - _log.fine('_onTextPanStart()'); - setState(() { - _isDraggingCaret = true; - _globalDragOffset = details.globalPosition; - _dragOffset = details.localPosition; - }); - } - - void _onPanUpdate(DragUpdateDetails details) { - _log.fine('_onPanUpdate handle mode'); - - if (_isDraggingCaret) { - widget.textController.selection = TextSelection.collapsed( - offset: _globalOffsetToTextPosition(details.globalPosition).offset, - ); - } - - setState(() { - _globalDragOffset = _globalDragOffset! + details.delta; - _dragOffset = _dragOffset! + details.delta; - - widget.textScrollController.updateAutoScrollingForTouchOffset( - userInteractionOffsetInViewport: _dragOffset!, - ); - - widget.editingOverlayController.showMagnifier(_globalDragOffset!); - }); - } - - void _onPanEnd(DragEndDetails details) { - _log.fine('_onPanEnd()'); - _onHandleDragEnd(); - } - - void _onPanCancel() { - _log.fine('_onPanCancel()'); - _onHandleDragEnd(); - } - - void _onHandleDragEnd() { - _log.fine('_onHandleDragEnd()'); - widget.textScrollController.stopScrolling(); - - if (_isDraggingCaret) { - widget.textScrollController.ensureExtentIsVisible(); - } - - setState(() { - _isDraggingCaret = false; - widget.editingOverlayController.hideMagnifier(); - - if (!widget.textController.selection.isCollapsed) { - widget.editingOverlayController.showToolbar(); - } - }); - } - - void _onScrollChange() { - if (_isDraggingCaret) { - // This callback is invoked as soon as the logical scroll offset - // changes, but that scroll value won't be reflected in the text - // layout until the end of this frame. Therefore, we schedule a - // a post frame callback to lookup the new text selection location - // after the current layout pass. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - widget.textController.selection = TextSelection.collapsed( - offset: _globalOffsetToTextPosition(_globalDragOffset!).offset, - ); - }); - } - } - - /// Converts a screen-level offset to an offset relative to the top-left - /// corner of the text within this text field. - Offset _globalOffsetToTextOffset(Offset globalOffset) { - final textBox = widget.selectableTextKey.currentContext!.findRenderObject() as RenderBox; - return textBox.globalToLocal(globalOffset); - } - - /// Converts a screen-level offset to a [TextPosition] that sits at that - /// global offset. - TextPosition _globalOffsetToTextPosition(Offset globalOffset) { - return _textLayout.getPositionNearestToOffset( - _globalOffsetToTextOffset(globalOffset), - ); - } - - /// Returns the [TextPosition] sitting at the given [localOffset] within - /// this [IOSTextFieldInteractor]. - TextPosition? _getTextPositionAtOffset(Offset localOffset) { - // We show placeholder text when there is no text content. We don't want - // to place the caret in the placeholder text, so when _currentText is - // empty, explicitly set the text position to an offset of -1. - if (widget.textController.text.text.isEmpty) { - return const TextPosition(offset: -1); - } - - final globalOffset = (context.findRenderObject() as RenderBox).localToGlobal(localOffset); - final textOffset = - (widget.selectableTextKey.currentContext!.findRenderObject() as RenderBox).globalToLocal(globalOffset); - return _textLayout.getPositionNearestToOffset(textOffset); - } - - /// Returns a [TextSelection] that selects the word surrounding the given - /// [position]. - TextSelection _getWordSelectionAt(TextPosition position) { - return _textLayout.getWordSelectionAt(position); - } - - @override - Widget build(BuildContext context) { - return CompositedTransformTarget( - link: _textViewportOffsetLink, - child: GestureDetector( - onTap: () { - _log.fine('Intercepting single tap'); - // This GestureDetector is here to prevent taps from going further - // up the tree. There must an issue with the custom gesture detector - // used below that's allowing taps to bubble up even if handled. - // - // If this GestureDetector is placed any further down in this tree, - // it won't block the touch event. But it does from right here. - // - // TODO: fix the custom gesture detector in the RawGestureDetector. - }, - onDoubleTap: () { - _log.fine('Intercepting double tap'); - // no-op - }, - child: DecoratedBox( - decoration: BoxDecoration( - border: widget.showDebugPaint ? Border.all(color: Colors.purple) : const Border(), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - widget.child, - if (widget.textController.selection.extentOffset >= 0) _buildExtentTrackerForMagnifier(), - _buildTapAndDragDetector(), - ], - ), - ), - ), - ); - } - - Widget _buildTapAndDragDetector() { - return Positioned( - left: 0, - top: 0, - right: 0, - bottom: 0, - child: RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapDown = _onTapDown - ..onTapUp = _onTapUp - ..onDoubleTapDown = _onDoubleTapDown - ..onTripleTapDown = _onTripleTapDown; - }, - ), - PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (PanGestureRecognizer recognizer) { - recognizer - ..onStart = widget.focusNode.hasFocus ? _onTextPanStart : null - ..onUpdate = widget.focusNode.hasFocus ? _onPanUpdate : null - ..onEnd = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanEnd : null - ..onCancel = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanCancel : null; - }, - ), - }, - ), - ); - } - - /// Builds a tracking widget at the selection extent offset. - /// - /// The extent widget is tracked via [_draggingHandleLink] - Widget _buildExtentTrackerForMagnifier() { - if (!_isDraggingCaret) { - return const SizedBox(); - } - - return Positioned( - left: _dragOffset!.dx, - top: _dragOffset!.dy, - child: CompositedTransformTarget( - link: widget.editingOverlayController.magnifierFocalPoint, - child: widget.showDebugPaint - ? FractionalTranslation( - translation: const Offset(-0.5, -0.5), - child: Container( - width: 20, - height: 20, - color: Colors.purpleAccent.withOpacity(0.5), - ), - ) - : const SizedBox(width: 1, height: 1), - ), - ); - } -} diff --git a/super_editor/lib/src/infrastructure/super_textfield/ios/ios_textfield.dart b/super_editor/lib/src/infrastructure/super_textfield/ios/ios_textfield.dart deleted file mode 100644 index 06db2ed237..0000000000 --- a/super_editor/lib/src/infrastructure/super_textfield/ios/ios_textfield.dart +++ /dev/null @@ -1,584 +0,0 @@ -import 'package:attributed_text/attributed_text.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; -import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'package:super_editor/src/infrastructure/focus.dart'; -import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/fill_width_if_constrained.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/hint_text.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/text_scrollview.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/ios/_editing_controls.dart'; -import 'package:super_text_layout/super_text_layout.dart'; - -import '../../platforms/ios/toolbar.dart'; -import '../metrics.dart'; -import '../styles.dart'; -import '_floating_cursor.dart'; -import '_user_interaction.dart'; - -export '../../platforms/ios/selection_handles.dart'; -export '../../platforms/ios/toolbar.dart'; -export '../infrastructure/magnifier.dart'; -export '_caret.dart'; -export '_user_interaction.dart'; - -final _log = iosTextFieldLog; - -class SuperIOSTextField extends StatefulWidget { - const SuperIOSTextField({ - Key? key, - this.focusNode, - this.textController, - this.textStyleBuilder = defaultTextFieldStyleBuilder, - this.textAlign = TextAlign.left, - this.hintBehavior = HintBehavior.displayHintUntilFocus, - this.hintBuilder, - this.minLines, - this.maxLines = 1, - this.lineHeight, - required this.caretColor, - required this.selectionColor, - required this.handlesColor, - this.textInputAction = TextInputAction.done, - this.popoverToolbarBuilder = _defaultPopoverToolbarBuilder, - this.showDebugPaint = false, - this.padding, - }) : super(key: key); - - /// [FocusNode] attached to this text field. - final FocusNode? focusNode; - - /// Controller that owns the text content and text selection for - /// this text field. - final ImeAttributedTextEditingController? textController; - - /// The alignment to use for text in this text field. - final TextAlign textAlign; - - /// Text style factory that creates styles for the content in - /// [textController] based on the attributions in that content. - final AttributionStyleBuilder textStyleBuilder; - - /// Policy for when the hint should be displayed. - final HintBehavior hintBehavior; - - /// Builder that creates the hint widget, when a hint is displayed. - /// - /// To easily build a hint with styled text, see [StyledHintBuilder]. - final WidgetBuilder? hintBuilder; - - /// Color of the caret. - final Color caretColor; - - /// Color of the selection rectangle for selected text. - final Color selectionColor; - - /// Color of the selection handles. - final Color handlesColor; - - /// The minimum height of this text field, represented as a - /// line count. - /// - /// If [minLines] is non-null and greater than `1`, [lineHeight] - /// must also be provided because there is no guarantee that all - /// lines of text have the same height. - /// - /// See also: - /// - /// * [maxLines] - /// * [lineHeight] - final int? minLines; - - /// The maximum height of this text field, represented as a - /// line count. - /// - /// If text exceeds the maximum line height, scrolling dynamics - /// are added to accommodate the overflowing text. - /// - /// If [maxLines] is non-null and greater than `1`, [lineHeight] - /// must also be provided because there is no guarantee that all - /// lines of text have the same height. - /// - /// See also: - /// - /// * [minLines] - /// * [lineHeight] - final int? maxLines; - - /// The height of a single line of text in this text scroll view, used - /// with [minLines] and [maxLines] to size the text field. - /// - /// If a [lineHeight] is provided, the text field viewport is sized as a - /// multiple of that [lineHeight]. If no [lineHeight] is provided, the - /// text field viewport is sized as a multiple of the line-height of the - /// first line of text. - final double? lineHeight; - - /// The type of action associated with the action button on the mobile - /// keyboard. - final TextInputAction textInputAction; - - /// Builder that creates the popover toolbar widget that appears when text is selected. - final Widget Function(BuildContext, IOSEditingOverlayController) popoverToolbarBuilder; - - /// Whether to paint debug guides. - final bool showDebugPaint; - - /// Padding placed around the text content of this text field, but within the - /// scrollable viewport. - final EdgeInsets? padding; - - @override - State createState() => SuperIOSTextFieldState(); -} - -class SuperIOSTextFieldState extends State - with TickerProviderStateMixin, WidgetsBindingObserver - implements ProseTextBlock, ImeInputOwner { - static const Duration _autoScrollAnimationDuration = Duration(milliseconds: 100); - static const Curve _autoScrollAnimationCurve = Curves.fastOutSlowIn; - - final _textFieldKey = GlobalKey(); - final _textFieldLayerLink = LayerLink(); - final _textContentLayerLink = LayerLink(); - final _scrollKey = GlobalKey(); - final _textContentKey = GlobalKey(); - - late FocusNode _focusNode; - - late ImeAttributedTextEditingController _textEditingController; - late FloatingCursorController _floatingCursorController; - - final _magnifierLayerLink = LayerLink(); - late IOSEditingOverlayController _editingOverlayController; - - late TextScrollController _textScrollController; - - // OverlayEntry that displays the toolbar and magnifier, and - // positions the invisible touch targets for base/extent - // dragging. - OverlayEntry? _controlsOverlayEntry; - - @override - void initState() { - super.initState(); - _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); - if (_focusNode.hasFocus) { - _showHandles(); - } - - _textEditingController = (widget.textController ?? ImeAttributedTextEditingController()) - ..addListener(_onTextOrSelectionChange) - ..onIOSFloatingCursorChange = _onFloatingCursorChange - ..onPerformActionPressed ??= _onPerformActionPressed; - - _textScrollController = TextScrollController( - textController: _textEditingController, - tickerProvider: this, - )..addListener(_onTextScrollChange); - - _floatingCursorController = FloatingCursorController( - textController: _textEditingController, - ); - - _editingOverlayController = IOSEditingOverlayController( - textController: _textEditingController, - magnifierFocalPoint: _magnifierLayerLink, - ); - - WidgetsBinding.instance.addObserver(this); - } - - @override - void didUpdateWidget(SuperIOSTextField oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.focusNode != oldWidget.focusNode) { - _focusNode.removeListener(_onFocusChange); - if (widget.focusNode != null) { - _focusNode = widget.focusNode!; - } else { - _focusNode = FocusNode(); - } - _focusNode.addListener(_onFocusChange); - } - - if (widget.textController != oldWidget.textController) { - _textEditingController - ..removeListener(_onTextOrSelectionChange) - ..onIOSFloatingCursorChange = null; - if (_textEditingController.onPerformActionPressed == _onPerformActionPressed) { - _textEditingController.onPerformActionPressed = null; - } - - if (widget.textController != null) { - _textEditingController = widget.textController!; - } else { - _textEditingController = ImeAttributedTextEditingController(); - } - - _textEditingController - ..addListener(_onTextOrSelectionChange) - ..onIOSFloatingCursorChange = _onFloatingCursorChange - ..onPerformActionPressed ??= _onPerformActionPressed; - } - - if (widget.showDebugPaint != oldWidget.showDebugPaint) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _rebuildHandles(); - }); - } - } - - @override - void reassemble() { - super.reassemble(); - - // On Hot Reload we need to remove any visible overlay controls and then - // bring them back a frame later to avoid having the controls attempt - // to access the layout of the text. The text layout is not immediately - // available upon Hot Reload. Accessing it results in an exception. - _removeEditingOverlayControls(); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _showHandles(); - }); - } - - @override - void dispose() { - _removeEditingOverlayControls(); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // Dispose after the current frame so that other widgets have - // time to remove their listeners. - _editingOverlayController.dispose(); - }); - - _textEditingController - ..removeListener(_onTextOrSelectionChange) - ..onIOSFloatingCursorChange = null; - if (widget.textController == null) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // Dispose after the current frame so that other widgets have - // time to remove their listeners. - _textEditingController.dispose(); - }); - } - - _focusNode.removeListener(_onFocusChange); - if (widget.focusNode == null) { - _focusNode.dispose(); - } - - _textScrollController - ..removeListener(_onTextScrollChange) - ..dispose(); - - WidgetsBinding.instance.removeObserver(this); - - super.dispose(); - } - - @override - void didChangeMetrics() { - // The available screen dimensions may have changed, e.g., due to keyboard - // appearance/disappearance. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted && _focusNode.hasFocus) { - _autoScrollToKeepTextFieldVisible(); - } - }); - } - - @override - ProseTextLayout get textLayout => _textContentKey.currentState!.textLayout; - - bool get _isMultiline => (widget.minLines ?? 1) != 1 || widget.maxLines != 1; - - @override - DeltaTextInputClient get imeClient => _textEditingController; - - void _onFocusChange() { - if (_focusNode.hasFocus) { - if (!_textEditingController.isAttachedToIme) { - _log.info('Attaching TextInputClient to TextInput'); - setState(() { - if (!_textEditingController.selection.isValid) { - _textEditingController.selection = TextSelection.collapsed(offset: _textEditingController.text.text.length); - } - - _textEditingController.attachToIme( - textInputAction: widget.textInputAction, - textInputType: _isMultiline ? TextInputType.multiline : TextInputType.text, - ); - - _autoScrollToKeepTextFieldVisible(); - _showHandles(); - }); - } - } else { - _log.info('Lost focus. Detaching TextInputClient from TextInput.'); - setState(() { - _textEditingController.detachFromIme(); - _textEditingController.selection = const TextSelection.collapsed(offset: -1); - _removeEditingOverlayControls(); - }); - } - } - - void _onTextOrSelectionChange() { - if (_textEditingController.selection.isCollapsed) { - _editingOverlayController.hideToolbar(); - } - _textScrollController.ensureExtentIsVisible(); - } - - void _onTextScrollChange() { - if (_controlsOverlayEntry != null) { - _rebuildHandles(); - } - } - - /// Displays [IOSEditingControls] in the app's [Overlay], if not already - /// displayed. - void _showHandles() { - if (_controlsOverlayEntry == null) { - _controlsOverlayEntry = OverlayEntry(builder: (overlayContext) { - return IOSEditingControls( - editingController: _editingOverlayController, - textScrollController: _textScrollController, - textFieldLayerLink: _textFieldLayerLink, - textFieldKey: _textFieldKey, - textContentLayerLink: _textContentLayerLink, - textContentKey: _textContentKey, - handleColor: widget.handlesColor, - popoverToolbarBuilder: _defaultPopoverToolbarBuilder, - showDebugPaint: widget.showDebugPaint, - ); - }); - - Overlay.of(context)!.insert(_controlsOverlayEntry!); - } - } - - /// Rebuilds the [IOSEditingControls] in the app's [Overlay], if - /// they're currently displayed. - void _rebuildHandles() { - _controlsOverlayEntry?.markNeedsBuild(); - } - - /// Removes [IOSEditingControls] from the app's [Overlay], if they're - /// currently displayed. - void _removeEditingOverlayControls() { - if (_controlsOverlayEntry != null) { - _controlsOverlayEntry!.remove(); - _controlsOverlayEntry = null; - } - } - - void _onFloatingCursorChange(RawFloatingCursorPoint point) { - _floatingCursorController.updateFloatingCursor(_textContentKey.currentState!.textLayout, point); - } - - /// Handles actions from the IME - void _onPerformActionPressed(TextInputAction action) { - switch (action) { - case TextInputAction.done: - _focusNode.unfocus(); - break; - case TextInputAction.next: - _focusNode.nextFocus(); - break; - case TextInputAction.previous: - _focusNode.previousFocus(); - break; - default: - _log.warning("User pressed unhandled action button: $action"); - } - } - - /// Scrolls the ancestor [Scrollable], if any, so [SuperTextField] - /// is visible on the viewport when it's focused - void _autoScrollToKeepTextFieldVisible() { - // If we are not inside a [Scrollable] we don't autoscroll - final ancestorScrollable = _findAncestorScrollable(context); - if (ancestorScrollable == null) { - return; - } - - // Compute the text field offset that should be visible to the user - final textFieldFocalPoint = widget.maxLines == null && _textEditingController.selection.isValid - ? _textContentKey.currentState!.textLayout.getOffsetAtPosition( - TextPosition(offset: _textEditingController.selection.extentOffset), - ) - : Offset.zero; - - final lineHeight = _textContentKey.currentState!.textLayout.getLineHeightAtPosition( - TextPosition(offset: _textEditingController.selection.extentOffset), - ); - final fieldBox = context.findRenderObject() as RenderBox; - - // The area of the text field that should be revealed. - // We add a small margin to leave some space between the text field and the keyboard. - final textFieldFocalRect = Rect.fromLTWH( - textFieldFocalPoint.dx, - textFieldFocalPoint.dy, - fieldBox.size.width, - lineHeight + gapBetweenCaretAndKeyboard, - ); - - fieldBox.showOnScreen( - rect: textFieldFocalRect, - duration: _autoScrollAnimationDuration, - curve: _autoScrollAnimationCurve, - ); - } - - ScrollableState? _findAncestorScrollable(BuildContext context) { - final ancestorScrollable = Scrollable.of(context); - if (ancestorScrollable == null) { - return null; - } - - final direction = ancestorScrollable.axisDirection; - // If the direction is horizontal, then we are inside a widget like a TabBar - // or a horizontal ListView, so we can't use the ancestor scrollable - if (direction == AxisDirection.left || direction == AxisDirection.right) { - return null; - } - - return ancestorScrollable; - } - - @override - Widget build(BuildContext context) { - return NonReparentingFocus( - key: _textFieldKey, - focusNode: _focusNode, - child: CompositedTransformTarget( - link: _textFieldLayerLink, - child: IOSTextFieldTouchInteractor( - focusNode: _focusNode, - selectableTextKey: _textContentKey, - textFieldLayerLink: _textFieldLayerLink, - textController: _textEditingController, - editingOverlayController: _editingOverlayController, - textScrollController: _textScrollController, - isMultiline: _isMultiline, - handleColor: widget.handlesColor, - showDebugPaint: widget.showDebugPaint, - child: TextScrollView( - key: _scrollKey, - textScrollController: _textScrollController, - textKey: _textContentKey, - textEditingController: _textEditingController, - textAlign: widget.textAlign, - minLines: widget.minLines, - maxLines: widget.maxLines, - lineHeight: widget.lineHeight, - perLineAutoScrollDuration: const Duration(milliseconds: 100), - showDebugPaint: widget.showDebugPaint, - padding: widget.padding, - child: ListenableBuilder( - listenable: _textEditingController, - builder: (context) { - final isTextEmpty = _textEditingController.text.text.isEmpty; - final showHint = widget.hintBuilder != null && - ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || - (isTextEmpty && - !_focusNode.hasFocus && - widget.hintBehavior == HintBehavior.displayHintUntilFocus)); - - return CompositedTransformTarget( - link: _textContentLayerLink, - child: Stack( - children: [ - if (showHint) widget.hintBuilder!(context), - _buildSelectableText(), - Positioned( - left: 0, - top: 0, - right: 0, - bottom: 0, - child: IOSFloatingCursor( - controller: _floatingCursorController, - ), - ), - ], - ), - ); - }, - ), - ), - ), - ), - ); - } - - Widget _buildSelectableText() { - final textSpan = _textEditingController.text.text.isNotEmpty - ? _textEditingController.text.computeTextSpan(widget.textStyleBuilder) - : AttributedText(text: "").computeTextSpan(widget.textStyleBuilder); - - return FillWidthIfConstrained( - child: SuperTextWithSelection.single( - key: _textContentKey, - richText: textSpan, - textAlign: widget.textAlign, - userSelection: UserSelection( - highlightStyle: SelectionHighlightStyle( - color: widget.selectionColor, - ), - caretStyle: CaretStyle( - color: _floatingCursorController.isShowingFloatingCursor ? Colors.grey : widget.caretColor, - ), - selection: _textEditingController.selection, - hasCaret: _focusNode.hasFocus, - ), - ), - ); - } -} - -Widget _defaultPopoverToolbarBuilder(BuildContext context, IOSEditingOverlayController controller) { - return IOSTextEditingFloatingToolbar( - onCutPressed: () { - final textController = controller.textController; - final selection = textController.selection; - if (selection.isCollapsed) { - return; - } - - final selectedText = selection.textInside(textController.text.text); - - textController.deleteSelectedText(); - - Clipboard.setData(ClipboardData(text: selectedText)); - }, - onCopyPressed: () { - final textController = controller.textController; - final selection = textController.selection; - final selectedText = selection.textInside(textController.text.text); - - Clipboard.setData(ClipboardData(text: selectedText)); - }, - onPastePressed: () async { - final clipboardContent = await Clipboard.getData('text/plain'); - if (clipboardContent == null || clipboardContent.text == null) { - return; - } - - final textController = controller.textController; - final selection = textController.selection; - if (selection.isCollapsed) { - textController.insertAtCaret(text: clipboardContent.text!); - } else { - textController.replaceSelectionWithUnstyledText(replacementText: clipboardContent.text!); - } - }, - ); -} diff --git a/super_editor/lib/src/infrastructure/super_textfield/metrics.dart b/super_editor/lib/src/infrastructure/super_textfield/metrics.dart deleted file mode 100644 index 330fc0d1f0..0000000000 --- a/super_editor/lib/src/infrastructure/super_textfield/metrics.dart +++ /dev/null @@ -1,3 +0,0 @@ -/// Minimum distance that should be maintained between the bottom of the caret -/// and the software keyboard when editing a `SuperTextField`. -const gapBetweenCaretAndKeyboard = 30; \ No newline at end of file diff --git a/super_editor/lib/src/infrastructure/text_input.dart b/super_editor/lib/src/infrastructure/text_input.dart new file mode 100644 index 0000000000..95dd413959 --- /dev/null +++ b/super_editor/lib/src/infrastructure/text_input.dart @@ -0,0 +1,5 @@ +/// The mode of user text input. +enum TextInputSource { + keyboard, + ime, +} diff --git a/super_editor/lib/src/infrastructure/toolbar_position_delegate.dart b/super_editor/lib/src/infrastructure/toolbar_position_delegate.dart index b066e5a87e..4ac2571a33 100644 --- a/super_editor/lib/src/infrastructure/toolbar_position_delegate.dart +++ b/super_editor/lib/src/infrastructure/toolbar_position_delegate.dart @@ -1,19 +1,21 @@ import 'dart:math'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/widgets.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/super_textfield/metrics.dart'; final _log = textFieldLog; /// A [SingleChildLayoutDelegate] that interprets its child as a text editing /// toolbar and positions that toolbar either above [desiredTopAnchorInTextField], /// or below [desiredBottomAnchorInTextField]. -// TODO: offer optional padding from screen edges class ToolbarPositionDelegate extends SingleChildLayoutDelegate { ToolbarPositionDelegate({ required this.textFieldGlobalOffset, required this.desiredTopAnchorInTextField, required this.desiredBottomAnchorInTextField, + this.screenPadding, }); /// The global screen `Offset` of the text field, used to map local anchor @@ -45,17 +47,21 @@ class ToolbarPositionDelegate extends SingleChildLayoutDelegate { /// sits on the bottom of a line of text. final Offset desiredBottomAnchorInTextField; + /// Minimum space from the screen edges. + final EdgeInsets? screenPadding; + @override Offset getPositionForChild(Size size, Size childSize) { - final fitsAboveTextField = (textFieldGlobalOffset.dy + desiredTopAnchorInTextField.dy) > 100; + final heightNeeded = childSize.height + gapBetweenToolbarAndContent + (screenPadding?.top ?? 0.0); + final fitsAboveTextField = (textFieldGlobalOffset.dy + desiredTopAnchorInTextField.dy) > heightNeeded; final desiredAnchor = fitsAboveTextField ? desiredTopAnchorInTextField : (desiredBottomAnchorInTextField + Offset(0, childSize.height)); final desiredTopLeft = (desiredAnchor - Offset(childSize.width / 2, childSize.height)) + textFieldGlobalOffset; - double x = max(desiredTopLeft.dx, 0); - x = min(x, size.width - childSize.width); + double x = max(desiredTopLeft.dx, (screenPadding?.left ?? 0)); + x = min(x, size.width - childSize.width - (screenPadding?.right ?? 0)); final constrainedOffset = Offset(x, desiredTopLeft.dy); diff --git a/super_editor/lib/src/infrastructure/touch_controls.dart b/super_editor/lib/src/infrastructure/touch_controls.dart index fa4cfeff3f..410b4dc55b 100644 --- a/super_editor/lib/src/infrastructure/touch_controls.dart +++ b/super_editor/lib/src/infrastructure/touch_controls.dart @@ -1,3 +1,5 @@ +import 'package:flutter/widgets.dart'; + /// Type of handle for touch editing. enum HandleType { /// Handle at a specific document position for a collapsed selection. @@ -9,3 +11,15 @@ enum HandleType { /// Handle on the downstream side of an expanded selection. downstream, } + +/// Configuration used to display a toolbar. +class ToolbarConfig { + ToolbarConfig({ + required this.focalPoint, + }); + + /// The desired point where a toolbar arrow should point to. + /// + /// Represented as global coordinates. + final Offset focalPoint; +} diff --git a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart index 3f32817f18..cb69f21d20 100644 --- a/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_android_touch_interactor.dart @@ -1,18 +1,43 @@ +import 'dart:async'; import 'dart:math'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/document_operations/selection_operations.dart'; -import 'package:super_editor/src/infrastructure/document_gestures.dart'; -import 'package:super_editor/src/default_editor/document_gestures_touch.dart'; import 'package:super_editor/src/default_editor/document_gestures_touch_android.dart'; +import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/blinking_caret.dart'; +import 'package:super_editor/src/infrastructure/document_gestures.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/overlay_with_groups.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/android/android_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/long_press_selection.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/magnifier.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; +import 'package:super_editor/src/infrastructure/signal_notifier.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; +import 'package:super_editor/src/infrastructure/toolbar_position_delegate.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; +import 'package:super_editor/src/super_textfield/metrics.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +import '../default_editor/text_tools.dart'; /// Read-only document gesture interactor that's designed for Android touch input, e.g., /// drag to scroll, and handles to control selection. @@ -26,27 +51,40 @@ class ReadOnlyAndroidDocumentTouchInteractor extends StatefulWidget { const ReadOnlyAndroidDocumentTouchInteractor({ Key? key, required this.focusNode, - required this.document, + this.tapRegionGroupId, + required this.readerContext, required this.documentKey, required this.getDocumentLayout, - required this.selection, - this.scrollController, + required this.selectionLinks, + required this.scrollController, + this.contentTapHandler, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), required this.handleColor, required this.popoverToolbarBuilder, + required this.fillViewport, this.createOverlayControlsClipper, this.showDebugPaint = false, + this.overlayController, required this.child, }) : super(key: key); final FocusNode focusNode; - final Document document; + /// {@macro super_reader_tap_region_group_id} + final String? tapRegionGroupId; + + final DocumentContext readerContext; + final GlobalKey documentKey; final DocumentLayout Function() getDocumentLayout; - final ValueNotifier selection; - final ScrollController? scrollController; + final SelectionLayerLinks selectionLinks; + + /// Optional handler that responds to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + final ContentTapDelegate? contentTapHandler; + + final ScrollController scrollController; /// The closest that the user's selection drag gesture can get to the /// document boundary before auto-scrolling. @@ -69,6 +107,13 @@ class ReadOnlyAndroidDocumentTouchInteractor extends StatefulWidget { /// (probably the entire screen). final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; + /// Shows, hides, and positions a floating toolbar and magnifier. + final MagnifierAndToolbarController? overlayController; + + /// Whether the document gesture detector should fill the entire viewport + /// even if the actual content is smaller. + final bool fillViewport; + final bool showDebugPaint; final Widget child; @@ -79,10 +124,6 @@ class ReadOnlyAndroidDocumentTouchInteractor extends StatefulWidget { class _ReadOnlyAndroidDocumentTouchInteractorState extends State with WidgetsBindingObserver, SingleTickerProviderStateMixin { - // ScrollController used when this interactor installs its own Scrollable. - // The alternative case is the one in which this interactor defers to an - // ancestor scrollable. - late ScrollController _scrollController; // The ScrollPosition attached to the _ancestorScrollable, if there's an ancestor // Scrollable. ScrollPosition? _ancestorScrollPosition; @@ -90,12 +131,13 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State _longPressStrategy != null; + AndroidDocumentLongPressSelectionStrategy? _longPressStrategy; + final _longPressMagnifierGlobalOffset = ValueNotifier(null); + + final _interactor = GlobalKey(); + @override void initState() { super.initState(); @@ -122,30 +175,21 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State _updateScrollPositionListener()); } @override @@ -178,18 +220,28 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State _showEditingControlsOverlay()); } } @@ -228,14 +278,10 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State _updateHandlesAfterSelectionOrLayoutChange()); } void _updateHandlesAfterSelectionOrLayoutChange() { - final newSelection = widget.selection.value; + final newSelection = widget.readerContext.composer.selection; if (newSelection == null) { _editingController @@ -362,7 +418,7 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State _ancestorScrollPosition ?? _scrollController.position; + ScrollPosition get scrollPosition => _ancestorScrollPosition ?? widget.scrollController.position; /// Returns the `RenderBox` for the scrolling viewport. /// @@ -372,13 +428,20 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State - (_findAncestorScrollable(context)?.context.findRenderObject() ?? context.findRenderObject()) as RenderBox; + RenderBox get viewportBox => context.findViewportBox(); - /// Converts the given [offset] from the [DocumentInteractor]'s coordinate + /// Returns the render box for the interactor gesture detector. + RenderBox get interactorBox => _interactor.currentContext!.findRenderObject() as RenderBox; + + Offset _getDocumentOffsetFromGlobalOffset(Offset globalOffset) { + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); + } + + /// Converts the given [interactorOffset] from the [DocumentInteractor]'s coordinate /// space to the [DocumentLayout]'s coordinate space. - Offset _getDocOffset(Offset offset) { - return _docLayout.getDocumentOffsetFromAncestorOffset(offset, context.findRenderObject()!); + Offset _interactorOffsetToDocOffset(Offset interactorOffset) { + final globalOffset = interactorBox.localToGlobal(interactorOffset); + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); } /// Maps the given [interactorOffset] within the interactor's coordinate space @@ -392,30 +455,113 @@ class _ReadOnlyAndroidDocumentTouchInteractorState extends State{ + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapCancel = _onTapCancel + ..onTapUp = _onTapUp + ..onDoubleTapDown = _onDoubleTapDown + ..onTripleTapDown = _onTripleTapDown + ..gestureSettings = gestureSettings; + }, + ), + }, + ), + widget.child, + // Layer above + OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: _buildControlsOverlay, + child: RawGestureDetector( + key: _interactor, + behavior: HitTestBehavior.translucent, + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer recognizer) { + recognizer + ..shouldAccept = () { + if (_globalTapDownOffset == null) { + return false; + } + return _isLongPressInProgress; + } + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + ), + ), + ], + ); + } + + Widget _buildControlsOverlay(BuildContext context) { + return TapRegion( + groupId: widget.tapRegionGroupId, + child: ListenableBuilder( + listenable: _overlayPortalRebuildSignal, + builder: (context, child) { + return AndroidDocumentTouchEditingControls( + editingController: _editingController, + documentKey: widget.documentKey, + documentLayout: _docLayout, + createOverlayControlsClipper: widget.createOverlayControlsClipper, + handleColor: widget.handleColor, + onHandleDragStart: _onHandleDragStart, + onHandleDragUpdate: _onHandleDragUpdate, + onHandleDragEnd: _onHandleDragEnd, + popoverToolbarBuilder: widget.popoverToolbarBuilder, + longPressMagnifierGlobalOffset: _longPressMagnifierGlobalOffset, + showDebugPaint: false, + ); + }, + ), + ); + } +} + +typedef SelectionChanger = void Function(DocumentSelection? newSelection); + +// TODO: This was moved here from the SuperEditor side. We've removed the need for this from SuperEditor, remove it from SuperReader, too. +class AndroidDocumentTouchEditingControls extends StatefulWidget { + const AndroidDocumentTouchEditingControls({ + Key? key, + required this.editingController, + required this.documentKey, + required this.documentLayout, + required this.handleColor, + this.onHandleDragStart, + this.onHandleDragUpdate, + this.onHandleDragEnd, + required this.popoverToolbarBuilder, + this.createOverlayControlsClipper, + required this.longPressMagnifierGlobalOffset, + this.showDebugPaint = false, + }) : super(key: key); + + final AndroidDocumentGestureEditingController editingController; + + final GlobalKey documentKey; + + final DocumentLayout documentLayout; + + /// Creates a clipper that applies to overlay controls, preventing + /// the overlay controls from appearing outside the given clipping + /// region. + /// + /// If no clipper factory method is provided, then the overlay controls + /// will be allowed to appear anywhere in the overlay in which they sit + /// (probably the entire screen). + final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; + + /// The color of the Android-style drag handles. + final Color handleColor; + + final void Function(HandleType handleType, Offset globalOffset)? onHandleDragStart; + + final void Function(Offset globalOffset)? onHandleDragUpdate; + + final void Function()? onHandleDragEnd; + + /// Builder that constructs the popover toolbar that's displayed above + /// selected text. + /// + /// Typically, this bar includes actions like "copy", "cut", "paste", etc. + final WidgetBuilder popoverToolbarBuilder; + + final ValueNotifier longPressMagnifierGlobalOffset; + + final bool showDebugPaint; + + @override + State createState() => _AndroidDocumentTouchEditingControlsState(); +} + +class _AndroidDocumentTouchEditingControlsState extends State + with SingleTickerProviderStateMixin { + // These global keys are assigned to each draggable handle to + // prevent a strange dragging issue. + // + // Without these keys, if the user drags into the auto-scroll area + // of the text field for a period of time, we never receive a + // "pan end" or "pan cancel" callback. I have no idea why this is + // the case. These handles sit in an Overlay, so it's not as if they + // suffered some conflict within a ScrollView. I tried many adjustments + // to recover the end/cancel callbacks. Finally, I tried adding these + // global keys based on a hunch that perhaps the gesture detector was + // somehow getting switched out, or assigned to a different widget, and + // that was somehow disrupting the callback series. For now, these keys + // seem to solve the problem. + final _collapsedHandleKey = GlobalKey(); + final _upstreamHandleKey = GlobalKey(); + final _downstreamHandleKey = GlobalKey(); + + bool _isDraggingExpandedHandle = false; + bool _isDraggingHandle = false; + Offset? _localDragOffset; + + late BlinkController _caretBlinkController; + Offset? _prevCaretOffset; + + @override + void initState() { + super.initState(); + _caretBlinkController = BlinkController(tickerProvider: this); + _prevCaretOffset = widget.editingController.caretTop; + widget.editingController.addListener(_onEditingControllerChange); + + if (widget.editingController.shouldDisplayCollapsedHandle) { + widget.editingController.startCollapsedHandleAutoHideCountdown(); } } - ScrollableState? _findAncestorScrollable(BuildContext context) { - final ancestorScrollable = Scrollable.of(context); - if (ancestorScrollable == null) { - return null; + @override + void didUpdateWidget(AndroidDocumentTouchEditingControls oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.editingController != oldWidget.editingController) { + oldWidget.editingController.removeListener(_onEditingControllerChange); + widget.editingController.addListener(_onEditingControllerChange); } + } + + @override + void dispose() { + widget.editingController.removeListener(_onEditingControllerChange); + _caretBlinkController.dispose(); + super.dispose(); + } + + void _onEditingControllerChange() { + if (_prevCaretOffset != widget.editingController.caretTop) { + if (widget.editingController.caretTop == null) { + _caretBlinkController.stopBlinking(); + } else { + _caretBlinkController.jumpToOpaque(); + } - final direction = ancestorScrollable.axisDirection; - // If the direction is horizontal, then we are inside a widget like a TabBar - // or a horizontal ListView, so we can't use the ancestor scrollable - if (direction == AxisDirection.left || direction == AxisDirection.right) { - return null; + _prevCaretOffset = widget.editingController.caretTop; } + } + + void _onCollapsedPanStart(DragStartDetails details) { + editorGesturesLog.fine('_onCollapsedPanStart'); + + setState(() { + _isDraggingExpandedHandle = false; + _isDraggingHandle = true; + // We map global to local instead of using details.localPosition because + // this drag event started in a handle, not within this overall widget. + _localDragOffset = (context.findRenderObject() as RenderBox).globalToLocal(details.globalPosition); + }); + + widget.onHandleDragStart?.call(HandleType.collapsed, details.globalPosition); + } + + void _onUpstreamHandlePanStart(DragStartDetails details) { + _onExpandedHandleDragStart(details); + widget.onHandleDragStart?.call(HandleType.upstream, details.globalPosition); + } + + void _onDownstreamHandlePanStart(DragStartDetails details) { + _onExpandedHandleDragStart(details); + widget.onHandleDragStart?.call(HandleType.downstream, details.globalPosition); + } - return ancestorScrollable; + void _onExpandedHandleDragStart(DragStartDetails details) { + setState(() { + _isDraggingExpandedHandle = true; + _isDraggingHandle = true; + // We map global to local instead of using details.localPosition because + // this drag event started in a handle, not within this overall widget. + _localDragOffset = (context.findRenderObject() as RenderBox).globalToLocal(details.globalPosition); + }); + } + + void _onPanUpdate(DragUpdateDetails details) { + editorGesturesLog.fine('_onPanUpdate'); + + widget.onHandleDragUpdate?.call(details.globalPosition); + + setState(() { + _localDragOffset = _localDragOffset! + details.delta; + }); + } + + void _onPanEnd(DragEndDetails details) { + editorGesturesLog.fine('_onPanEnd'); + _onHandleDragEnd(); + } + + void _onPanCancel() { + editorGesturesLog.fine('_onPanCancel'); + _onHandleDragEnd(); + } + + void _onHandleDragEnd() { + editorGesturesLog.fine('_onHandleDragEnd()'); + + // TODO: ensure that extent is visible + + setState(() { + _isDraggingExpandedHandle = false; + _isDraggingHandle = false; + _localDragOffset = null; + }); + + widget.onHandleDragEnd?.call(); } @override Widget build(BuildContext context) { - return _buildGestureInput( - child: ScrollableDocument( - scrollController: _scrollController, - documentLayerLink: _documentLayoutLink, - child: widget.child, + return ListenableBuilder( + listenable: widget.editingController, + builder: (context, _) { + return Padding( + // Remove the keyboard from the space that we occupy so that + // clipping calculations apply to the expected visual borders, + // instead of applying underneath the keyboard. + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: ClipRect( + clipper: widget.createOverlayControlsClipper?.call(context), + child: SizedBox( + // ^ SizedBox tries to be as large as possible, because + // a Stack will collapse into nothing unless something + // expands it. + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + // Build the caret + _buildCaret(), + // Build the drag handles (if desired). + // We don't show handles on web because the browser already displays the native handles. + if (!CurrentPlatform.isWeb) // + ..._buildHandles(), + // Build the focal point for the magnifier + if (_isDraggingHandle || widget.longPressMagnifierGlobalOffset.value != null) + _buildMagnifierFocalPoint(), + // Build the magnifier (this needs to be done before building + // the handles so that the magnifier doesn't show the handles. + // We don't show magnifier on web because the browser already displays the native magnifier. + if (!CurrentPlatform.isWeb && widget.editingController.shouldDisplayMagnifier) _buildMagnifier(), + // Build the editing toolbar. + // We don't show toolbar on web because the browser already displays the native toolbar. + if (!CurrentPlatform.isWeb && + widget.editingController.shouldDisplayToolbar && + widget.editingController.isToolbarPositioned) + _buildToolbar(context), + // Build a UI that's useful for debugging, if desired. + if (widget.showDebugPaint) + IgnorePointer( + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.yellow.withValues(alpha: 0.2), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildCaret() { + if (!widget.editingController.hasCaret) { + return const SizedBox(); + } + + return Follower.withOffset( + link: widget.editingController.selectionLinks.caretLink, + leaderAnchor: Alignment.topCenter, + followerAnchor: Alignment.topCenter, + showWhenUnlinked: false, + child: IgnorePointer( + child: BlinkingCaret( + controller: _caretBlinkController, + caretOffset: const Offset(0, 0), + caretHeight: widget.editingController.caretHeight!, + width: 2, + color: widget.showDebugPaint ? Colors.green : widget.handleColor, + borderRadius: BorderRadius.zero, + isTextEmpty: false, + showCaret: true, + ), ), ); } - Widget _buildGestureInput({ - required Widget child, + List _buildHandles() { + if (!widget.editingController.shouldDisplayCollapsedHandle && + !widget.editingController.shouldDisplayExpandedHandles) { + editorGesturesLog.finer('Not building overlay handles because there is no selection'); + // There is no selection. Draw nothing. + return []; + } + + if (widget.editingController.shouldDisplayCollapsedHandle && !_isDraggingExpandedHandle) { + // Note: we don't build the collapsed handle if we're currently dragging + // the base or extent because, if we did, then when the user drags + // crosses the base and extent, we'd suddenly jump from an expanded + // selection to a collapsed selection. + return [ + _buildCollapsedHandle(), + ]; + } else { + return _buildExpandedHandles(); + } + } + + Widget _buildCollapsedHandle() { + return _buildHandle( + handleKey: _collapsedHandleKey, + handleLink: widget.editingController.selectionLinks.caretLink, + leaderAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + handleOffset: const Offset(-0.5, 5), // Chosen experimentally + handleType: HandleType.collapsed, + debugColor: Colors.green, + onPanStart: _onCollapsedPanStart, + ); + } + + List _buildExpandedHandles() { + return [ + // upstream-bounding (left side of a RTL line of text) handle touch target + _buildHandle( + handleKey: _upstreamHandleKey, + handleLink: widget.editingController.selectionLinks.upstreamLink, + leaderAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topRight, + handleOffset: const Offset(0, 2), // Chosen experimentally + handleType: HandleType.upstream, + debugColor: Colors.green, + onPanStart: _onUpstreamHandlePanStart, + ), + // downstream-bounding (right side of a RTL line of text) handle touch target + _buildHandle( + handleKey: _downstreamHandleKey, + handleLink: widget.editingController.selectionLinks.downstreamLink, + leaderAnchor: Alignment.bottomRight, + followerAnchor: Alignment.topLeft, + handleOffset: const Offset(-1, 2), // Chosen experimentally + handleType: HandleType.downstream, + debugColor: Colors.red, + onPanStart: _onDownstreamHandlePanStart, + ), + ]; + } + + Widget _buildHandle({ + required Key handleKey, + required LeaderLink handleLink, + required Alignment leaderAnchor, + required Alignment followerAnchor, + Offset? handleOffset, + Offset handleFractionalTranslation = Offset.zero, + required HandleType handleType, + required Color debugColor, + required void Function(DragStartDetails) onPanStart, }) { - return RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapUp = _onTapUp - ..onDoubleTapDown = _onDoubleTapDown - ..onTripleTapDown = _onTripleTapDown; - }, + // Use the offset to account for the invisible expanded touch region around the handle. + final expandedTapRegionOffset = AndroidSelectionHandle.defaultTouchRegionExpansion.topRight * + MediaQuery.devicePixelRatioOf(context) * + (handleType == HandleType.upstream ? -1 : 1); + + return Follower.withOffset( + key: handleKey, + link: handleLink, + leaderAnchor: leaderAnchor, + followerAnchor: followerAnchor, + offset: (handleOffset ?? Offset.zero) + expandedTapRegionOffset, + showWhenUnlinked: false, + child: FractionalTranslation( + translation: handleFractionalTranslation, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onPanCancel: _onPanCancel, + child: Container( + color: widget.showDebugPaint ? Colors.green : Colors.transparent, + child: AnimatedOpacity( + opacity: handleType == HandleType.collapsed && widget.editingController.isCollapsedHandleAutoHidden + ? 0.0 + : 1.0, + duration: const Duration(milliseconds: 150), + child: AndroidSelectionHandle( + handleType: handleType, + color: widget.handleColor, + ), + ), + ), ), - }, - child: child, + ), + ); + } + + Widget _buildMagnifierFocalPoint() { + late Offset magnifierOffset; + if (widget.longPressMagnifierGlobalOffset.value != null) { + // The user is long-pressing, the magnifier should go at the selection + // extent. + magnifierOffset = widget.longPressMagnifierGlobalOffset.value!; + } else { + // The user is dragging a handle. The magnifier should go wherever the user + // places his finger. + // + // Also, pull the magnifier up a little bit because the Android drag handles + // sit below the content they refer to. + magnifierOffset = _localDragOffset! - const Offset(0, 20); + } + + // When the user is dragging a handle in this overlay, we + // are responsible for positioning the focal point for the + // magnifier to follow. We do that here. + return Positioned( + left: magnifierOffset.dx, + // TODO: select focal position based on type of content + top: magnifierOffset.dy, + child: Leader( + link: widget.editingController.magnifierFocalPointLink, + child: const SizedBox(width: 1, height: 1), + ), + ); + } + + Widget _buildMagnifier() { + // Display a magnifier that tracks a focal point. + // + // When the user is dragging an overlay handle, we place a LayerLink + // target. This magnifier follows that target. + return AndroidFollowingMagnifier( + layerLink: widget.editingController.magnifierFocalPointLink, + offsetFromFocalPoint: Offset(0, -54 * MediaQuery.devicePixelRatioOf(context)), + ); + } + + Widget _buildToolbar(BuildContext context) { + // TODO: figure out why this approach works. Why isn't the text field's + // RenderBox offset stale when the keyboard opens or closes? Shouldn't + // we end up with the previous offset because no rebuild happens? + // + // Disproven theory: CompositedTransformFollower's link causes a rebuild of its + // subtree whenever the linked transform changes. + // + // Theory: + // - Keyboard only effects vertical offsets, so global x offset + // was never at risk + // - The global y offset isn't used in the calculation at all + // - If this same approach were used in a situation where the + // distance between the left edge of the available space and the + // text field changed, I think it would fail. + return CustomSingleChildLayout( + delegate: ToolbarPositionDelegate( + // TODO: handle situation where document isn't full screen + textFieldGlobalOffset: Offset.zero, + desiredTopAnchorInTextField: widget.editingController.toolbarTopAnchor!, //toolbarTopAnchor, + desiredBottomAnchorInTextField: widget.editingController.toolbarBottomAnchor!, //toolbarBottomAnchor, + screenPadding: widget.editingController.screenPadding, + ), + child: IgnorePointer( + ignoring: !widget.editingController.shouldDisplayToolbar, + child: AnimatedOpacity( + opacity: widget.editingController.shouldDisplayToolbar ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + child: Builder(builder: widget.popoverToolbarBuilder), + ), + ), ); } } diff --git a/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart index 599743889f..13bcd52b6a 100644 --- a/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_ios_touch_interactor.dart @@ -1,53 +1,219 @@ -import 'dart:math'; +import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/document_operations/selection_operations.dart'; -import 'package:super_editor/src/infrastructure/document_gestures.dart'; -import 'package:super_editor/src/default_editor/document_gestures_touch.dart'; import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/document_gestures.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/flutter/eager_pan_gesture_recognizer.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/long_press_selection.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.dart'; import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; +import 'package:super_editor/src/super_reader/reader_context.dart'; +import 'package:super_editor/src/super_reader/super_reader.dart'; + +/// An [InheritedWidget] that provides shared access to a [SuperReaderIosControlsController], +/// which coordinates the state of iOS controls like drag handles, magnifier, and toolbar. +/// +/// This widget and its associated controller exist so that [SuperReader] has maximum freedom +/// in terms of where to implement iOS gestures vs handles vs the magnifier vs the toolbar. +/// Each of these responsibilities have some unique differences, which make them difficult +/// or impossible to implement within a single widget. By sharing a controller, a group of +/// independent widgets can work together to cover those various responsibilities. +/// +/// Centralizing a controller in an [InheritedWidget] also allows [SuperReader] to share that +/// control with application code outside of [SuperReader], by placing an [SuperReaderIosControlsScope] +/// above the [SuperReader] in the widget tree. For this reason, [SuperReader] should access +/// the [SuperReaderIosControlsScope] through [rootOf]. +class SuperReaderIosControlsScope extends InheritedWidget { + /// Finds the highest [SuperReaderIosControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperReaderIosControlsController]. + static SuperReaderIosControlsController rootOf(BuildContext context) { + final data = maybeRootOf(context); + + if (data == null) { + throw Exception("Tried to depend upon the root IosReaderControlsScope but no such ancestor widget exists."); + } + + return data; + } + + static SuperReaderIosControlsController? maybeRootOf(BuildContext context) { + InheritedElement? root; + + context.visitAncestorElements((element) { + if (element is! InheritedElement || element.widget is! SuperReaderIosControlsScope) { + // Keep visiting. + return true; + } + + root = element; + + // Keep visiting, to ensure we get the root scope. + return true; + }); + + if (root == null) { + return null; + } + + // Create build dependency on the iOS controls context. + context.dependOnInheritedElement(root!); + + // Return the current iOS controls data. + return (root!.widget as SuperReaderIosControlsScope).controller; + } + + /// Finds the nearest [SuperReaderIosControlsScope] in the widget tree, above the given + /// [context], and returns its associated [SuperReaderIosControlsController]. + static SuperReaderIosControlsController nearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()!.controller; + + static SuperReaderIosControlsController? maybeNearestOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.controller; + + const SuperReaderIosControlsScope({ + super.key, + required this.controller, + required super.child, + }); + + final SuperReaderIosControlsController controller; + + @override + bool updateShouldNotify(SuperReaderIosControlsScope oldWidget) { + return controller != oldWidget.controller; + } +} + +/// A controller, which coordinates the state of various iOS reader controls, including +/// drag handles, magnifier, and toolbar. +class SuperReaderIosControlsController { + SuperReaderIosControlsController({ + this.handleColor, + this.magnifierBuilder, + this.toolbarBuilder, + this.createOverlayControlsClipper, + }); + + void dispose() { + _shouldShowMagnifier.dispose(); + _shouldShowToolbar.dispose(); + } + + /// Color of the text selection drag handles on iOS. + final Color? handleColor; + + /// Whether the iOS magnifier should be displayed right now. + ValueListenable get shouldShowMagnifier => _shouldShowMagnifier; + final _shouldShowMagnifier = ValueNotifier(false); + + /// Shows the magnifier by setting [shouldShowMagnifier] to `true`. + void showMagnifier() => _shouldShowMagnifier.value = true; + + /// Hides the magnifier by setting [shouldShowMagnifier] to `false`. + void hideMagnifier() => _shouldShowMagnifier.value = false; + + /// Toggles [shouldShowMagnifier]. + void toggleMagnifier() => _shouldShowMagnifier.value = !_shouldShowMagnifier.value; + + /// Link to a location where a magnifier should be focused. + final magnifierFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the magnifier. + /// + /// If [magnifierBuilder] is `null`, a default iOS magnifier is displayed. + final DocumentMagnifierBuilder? magnifierBuilder; + + /// Whether the iOS floating toolbar should be displayed right now. + ValueListenable get shouldShowToolbar => _shouldShowToolbar; + final _shouldShowToolbar = ValueNotifier(false); + + /// Shows the toolbar by setting [shouldShowToolbar] to `true`. + void showToolbar() => _shouldShowToolbar.value = true; + + /// Hides the toolbar by setting [shouldShowToolbar] to `false`. + void hideToolbar() => _shouldShowToolbar.value = false; + + /// Toggles [shouldShowToolbar]. + void toggleToolbar() => _shouldShowToolbar.value = !_shouldShowToolbar.value; + + /// Link to a location where a toolbar should be focused. + /// + /// This link probably points to a rectangle, such as a bounding rectangle + /// around the user's selection. Therefore, the toolbar builder shouldn't + /// assume that this focal point is a single pixel. + final toolbarFocalPoint = LeaderLink(); + + /// (Optional) Builder to create the visual representation of the floating + /// toolbar. + /// + /// If [toolbarBuilder] is `null`, a default iOS toolbar is displayed. + final DocumentFloatingToolbarBuilder? toolbarBuilder; + + /// Creates a clipper that restricts where the toolbar and magnifier can + /// appear in the overlay. + /// + /// If no clipper factory method is provided, then the overlay controls + /// will be allowed to appear anywhere in the overlay in which they sit + /// (probably the entire screen). + final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; +} /// Document gesture interactor that's designed for iOS touch input, e.g., -/// drag to scroll, and handles to control selection. +/// drag to scroll, double and triple tap to select content, and drag +/// selection ends to expand selection. /// /// The primary difference between a read-only touch interactor, and an /// editing touch interactor, is that read-only documents don't support /// collapsed selections, i.e., caret display. When the user taps on /// a read-only document, nothing happens. The user must drag an expanded /// selection, or double/triple tap to select content. -class ReadOnlyIOSDocumentTouchInteractor extends StatefulWidget { - const ReadOnlyIOSDocumentTouchInteractor({ +class SuperReaderIosDocumentTouchInteractor extends StatefulWidget { + const SuperReaderIosDocumentTouchInteractor({ Key? key, required this.focusNode, - required this.document, + required this.readerContext, required this.documentKey, required this.getDocumentLayout, - required this.selection, - this.scrollController, + required this.scrollController, + required this.fillViewport, + this.contentTapHandler, this.dragAutoScrollBoundary = const AxisOffset.symmetric(54), - required this.handleColor, - required this.popoverToolbarBuilder, - this.createOverlayControlsClipper, this.showDebugPaint = false, required this.child, }) : super(key: key); final FocusNode focusNode; - final Document document; + + final DocumentContext readerContext; + final GlobalKey documentKey; final DocumentLayout Function() getDocumentLayout; - final ValueNotifier selection; + final ScrollController scrollController; - final ScrollController? scrollController; + /// Optional handler that responds to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + final ContentTapDelegate? contentTapHandler; /// The closest that the user's selection drag gesture can get to the /// document boundary before auto-scrolling. @@ -56,46 +222,24 @@ class ReadOnlyIOSDocumentTouchInteractor extends StatefulWidget { /// edges. final AxisOffset dragAutoScrollBoundary; - /// Color the iOS-style text selection drag handles. - final Color handleColor; - - final WidgetBuilder popoverToolbarBuilder; - - /// Creates a clipper that applies to overlay controls, preventing - /// the overlay controls from appearing outside the given clipping - /// region. - /// - /// If no clipper factory method is provided, then the overlay controls - /// will be allowed to appear anywhere in the overlay in which they sit - /// (probably the entire screen). - final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; + /// Whether the document gesture detector should fill the entire viewport + /// even if the actual content is smaller. + final bool fillViewport; final bool showDebugPaint; final Widget child; @override - State createState() => _ReadOnlyIOSDocumentTouchInteractorState(); + State createState() => _SuperReaderIosDocumentTouchInteractorState(); } -class _ReadOnlyIOSDocumentTouchInteractorState extends State +class _SuperReaderIosDocumentTouchInteractorState extends State with WidgetsBindingObserver, SingleTickerProviderStateMixin { - // ScrollController used when this interactor installs its own Scrollable. - // The alternative case is the one in which this interactor defers to an - // ancestor scrollable. - late ScrollController _scrollController; // The ScrollPosition attached to the _ancestorScrollable. ScrollPosition? _ancestorScrollPosition; - // The actual ScrollPosition that's used for the document layout, either - // the Scrollable installed by this interactor, or an ancestor Scrollable. - ScrollPosition? _activeScrollPosition; - // OverlayEntry that displays editing controls, e.g., - // drag handles, magnifier, and toolbar. - OverlayEntry? _controlsOverlayEntry; - late IosDocumentGestureEditingController _editingController; - final _documentLayerLink = LayerLink(); - final _magnifierFocalPointLink = LayerLink(); + SuperReaderIosControlsController? _controlsController; late DragHandleAutoScroller _handleAutoScrolling; Offset? _globalStartDragOffset; @@ -105,25 +249,19 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State(null); + + Timer? _tapDownLongPressTimer; + Offset? _globalTapDownOffset; + bool get _isLongPressInProgress => _longPressStrategy != null; + IosLongPressSelectionStrategy? _longPressStrategy; + + final _interactor = GlobalKey(); @override void initState() { @@ -136,30 +274,11 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State viewportBox, ); - widget.focusNode.addListener(_onFocusChange); - if (widget.focusNode.hasFocus) { - _showEditingControlsOverlay(); - } - - _scrollController = _scrollController = (widget.scrollController ?? ScrollController()); - // I added this listener directly to our ScrollController because the listener we added - // to the ScrollPosition wasn't triggering once the user makes an initial selection. I'm - // not sure why that happened. It's as if the ScrollPosition was replaced, but I don't - // know why the ScrollPosition would be replaced. In the meantime, adding this listener - // keeps the toolbar positioning logic working. - // TODO: rely solely on a ScrollPosition listener, not a ScrollController listener. - _scrollController.addListener(_onScrollChange); - - _editingController = IosDocumentGestureEditingController( - documentLayoutLink: _documentLayerLink, - magnifierFocalPointLink: _magnifierFocalPointLink, - ); - - widget.document.addListener(_onDocumentChange); + widget.readerContext.document.addListener(_onDocumentChange); - widget.selection.addListener(_onSelectionChange); - // If we already have a selection, we need to display the caret. - if (widget.selection.value != null) { + widget.readerContext.composer.selectionNotifier.addListener(_onSelectionChange); + // If we already have a selection, we may need to display drag handles. + if (widget.readerContext.composer.selection != null) { _onSelectionChange(); } @@ -170,147 +289,72 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State _updateHandlesAfterSelectionOrLayoutChange()); } void _updateHandlesAfterSelectionOrLayoutChange() { - final newSelection = widget.selection.value; + final newSelection = widget.readerContext.composer.selection; if (newSelection == null) { - _editingController - ..removeCaret() - ..hideToolbar() - ..collapsedHandleOffset = null - ..upstreamHandleOffset = null - ..downstreamHandleOffset = null - ..collapsedHandleOffset = null; - } else if (!newSelection.isCollapsed) { - _positionExpandedSelectionHandles(); + _controlsController!.hideToolbar(); } } - void _onScrollChange() { - _positionToolbar(); - } - /// Returns the layout for the current document, which answers questions /// about the locations and sizes of visual components within the layout. DocumentLayout get _docLayout => widget.getDocumentLayout(); @@ -363,7 +393,7 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State _ancestorScrollPosition ?? _scrollController.position; + ScrollPosition get scrollPosition => _ancestorScrollPosition ?? widget.scrollController.position; /// Returns the `RenderBox` for the scrolling viewport. /// @@ -373,21 +403,16 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State - (_findAncestorScrollable(context)?.context.findRenderObject() ?? context.findRenderObject()) as RenderBox; + RenderBox get viewportBox => context.findViewportBox(); - RenderBox get interactorBox => context.findRenderObject() as RenderBox; + /// Returns the render box for the interactor gesture detector. + RenderBox get interactorBox => _interactor.currentContext!.findRenderObject() as RenderBox; /// Converts the given [interactorOffset] from the [DocumentInteractor]'s coordinate /// space to the [DocumentLayout]'s coordinate space. - Offset _interactorOffsetToDocOffset(Offset interactorOffset) { - return _docLayout.getDocumentOffsetFromAncestorOffset(interactorOffset, context.findRenderObject()!); - } - - /// Converts the given [documentOffset] to an `Offset` in the interactor's - /// coordinate space. - Offset _docOffsetToInteractorOffset(Offset documentOffset) { - return _docLayout.getAncestorOffsetFromDocumentOffset(documentOffset, context.findRenderObject()!); + Offset _interactorOffsetToDocumentOffset(Offset interactorOffset) { + final globalOffset = interactorBox.localToGlobal(interactorOffset); + return _docLayout.getDocumentOffsetFromAncestorOffset(globalOffset); } /// Maps the given [interactorOffset] within the interactor's coordinate space @@ -406,45 +431,113 @@ class _ReadOnlyIOSDocumentTouchInteractorState extends State 1) { + // During Hot Reload, if the gesture mode was changed, + // the widget might be built while the old gesture interactor + // scroller is still attached to the _scrollController. + // + // Defer adding the listener to the next frame. + scheduleBuildAfterBuild(); + } } - const toolbarGap = 24.0; - late Rect selectionRect; - Offset toolbarTopAnchor; - Offset toolbarBottomAnchor; - - final baseRectInDoc = _docLayout.getRectForPosition(selection.base)!; - final extentRectInDoc = _docLayout.getRectForPosition(selection.extent)!; - final selectionRectInDoc = Rect.fromPoints( - Offset( - min(baseRectInDoc.left, extentRectInDoc.left), - min(baseRectInDoc.top, extentRectInDoc.top), - ), - Offset( - max(baseRectInDoc.right, extentRectInDoc.right), - max(baseRectInDoc.bottom, extentRectInDoc.bottom), - ), - ); - selectionRect = Rect.fromPoints( - _docLayout.getGlobalOffsetFromDocumentOffset(selectionRectInDoc.topLeft), - _docLayout.getGlobalOffsetFromDocumentOffset(selectionRectInDoc.bottomRight), + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + // PanGestureRecognizer is above contents to have first pass at gestures, but it only accepts + // gestures that are over caret or handles or when a long press is in progress. + // TapGestureRecognizer is below contents so that it doesn't interferes with buttons and other + // tappable widgets. + return SliverHybridStack( + fillViewport: widget.fillViewport, + children: [ + // Layer below + RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapCancel = _onTapCancel + ..onTapUp = _onTapUp + ..onDoubleTapUp = _onDoubleTapUp + ..onTripleTapUp = _onTripleTapUp + ..gestureSettings = gestureSettings; + }, + ), + }, + ), + widget.child, + // Layer above + RawGestureDetector( + key: _interactor, + behavior: HitTestBehavior.translucent, + gestures: { + EagerPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerPanGestureRecognizer(), + (EagerPanGestureRecognizer instance) { + instance + ..shouldAccept = () { + if (_globalTapDownOffset == null) { + return false; + } + final panDown = interactorBox.globalToLocal(_globalTapDownOffset!); + final isOverHandle = _isOverBaseHandle(panDown) || _isOverExtentHandle(panDown); + return isOverHandle || _isLongPressInProgress; + } + ..dragStartBehavior = DragStartBehavior.down + ..onDown = _onPanDown + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + child: Stack( + children: [ + _buildMagnifierFocalPoint(), + ], + ), + ), + ], ); + } + + Widget _buildMagnifierFocalPoint() { + return ValueListenableBuilder( + valueListenable: _magnifierFocalPoint, + builder: (context, magnifierOffset, child) { + if (magnifierOffset == null) { + return const SizedBox(); + } - // TODO: fix the horizontal placement - // The logic to position the toolbar horizontally is wrong. - // The toolbar should appear horizontally centered between the - // left-most and right-most edge of the selection. However, the - // left-most and right-most edge of the selection may not match - // the handle locations. Consider the situation where multiple - // lines/blocks of content are selected, but both handles sit near - // the left side of the screen. This logic will position the - // toolbar near the left side of the content, when the toolbar should - // instead be centered across the full width of the document. - toolbarTopAnchor = selectionRect.topCenter - const Offset(0, toolbarGap); - toolbarBottomAnchor = selectionRect.bottomCenter + const Offset(0, toolbarGap); - - _editingController.positionToolbar( - topAnchor: toolbarTopAnchor, - bottomAnchor: toolbarBottomAnchor, + // When the user is dragging a handle in this overlay, we + // are responsible for positioning the focal point for the + // magnifier to follow. We do that here. + return Positioned( + left: magnifierOffset.dx, + top: magnifierOffset.dy, + child: Leader( + link: _controlsController!.magnifierFocalPoint, + child: const SizedBox(width: 1, height: 1), + ), + ); + }, ); } +} - void _removeEditingOverlayControls() { - if (_controlsOverlayEntry != null) { - _controlsOverlayEntry!.remove(); - _controlsOverlayEntry = null; - } - } +/// Adds and removes an iOS-style editor toolbar, as dictated by an ancestor +/// [SuperReaderIosControlsScope]. +class SuperReaderIosToolbarOverlayManager extends StatefulWidget { + const SuperReaderIosToolbarOverlayManager({ + super.key, + this.tapRegionGroupId, + this.defaultToolbarBuilder, + this.child, + }); - void _selectWordAtCaret() { - final docSelection = widget.selection.value; - if (docSelection == null) { - return; - } + /// {@macro super_reader_tap_region_group_id} + final String? tapRegionGroupId; - selectWordAt( - docPosition: docSelection.extent, - docLayout: _docLayout, - selection: widget.selection, - ); + final DocumentFloatingToolbarBuilder? defaultToolbarBuilder; + + final Widget? child; + + @override + State createState() => SuperReaderIosToolbarOverlayManagerState(); +} + +@visibleForTesting +class SuperReaderIosToolbarOverlayManagerState extends State { + final OverlayPortalController _overlayPortalController = OverlayPortalController(); + SuperReaderIosControlsController? _controlsContext; + + @visibleForTesting + bool get wantsToDisplayToolbar => _controlsContext!.shouldShowToolbar.value; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsContext = SuperReaderIosControlsScope.rootOf(context); + + // It's possible that `didChangeDependencies` is called during build when pushing a route + // that has a delegated transition. We need to wait until the next frame to show the overlay, + // otherwise this widget crashes, since we can't call `OverlayPortalController.show` during build. + onNextFrame((timeStamp) { + _overlayPortalController.show(); + }); } - void _selectParagraphAtCaret() { - final docSelection = widget.selection.value; - if (docSelection == null) { - return; - } + @override + Widget build(BuildContext context) { + return SliverHybridStack( + children: [ + widget.child!, + OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: _buildToolbar, + child: const SizedBox(), + ), + ], + ); + } - selectParagraphAt( - docPosition: docSelection.extent, - docLayout: _docLayout, - selection: widget.selection, + Widget _buildToolbar(BuildContext context) { + return TapRegion( + groupId: widget.tapRegionGroupId, + child: IosFloatingToolbarOverlay( + shouldShowToolbar: _controlsContext!.shouldShowToolbar, + toolbarFocalPoint: _controlsContext!.toolbarFocalPoint, + floatingToolbarBuilder: + _controlsContext!.toolbarBuilder ?? widget.defaultToolbarBuilder ?? (_, __, ___) => const SizedBox(), + createOverlayControlsClipper: _controlsContext!.createOverlayControlsClipper, + showDebugPaint: false, + ), ); } +} - ScrollableState? _findAncestorScrollable(BuildContext context) { - final ancestorScrollable = Scrollable.of(context); - if (ancestorScrollable == null) { - return null; - } +/// Adds and removes an iOS-style editor magnifier, as dictated by an ancestor +/// [SuperReaderIosControlsScope]. +class SuperReaderIosMagnifierOverlayManager extends StatefulWidget { + const SuperReaderIosMagnifierOverlayManager({ + super.key, + this.child, + }); - final direction = ancestorScrollable.axisDirection; - // If the direction is horizontal, then we are inside a widget like a TabBar - // or a horizontal ListView, so we can't use the ancestor scrollable - if (direction == AxisDirection.left || direction == AxisDirection.right) { - return null; - } + final Widget? child; + + @override + State createState() => SuperReaderIosMagnifierOverlayManagerState(); +} - return ancestorScrollable; +@visibleForTesting +class SuperReaderIosMagnifierOverlayManagerState extends State + with SingleTickerProviderStateMixin { + final OverlayPortalController _overlayPortalController = OverlayPortalController(); + SuperReaderIosControlsController? _controlsContext; + + @visibleForTesting + bool get wantsToDisplayMagnifier => _controlsContext!.shouldShowMagnifier.value; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _controlsContext = SuperReaderIosControlsScope.rootOf(context); + + // It's possible that `didChangeDependencies` is called during build when pushing a route + // that has a delegated transition. We need to wait until the next frame to show the overlay, + // otherwise this widget crashes, since we can't call `OverlayPortalController.show` during build. + onNextFrame((timeStamp) { + _overlayPortalController.show(); + }); } @override Widget build(BuildContext context) { - if (_scrollController.hasClients) { - if (_scrollController.positions.length > 1) { - // During Hot Reload, if the gesture mode was changed, - // the widget might be built while the old gesture interactor - // scroller is still attached to the _scrollController. - // - // Defer adding the listener to the next frame. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - setState(() {}); - }); - } else { - if (scrollPosition != _activeScrollPosition) { - _activeScrollPosition = scrollPosition; - _activeScrollPosition?.addListener(_onScrollChange); - } - } + return SliverHybridStack( + children: [ + widget.child!, + OverlayPortal( + controller: _overlayPortalController, + overlayChildBuilder: _buildMagnifier, + child: const SizedBox(), + ), + ], + ); + } + + Widget _buildMagnifier(BuildContext context) { + // Display a magnifier that tracks a focal point. + // + // When the user is dragging an overlay handle, SuperEditor + // position a Leader with a LeaderLink. This magnifier follows that Leader + // via the LeaderLink. + return ValueListenableBuilder( + valueListenable: _controlsContext!.shouldShowMagnifier, + builder: (context, shouldShowMagnifier, child) { + return _controlsContext!.magnifierBuilder != null // + ? _controlsContext!.magnifierBuilder!( + context, + DocumentKeys.magnifier, + _controlsContext!.magnifierFocalPoint, + shouldShowMagnifier, + ) + : _buildDefaultMagnifier( + context, + DocumentKeys.magnifier, + _controlsContext!.magnifierFocalPoint, + shouldShowMagnifier, + ); + }, + ); + } + + Widget _buildDefaultMagnifier(BuildContext context, Key magnifierKey, LeaderLink magnifierFocalPoint, bool visible) { + if (CurrentPlatform.isWeb) { + // Defer to the browser to display overlay controls on mobile. + return const SizedBox(); } - return _buildGestureInput( - child: ScrollableDocument( - scrollController: _scrollController, - disableDragScrolling: true, - documentLayerLink: _documentLayerLink, - child: widget.child, - ), + return IOSFollowingMagnifier.roundedRectangle( + magnifierKey: magnifierKey, + show: visible, + leaderLink: magnifierFocalPoint, + // The magnifier is centered with the focal point. Translate it so that it sits + // above the focal point and leave a few pixels between the bottom of the magnifier + // and the focal point. This value was chosen empirically. + offsetFromFocalPoint: Offset(0, (-defaultIosMagnifierSize.height / 2) - 20), + handleColor: _controlsContext!.handleColor, ); } +} - Widget _buildGestureInput({ - required Widget child, - }) { - return RawGestureDetector( - behavior: HitTestBehavior.opaque, - gestures: { - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => TapSequenceGestureRecognizer(), - (TapSequenceGestureRecognizer recognizer) { - recognizer - ..onTapUp = _onTapUp - ..onDoubleTapUp = _onDoubleTapUp - ..onTripleTapUp = _onTripleTapUp - ..onTimeout = _onTapTimeout; - }, - ), - // We use a VerticalDragGestureRecognizer instead of a PanGestureRecognizer - // because `Scrollable` also uses a VerticalDragGestureRecognizer and we - // need to beat out any ancestor `Scrollable` in the gesture arena. - VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => VerticalDragGestureRecognizer(), - (VerticalDragGestureRecognizer instance) { - instance - ..dragStartBehavior = DragStartBehavior.down - ..onDown = _onPanDown - ..onStart = _onPanStart - ..onUpdate = _onPanUpdate - ..onEnd = _onPanEnd - ..onCancel = _onPanCancel; - }, - ), +/// A [SuperReaderLayerBuilder], which builds a [IosHandlesDocumentLayer], +/// which displays iOS-style handles. +class SuperReaderIosHandlesDocumentLayerBuilder implements SuperReaderDocumentLayerBuilder { + const SuperReaderIosHandlesDocumentLayerBuilder({ + this.handleColor, + }); + + final Color? handleColor; + + @override + ContentLayerWidget build(BuildContext context, SuperReaderContext readerContext) { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return const ContentLayerProxyWidget(child: SizedBox()); + } + + return IosHandlesDocumentLayer( + document: readerContext.document, + documentLayout: readerContext.documentLayout, + selection: readerContext.composer.selectionNotifier, + changeSelection: (newSelection, changeType, reason) { + readerContext.editor.execute([ + ChangeSelectionRequest( + newSelection, + changeType, + reason, + ), + ]); }, - child: child, + handleColor: handleColor ?? Theme.of(context).primaryColor, + shouldCaretBlink: ValueNotifier(false), ); } } diff --git a/super_editor/lib/src/super_reader/read_only_document_keyboard_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_keyboard_interactor.dart index 925167c38e..6dcd146eb2 100644 --- a/super_editor/lib/src/super_reader/read_only_document_keyboard_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_keyboard_interactor.dart @@ -5,7 +5,10 @@ import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; +import '../core/document_composer.dart'; import 'reader_context.dart'; /// Governs document input that comes from a physical keyboard. @@ -40,16 +43,16 @@ class ReadOnlyDocumentKeyboardInteractor extends StatelessWidget { final FocusNode focusNode; /// Service locator for document display dependencies. - final ReaderContext readerContext; + final SuperReaderContext readerContext; /// All the actions that the user can execute with keyboard keys. /// /// [keyboardActions] operates as a Chain of Responsibility. Starting - /// from the beginning of the list, a [ReadOnlyDocumentKeyboardAction] is + /// from the beginning of the list, a [DocumentKeyboardAction] is /// given the opportunity to handle the currently pressed keys. If that - /// [ReadOnlyDocumentKeyboardAction] reports the keys as handled, then execution - /// stops. Otherwise, execution continues to the next [ReadOnlyDocumentKeyboardAction]. - final List keyboardActions; + /// [DocumentKeyboardAction] reports the keys as handled, then execution + /// stops. Otherwise, execution continues to the next [DocumentKeyboardAction]. + final List keyboardActions; /// Whether or not the [ReadOnlyDocumentKeyboardInteractor] should autofocus final bool autofocus; @@ -58,7 +61,7 @@ class ReadOnlyDocumentKeyboardInteractor extends StatelessWidget { /// somewhere in the sub-tree. final Widget child; - KeyEventResult _onKeyPressed(FocusNode node, RawKeyEvent keyEvent) { + KeyEventResult _onKeyEventPressed(FocusNode node, KeyEvent keyEvent) { readerKeyLog.info("Handling key press: $keyEvent"); ExecutionInstruction instruction = ExecutionInstruction.continueExecution; int index = 0; @@ -83,7 +86,8 @@ class ReadOnlyDocumentKeyboardInteractor extends StatelessWidget { Widget build(BuildContext context) { return Focus( focusNode: focusNode, - onKey: _onKeyPressed, + includeSemantics: false, + onKeyEvent: _onKeyEventPressed, autofocus: autofocus, child: child, ); @@ -99,13 +103,13 @@ class ReadOnlyDocumentKeyboardInteractor extends StatelessWidget { /// /// It is possible that an action does nothing and then returns /// [ExecutionInstruction.haltExecution] to prevent further execution. -typedef ReadOnlyDocumentKeyboardAction = ExecutionInstruction Function({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, +typedef SuperReaderKeyboardAction = ExecutionInstruction Function({ + required SuperReaderContext documentContext, + required KeyEvent keyEvent, }); /// Keyboard actions for the standard [SuperReader]. -final readOnlyDefaultKeyboardActions = [ +final superReaderDefaultKeyboardActions = [ removeCollapsedSelectionWhenShiftIsReleased, scrollUpWithArrowKey, scrollDownWithArrowKey, @@ -132,19 +136,21 @@ final readOnlyDefaultKeyboardActions = [ /// pressing shift, we want to allow any selection. When the user releases the /// shift key (and triggers this shortcut), we want to remove the document selection /// if it's collapsed. -final removeCollapsedSelectionWhenShiftIsReleased = createShortcut( +final removeCollapsedSelectionWhenShiftIsReleased = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { - final selection = documentContext.selection.value; + final selection = documentContext.composer.selection; if (selection == null || !selection.isCollapsed) { return ExecutionInstruction.continueExecution; } // The selection is collapsed, and the shift key was released. We don't // want to retain the selection any longer. Remove it. - documentContext.selection.value = null; + documentContext.editor.execute([ + const ClearSelectionRequest(), + ]); return ExecutionInstruction.haltExecution; }, keyPressedOrReleased: LogicalKeyboardKey.shift, @@ -153,52 +159,51 @@ final removeCollapsedSelectionWhenShiftIsReleased = createShortcut( onKeyDown: false, ); -final scrollUpWithArrowKey = createShortcut( +final scrollUpWithArrowKey = createSuperReaderShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required SuperReaderContext documentContext, + required KeyEvent keyEvent, }) { - documentContext.scrollController.jumpBy(-20); + documentContext.scroller.jumpBy(-20); return ExecutionInstruction.haltExecution; }, keyPressedOrReleased: LogicalKeyboardKey.arrowUp, isShiftPressed: false, ); -final scrollDownWithArrowKey = createShortcut( +final scrollDownWithArrowKey = createSuperReaderShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required SuperReaderContext documentContext, + required KeyEvent keyEvent, }) { - documentContext.scrollController.jumpBy(20); + documentContext.scroller.jumpBy(20); return ExecutionInstruction.haltExecution; }, keyPressedOrReleased: LogicalKeyboardKey.arrowDown, isShiftPressed: false, ); -final expandSelectionWithLeftArrow = createShortcut( +final expandSelectionWithLeftArrow = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } if (defaultTargetPlatform == TargetPlatform.linux && - keyEvent.isAltPressed && + HardwareKeyboard.instance.isAltPressed && (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp || keyEvent.logicalKey == LogicalKeyboardKey.arrowDown)) { return ExecutionInstruction.continueExecution; } // Move the caret left/upstream. final didMove = moveCaretUpstream( - document: documentContext.document, + editor: documentContext.editor, documentLayout: documentContext.documentLayout, - selectionNotifier: documentContext.selection, movementModifier: _getHorizontalMovementModifier(keyEvent), - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -206,28 +211,27 @@ final expandSelectionWithLeftArrow = createShortcut( keyPressedOrReleased: LogicalKeyboardKey.arrowLeft, ); -final expandSelectionWithRightArrow = createShortcut( +final expandSelectionWithRightArrow = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } if (defaultTargetPlatform == TargetPlatform.linux && - keyEvent.isAltPressed && + HardwareKeyboard.instance.isAltPressed && (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp || keyEvent.logicalKey == LogicalKeyboardKey.arrowDown)) { return ExecutionInstruction.continueExecution; } // Move the caret right/downstream. final didMove = moveCaretDownstream( - document: documentContext.document, + editor: documentContext.editor, documentLayout: documentContext.documentLayout, - selectionNotifier: documentContext.selection, movementModifier: _getHorizontalMovementModifier(keyEvent), - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -235,37 +239,36 @@ final expandSelectionWithRightArrow = createShortcut( keyPressedOrReleased: LogicalKeyboardKey.arrowRight, ); -MovementModifier? _getHorizontalMovementModifier(RawKeyEvent keyEvent) { +MovementModifier? _getHorizontalMovementModifier(KeyEvent keyEvent) { if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && - keyEvent.isControlPressed) { + HardwareKeyboard.instance.isControlPressed) { return MovementModifier.word; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isMetaPressed) { + } else if (CurrentPlatform.isApple && HardwareKeyboard.instance.isMetaPressed) { return MovementModifier.line; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isAltPressed) { + } else if (CurrentPlatform.isApple && HardwareKeyboard.instance.isAltPressed) { return MovementModifier.word; } return null; } -final expandSelectionWithUpArrow = createShortcut( +final expandSelectionWithUpArrow = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } - if (defaultTargetPlatform == TargetPlatform.linux && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.linux && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } final didMove = moveCaretUp( - document: documentContext.document, + editor: documentContext.editor, documentLayout: documentContext.documentLayout, - selectionNotifier: documentContext.selection, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -273,24 +276,23 @@ final expandSelectionWithUpArrow = createShortcut( keyPressedOrReleased: LogicalKeyboardKey.arrowUp, ); -final expandSelectionWithDownArrow = createShortcut( +final expandSelectionWithDownArrow = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } - if (defaultTargetPlatform == TargetPlatform.linux && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.linux && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } final didMove = moveCaretDown( - document: documentContext.document, + editor: documentContext.editor, documentLayout: documentContext.documentLayout, - selectionNotifier: documentContext.selection, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -298,17 +300,16 @@ final expandSelectionWithDownArrow = createShortcut( keyPressedOrReleased: LogicalKeyboardKey.arrowDown, ); -final expandSelectionToLineStartWithHomeOnWindowsAndLinux = createShortcut( +final expandSelectionToLineStartWithHomeOnWindowsAndLinux = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { final didMove = moveCaretUpstream( - document: documentContext.document, + editor: documentContext.editor, documentLayout: documentContext.documentLayout, - selectionNotifier: documentContext.selection, movementModifier: MovementModifier.line, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -318,17 +319,16 @@ final expandSelectionToLineStartWithHomeOnWindowsAndLinux = createShortcut( platforms: {TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia}, ); -final expandSelectionToLineEndWithEndOnWindowsAndLinux = createShortcut( +final expandSelectionToLineEndWithEndOnWindowsAndLinux = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { final didMove = moveCaretDownstream( - document: documentContext.document, + editor: documentContext.editor, documentLayout: documentContext.documentLayout, - selectionNotifier: documentContext.selection, movementModifier: MovementModifier.line, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -338,17 +338,16 @@ final expandSelectionToLineEndWithEndOnWindowsAndLinux = createShortcut( platforms: {TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia}, ); -final expandSelectionToLineStartWithCtrlAOnWindowsAndLinux = createShortcut( +final expandSelectionToLineStartWithCtrlAOnWindowsAndLinux = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { final didMove = moveCaretUpstream( - document: documentContext.document, + editor: documentContext.editor, documentLayout: documentContext.documentLayout, - selectionNotifier: documentContext.selection, movementModifier: MovementModifier.line, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -359,17 +358,16 @@ final expandSelectionToLineStartWithCtrlAOnWindowsAndLinux = createShortcut( platforms: {TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia}, ); -final expandSelectionToLineEndWithCtrlEOnWindowsAndLinux = createShortcut( +final expandSelectionToLineEndWithCtrlEOnWindowsAndLinux = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { final didMove = moveCaretDownstream( - document: documentContext.document, + editor: documentContext.editor, documentLayout: documentContext.documentLayout, - selectionNotifier: documentContext.selection, movementModifier: MovementModifier.line, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -380,12 +378,12 @@ final expandSelectionToLineEndWithCtrlEOnWindowsAndLinux = createShortcut( platforms: {TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.fuchsia}, ); -final selectAllWhenCmdAIsPressedOnMac = createShortcut( +final selectAllWhenCmdAIsPressedOnMac = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { - final didSelectAll = selectAll(documentContext.document, documentContext.selection); + final didSelectAll = selectAll(documentContext.editor); return didSelectAll ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; }, keyPressedOrReleased: LogicalKeyboardKey.keyA, @@ -393,12 +391,12 @@ final selectAllWhenCmdAIsPressedOnMac = createShortcut( platforms: {TargetPlatform.macOS, TargetPlatform.iOS}, ); -final selectAllWhenCtlAIsPressedOnWindowsAndLinux = createShortcut( +final selectAllWhenCtlAIsPressedOnWindowsAndLinux = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { - final didSelectAll = selectAll(documentContext.document, documentContext.selection); + final didSelectAll = selectAll(documentContext.editor); return didSelectAll ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; }, keyPressedOrReleased: LogicalKeyboardKey.keyA, @@ -411,22 +409,22 @@ final selectAllWhenCtlAIsPressedOnWindowsAndLinux = createShortcut( }, ); -final copyWhenCmdCIsPressedOnMac = createShortcut( +final copyWhenCmdCIsPressedOnMac = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { - if (documentContext.selection.value == null) { + if (documentContext.composer.selection == null) { return ExecutionInstruction.continueExecution; } - if (documentContext.selection.value!.isCollapsed) { + if (documentContext.composer.selection!.isCollapsed) { // Nothing to copy, but we technically handled the task. return ExecutionInstruction.haltExecution; } copy( document: documentContext.document, - selection: documentContext.selection.value!, + selection: documentContext.composer.selection!, ); return ExecutionInstruction.haltExecution; @@ -436,22 +434,22 @@ final copyWhenCmdCIsPressedOnMac = createShortcut( platforms: {TargetPlatform.macOS, TargetPlatform.iOS}, ); -final copyWhenCtlCIsPressedOnWindowsAndLinux = createShortcut( +final copyWhenCtlCIsPressedOnWindowsAndLinux = createDocumentShortcut( ({ - required ReaderContext documentContext, - required RawKeyEvent keyEvent, + required DocumentContext documentContext, + required KeyEvent keyEvent, }) { - if (documentContext.selection.value == null) { + if (documentContext.composer.selection == null) { return ExecutionInstruction.continueExecution; } - if (documentContext.selection.value!.isCollapsed) { + if (documentContext.composer.selection!.isCollapsed) { // Nothing to copy, but we technically handled the task. return ExecutionInstruction.haltExecution; } copy( document: documentContext.document, - selection: documentContext.selection.value!, + selection: documentContext.composer.selection!, ); return ExecutionInstruction.haltExecution; @@ -466,27 +464,27 @@ final copyWhenCtlCIsPressedOnWindowsAndLinux = createShortcut( }, ); -/// A proxy for a [ReadOnlyDocumentKeyboardAction] that filters events based +/// A proxy for a [SuperReaderKeyboardAction] that filters events based /// on [onKeyUp], [onKeyDown], and [shortcut]. /// /// If [onKeyUp] is `false`, all key-up events are ignored. If [onKeyDown] is /// `false`, all key-down events are ignored. If [shortcut] is non-null, all /// events that don't match the [shortcut] key presses are ignored. /// -/// This proxy is optional. Individual [ReadOnlyDocumentKeyboardAction]s can +/// This proxy is optional. Individual [SuperReaderKeyboardAction]s can /// make these same decisions about key handling. This proxy is provided as /// a convenience for the average use-case, which typically tries to match /// a specific shortcut for either an up or down key event. -ReadOnlyDocumentKeyboardAction createShortcut( - ReadOnlyDocumentKeyboardAction action, { +SuperReaderKeyboardAction createSuperReaderShortcut( + SuperReaderKeyboardAction action, { LogicalKeyboardKey? keyPressedOrReleased, Set? triggers, bool? isShiftPressed, bool? isCmdPressed, bool? isCtlPressed, bool? isAltPressed, - bool onKeyUp = true, - bool onKeyDown = false, + bool onKeyUp = false, + bool onKeyDown = true, Set? platforms, }) { if (onKeyUp == false && onKeyDown == false) { @@ -494,31 +492,31 @@ ReadOnlyDocumentKeyboardAction createShortcut( "Invalid shortcut definition. Both onKeyUp and onKeyDown are false. This shortcut will never be triggered."); } - return ({required ReaderContext documentContext, required RawKeyEvent keyEvent}) { - if (keyEvent is RawKeyUpEvent && !onKeyUp) { + return ({required SuperReaderContext documentContext, required KeyEvent keyEvent}) { + if (keyEvent is KeyUpEvent && !onKeyUp) { return ExecutionInstruction.continueExecution; } - if (keyEvent is RawKeyDownEvent && !onKeyDown) { + if ((keyEvent is KeyDownEvent || keyEvent is KeyRepeatEvent) && !onKeyDown) { return ExecutionInstruction.continueExecution; } - if (isCmdPressed != null && isCmdPressed != keyEvent.isMetaPressed) { + if (isCmdPressed != null && isCmdPressed != HardwareKeyboard.instance.isMetaPressed) { return ExecutionInstruction.continueExecution; } - if (isCtlPressed != null && isCtlPressed != keyEvent.isControlPressed) { + if (isCtlPressed != null && isCtlPressed != HardwareKeyboard.instance.isControlPressed) { return ExecutionInstruction.continueExecution; } - if (isAltPressed != null && isAltPressed != keyEvent.isAltPressed) { + if (isAltPressed != null && isAltPressed != HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } if (isShiftPressed != null) { - if (isShiftPressed && !keyEvent.isShiftPressed) { + if (isShiftPressed && !HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; - } else if (!isShiftPressed && keyEvent.isShiftPressed) { + } else if (!isShiftPressed && HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; } } @@ -541,7 +539,7 @@ ReadOnlyDocumentKeyboardAction createShortcut( if (triggers != null) { for (final key in triggers) { - if (!keyEvent.isKeyPressed(key)) { + if (!HardwareKeyboard.instance.isLogicalKeyPressed(key)) { // Manually account for the fact that Flutter pretends that different // shift keys mean different things. if (key == LogicalKeyboardKey.shift || @@ -571,3 +569,29 @@ ReadOnlyDocumentKeyboardAction createShortcut( return action(documentContext: documentContext, keyEvent: keyEvent); }; } + +@Deprecated("Use createReadOnlyShortcut or createSuperReaderShortcut instead") +SuperReaderKeyboardAction createShortcut( + SuperReaderKeyboardAction action, { + LogicalKeyboardKey? keyPressedOrReleased, + Set? triggers, + bool? isShiftPressed, + bool? isCmdPressed, + bool? isCtlPressed, + bool? isAltPressed, + bool onKeyUp = false, + bool onKeyDown = true, + Set? platforms, +}) => + createSuperReaderShortcut( + action, + keyPressedOrReleased: keyPressedOrReleased, + triggers: triggers, + isShiftPressed: isShiftPressed, + isCmdPressed: isCmdPressed, + isCtlPressed: isCtlPressed, + isAltPressed: isAltPressed, + onKeyUp: onKeyUp, + onKeyDown: onKeyDown, + platforms: platforms, + ); diff --git a/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart index 1c47398ea9..f219509672 100644 --- a/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart @@ -7,11 +7,17 @@ import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/default_editor/document_scrollable.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; +import 'package:super_editor/src/infrastructure/sliver_hybrid_stack.dart'; -import 'reader_context.dart'; +import '../core/document_composer.dart'; +import '../core/editor.dart'; /// Governs mouse gesture interaction with a read-only document, such as scrolling /// a document with a scroll wheel and tap-and-dragging to create an expanded selection. @@ -35,7 +41,9 @@ class ReadOnlyDocumentMouseInteractor extends StatefulWidget { Key? key, this.focusNode, required this.readerContext, + this.contentTapHandler, required this.autoScroller, + required this.fillViewport, this.showDebugPaint = false, required this.child, }) : super(key: key); @@ -43,11 +51,19 @@ class ReadOnlyDocumentMouseInteractor extends StatefulWidget { final FocusNode? focusNode; /// Service locator for document dependencies. - final ReaderContext readerContext; + final DocumentContext readerContext; + + /// Optional handler that responds to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + final ContentTapDelegate? contentTapHandler; /// Auto-scrolling delegate. final AutoScrollController autoScroller; + /// Whether the document gesture detector should fill the entire viewport + /// even if the actual content is smaller. + final bool fillViewport; + /// Paints some extra visual ornamentation to help with /// debugging, when `true`. final bool showDebugPaint; @@ -74,12 +90,18 @@ class _ReadOnlyDocumentMouseInteractorState extends State(SystemMouseCursors.text); + Offset? _lastHoverOffset; + @override void initState() { super.initState(); _focusNode = widget.focusNode ?? FocusNode(); - widget.readerContext.selection.addListener(_onSelectionChange); - widget.autoScroller.addListener(_updateDragSelection); + widget.readerContext.composer.selectionNotifier.addListener(_onSelectionChange); + widget.autoScroller + ..addListener(_updateDragSelection) + ..addListener(_updateMouseCursorAtLatestOffset); + widget.contentTapHandler?.addListener(_updateMouseCursorAtLatestOffset); } @override @@ -88,23 +110,34 @@ class _ReadOnlyDocumentMouseInteractorState extends State (RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft) || - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftRight) || - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shift)); + bool get _isShiftPressed => (HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shift)); void _onSelectionChange() { if (mounted) { // Use a post-frame callback to "ensure selection extent is visible" // so that any pending visual document changes can happen before // attempting to calculate the visual position of the selection extent. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + onNextFrame((_) { readerGesturesLog.finer("Ensuring selection extent is visible because the doc selection changed"); final globalExtentRect = _getSelectionExtentAsGlobalRect(); @@ -137,7 +170,7 @@ class _ReadOnlyDocumentMouseInteractorState extends State _lastHoverOffset = null, + child: child, + ); + }, child: child, ); } @@ -445,6 +561,7 @@ Updating drag selection: Widget _buildGestureInput({ required Widget child, }) { + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; return RawGestureDetector( behavior: HitTestBehavior.translucent, gestures: { @@ -456,17 +573,22 @@ Updating drag selection: ..onDoubleTapDown = _onDoubleTapDown ..onDoubleTap = _onDoubleTap ..onTripleTapDown = _onTripleTapDown - ..onTripleTap = _onTripleTap; + ..onTripleTap = _onTripleTap + ..gestureSettings = gestureSettings; }, ), PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), + () => PanGestureRecognizer(supportedDevices: { + PointerDeviceKind.mouse, + PointerDeviceKind.touch, + }), (PanGestureRecognizer recognizer) { recognizer ..onStart = _onPanStart ..onUpdate = _onPanUpdate ..onEnd = _onPanEnd - ..onCancel = _onPanCancel; + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; }, ), }, @@ -546,3 +668,139 @@ Updating drag selection: ]; } } + +void moveToNearestSelectableComponent( + Editor editor, + DocumentLayout documentLayout, + String nodeId, + DocumentComponent component, +) { + // TODO: this was taken from CommonOps. We don't have CommonOps in this + // interactor, because it's for read-only documents. Selection operations + // should probably be moved to something outside of CommonOps + DocumentNode startingNode = editor.document.getNodeById(nodeId)!; + String? newNodeId; + NodePosition? newPosition; + + // Try to find a new selection downstream. + final downstreamNode = getDownstreamSelectableNodeAfter(editor.document, () => documentLayout, startingNode); + if (downstreamNode != null) { + newNodeId = downstreamNode.id; + final nextComponent = documentLayout.getComponentByNodeId(newNodeId); + newPosition = nextComponent?.getBeginningPosition(); + } + + // Try to find a new selection upstream. + if (newPosition == null) { + final upstreamNode = getUpstreamSelectableNodeBefore(editor.document, () => documentLayout, startingNode); + if (upstreamNode != null) { + newNodeId = upstreamNode.id; + final previousComponent = documentLayout.getComponentByNodeId(newNodeId); + newPosition = previousComponent?.getBeginningPosition(); + } + } + + if (newNodeId == null || newPosition == null) { + return; + } + + editor.execute([ + ChangeSelectionRequest( + editor.composer.selection!.expandTo( + DocumentPosition( + nodeId: newNodeId, + nodePosition: newPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); +} + +/// Calculates an appropriate [DocumentSelection] from an (x,y) +/// [baseOffsetInDocument], to an (x,y) [extentOffsetInDocument], setting +/// the new document selection in the given [selection]. +void selectRegion({ + required Editor editor, + required DocumentLayout documentLayout, + required Offset baseOffsetInDocument, + required Offset extentOffsetInDocument, + required SelectionType selectionType, + bool expandSelection = false, +}) { + docGesturesLog.info("Selecting region with selection mode: $selectionType"); + DocumentSelection? regionSelection = documentLayout.getDocumentSelectionInRegion( + baseOffsetInDocument, + extentOffsetInDocument, + ); + DocumentPosition? basePosition = regionSelection?.base; + DocumentPosition? extentPosition = regionSelection?.extent; + docGesturesLog.fine(" - base: $basePosition, extent: $extentPosition"); + + if (basePosition == null || extentPosition == null) { + editor.execute([const ClearSelectionRequest()]); + return; + } + + if (selectionType == SelectionType.paragraph) { + final baseParagraphSelection = getParagraphSelection( + docPosition: basePosition, + docLayout: documentLayout, + ); + if (baseParagraphSelection == null) { + editor.execute([const ClearSelectionRequest()]); + return; + } + basePosition = baseOffsetInDocument.dy < extentOffsetInDocument.dy + ? baseParagraphSelection.base + : baseParagraphSelection.extent; + + final extentParagraphSelection = getParagraphSelection( + docPosition: extentPosition, + docLayout: documentLayout, + ); + if (extentParagraphSelection == null) { + editor.execute([const ClearSelectionRequest()]); + return; + } + extentPosition = baseOffsetInDocument.dy < extentOffsetInDocument.dy + ? extentParagraphSelection.extent + : extentParagraphSelection.base; + } else if (selectionType == SelectionType.word) { + final baseWordSelection = getWordSelection( + docPosition: basePosition, + docLayout: documentLayout, + ); + if (baseWordSelection == null) { + editor.execute([const ClearSelectionRequest()]); + return; + } + basePosition = baseWordSelection.base; + + final extentWordSelection = getWordSelection( + docPosition: extentPosition, + docLayout: documentLayout, + ); + if (extentWordSelection == null) { + editor.execute([const ClearSelectionRequest()]); + return; + } + extentPosition = extentWordSelection.extent; + } + + final selection = editor.composer.selection; + editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + // If desired, expand the selection instead of replacing it. + base: expandSelection ? selection?.base ?? basePosition : basePosition, + extent: extentPosition, + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + + docGesturesLog.fine("Selected region: ${editor.composer.selection}"); +} diff --git a/super_editor/lib/src/super_reader/reader_context.dart b/super_editor/lib/src/super_reader/reader_context.dart index 91cbedf1bd..1cb00020fc 100644 --- a/super_editor/lib/src/super_reader/reader_context.dart +++ b/super_editor/lib/src/super_reader/reader_context.dart @@ -1,39 +1,23 @@ -import 'package:flutter/foundation.dart'; import 'package:super_editor/src/core/document.dart'; -import 'package:super_editor/src/core/document_layout.dart'; -import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/default_editor/document_scrollable.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; +import 'package:super_editor/src/infrastructure/document_context.dart'; /// Collection of core artifacts used to display a read-only document. /// -/// In particular, the context contains the [Document], [DocumentSelection], -/// and [DocumentLayout]. -class ReaderContext { - /// Creates document context that makes up a collection of core artifacts for - /// displaying a read-only document. - /// - /// The [documentLayout] is passed as a [getDocumentLayout] callback that - /// should return the current layout as it might change. - ReaderContext({ - required this.document, - required DocumentLayout Function() getDocumentLayout, - required this.selection, - required this.scrollController, - }) : _getDocumentLayout = getDocumentLayout; +/// While [SuperReaderContext] includes an [editor], it's expected that clients +/// of a [SuperReaderContext] do not allow users to alter [Document] within +/// the [editor]. Instead, the [editor] provides access to a [Document], a +/// [DocumentComposer] to display and alter selections, and the ability for +/// code to alter the [Document], such as an AI GPT system. +class SuperReaderContext extends DocumentContext { + SuperReaderContext({ + required super.editor, + required super.getDocumentLayout, + required this.scroller, + }); - /// The [Document] that's currently being displayed. - final Document document; - - /// The document layout that is a visual representation of the document. - /// - /// This member might change over time. - DocumentLayout get documentLayout => _getDocumentLayout(); - final DocumentLayout Function() _getDocumentLayout; - - /// The current selection within the displayed document. - final ValueNotifier selection; - - /// The [AutoScrollController] that scrolls a document up/down within the - /// document's viewport. - final AutoScrollController scrollController; + /// The [DocumentScroller] that provides status and control over [SuperReader] + /// scrolling. + final DocumentScroller scroller; } diff --git a/super_editor/lib/src/super_reader/super_reader.dart b/super_editor/lib/src/super_reader/super_reader.dart index 5274102b9f..54f7b61797 100644 --- a/super_editor/lib/src/super_reader/super_reader.dart +++ b/super_editor/lib/src/super_reader/super_reader.dart @@ -1,58 +1,81 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_debug_paint.dart'; -import 'package:super_editor/src/core/document_editor.dart'; import 'package:super_editor/src/core/document_interaction.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/core/styles.dart'; import 'package:super_editor/src/default_editor/attributions.dart'; import 'package:super_editor/src/default_editor/blockquote.dart'; import 'package:super_editor/src/default_editor/document_scrollable.dart'; import 'package:super_editor/src/default_editor/horizontal_rule.dart'; import 'package:super_editor/src/default_editor/image.dart'; -import 'package:super_editor/src/default_editor/layout_single_column/_layout.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_presenter.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_styler_per_component.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_styler_shylesheet.dart'; import 'package:super_editor/src/default_editor/layout_single_column/_styler_user_selection.dart'; import 'package:super_editor/src/default_editor/list_items.dart'; import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/default_editor/text/custom_underlines.dart'; import 'package:super_editor/src/default_editor/unknown_component.dart'; - -import 'read_only_document_android_touch_interactor.dart'; -import 'read_only_document_ios_touch_interactor.dart'; -import 'read_only_document_keyboard_interactor.dart'; -import 'read_only_document_mouse_interactor.dart'; -import 'reader_context.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/content_layers.dart'; +import 'package:super_editor/src/infrastructure/content_layers_for_slivers.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/documents/document_scaffold.dart'; +import 'package:super_editor/src/infrastructure/documents/document_scroller.dart'; +import 'package:super_editor/src/infrastructure/documents/document_selection.dart'; +import 'package:super_editor/src/infrastructure/documents/selection_leader_document_layer.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/links.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; +import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/text_input.dart'; +import 'package:super_editor/src/super_reader/read_only_document_android_touch_interactor.dart'; +import 'package:super_editor/src/super_reader/read_only_document_ios_touch_interactor.dart'; +import 'package:super_editor/src/super_reader/read_only_document_keyboard_interactor.dart'; +import 'package:super_editor/src/super_reader/read_only_document_mouse_interactor.dart'; +import 'package:super_editor/src/super_reader/reader_context.dart'; +import 'package:super_editor/src/super_reader/tasks.dart'; class SuperReader extends StatefulWidget { SuperReader({ Key? key, this.focusNode, - required this.document, + this.autofocus = false, + this.tapRegionGroupId, + required this.editor, this.documentLayoutKey, - this.selection, + this.selectionLayerLinks, this.scrollController, Stylesheet? stylesheet, this.customStylePhases = const [], - this.documentOverlayBuilders = const [], + this.documentUnderlayBuilders = const [], + this.documentOverlayBuilders = defaultSuperReaderDocumentOverlayBuilders, List? componentBuilders, - List? keyboardActions, + List? keyboardActions, SelectionStyles? selectionStyle, this.gestureMode, + this.contentTapDelegateFactory = superReaderLaunchLinkTapHandlerFactory, + this.overlayController, this.androidHandleColor, this.androidToolbarBuilder, this.iOSHandleColor, this.iOSToolbarBuilder, this.createOverlayControlsClipper, - this.autofocus = false, this.debugPaint = const DebugPaintConfig(), + this.shrinkWrap = false, }) : stylesheet = stylesheet ?? readOnlyDefaultStylesheet, selectionStyles = selectionStyle ?? readOnlyDefaultSelectionStyle, - keyboardActions = keyboardActions ?? readOnlyDefaultKeyboardActions, + keyboardActions = keyboardActions ?? superReaderDefaultKeyboardActions, componentBuilders = componentBuilders != null ? [...componentBuilders, const UnknownComponentBuilder()] : [...readOnlyDefaultComponentBuilders, const UnknownComponentBuilder()], @@ -60,8 +83,25 @@ class SuperReader extends StatefulWidget { final FocusNode? focusNode; - /// The [Document] displayed in this [SuperReader], in read-only mode. - final Document document; + /// Whether or not the [SuperReader] should autofocus. + final bool autofocus; + + /// {@template super_reader_tap_region_group_id} + /// A group ID for a tap region that surrounds the reader + /// and also surrounds any related widgets, such as drag handles and a toolbar. + /// + /// When the reader is inside a [TapRegion], tapping at a drag handle causes + /// [TapRegion.onTapOutside] to be called. To prevent that, provide a + /// [tapRegionGroupId] with the same value as the ancestor [TapRegion] groupId. + /// {@endtemplate} + final String? tapRegionGroupId; + + /// The [Editor] whose [Document] displayed in this [SuperReader]. + /// + /// [SuperReader] prevents users from interacting with, and altering the [Document]. + /// However, [SuperReader] takes an [Editor] so that developers can alter the [Document] + /// through code, such as contributing new content from an AI GPT. + final Editor editor; /// [GlobalKey] that's bound to the [DocumentLayout] within /// this [SuperReader]. @@ -70,7 +110,13 @@ class SuperReader extends StatefulWidget { /// layout within this [SuperReader]. final GlobalKey? documentLayoutKey; - final ValueNotifier? selection; + /// Leader links that connect leader widgets near the user's selection + /// to carets, handles, and other things that want to follow the selection. + /// + /// These links are always created and used within [SuperEditor]. By providing + /// an explicit [selectionLayerLinks], external widgets can also follow the + /// user's selection. + final SelectionLayerLinks? selectionLayerLinks; /// The [ScrollController] that governs this [SuperReader]'s scroll /// offset. @@ -103,9 +149,13 @@ class SuperReader extends StatefulWidget { /// knows how to interpret and apply table styles for your visual table component. final List customStylePhases; + /// Layers that are displayed beneath the document layout, aligned + /// with the location and size of the document layout. + final List documentUnderlayBuilders; + /// Layers that are displayed on top of the document layout, aligned /// with the location and size of the document layout. - final List documentOverlayBuilders; + final List documentOverlayBuilders; /// Priority list of widget factories that create instances of /// each visual component displayed in the document layout, e.g., @@ -116,13 +166,23 @@ class SuperReader extends StatefulWidget { /// events, e.g., text entry, newlines, character deletion, /// copy, paste, etc. /// - /// These actions are only used when in [DocumentInputSource.keyboard] + /// These actions are only used when in [TextInputSource.keyboard] /// mode. - final List keyboardActions; + final List keyboardActions; /// The [SuperReader] gesture mode, e.g., mouse or touch. final DocumentGestureMode? gestureMode; + /// Factory that creates a [ContentTapDelegate], which is given an + /// opportunity to respond to taps on content before the editor, itself. + /// + /// A [ContentTapDelegate] might be used, for example, to launch a URL + /// when a user taps on a link. + final SuperReaderContentTapDelegateFactory? contentTapDelegateFactory; + + /// Shows, hides, and positions a floating toolbar and magnifier. + final MagnifierAndToolbarController? overlayController; + /// Color of the text selection drag handles on Android. final Color? androidHandleColor; @@ -130,9 +190,11 @@ class SuperReader extends StatefulWidget { final WidgetBuilder? androidToolbarBuilder; /// Color of the text selection drag handles on iOS. + @Deprecated("To configure handle color, surround SuperEditor with an IosEditorControlsScope, instead") final Color? iOSHandleColor; /// Builder that creates a floating toolbar when running on iOS. + @Deprecated("To configure a toolbar builder, surround SuperEditor with an IosEditorControlsScope, instead") final WidgetBuilder? iOSToolbarBuilder; /// Creates a clipper that applies to overlay controls, like drag @@ -144,60 +206,71 @@ class SuperReader extends StatefulWidget { /// (probably the entire screen). final CustomClipper Function(BuildContext overlayContext)? createOverlayControlsClipper; - /// Whether or not the [SuperReader] should autofocus. - final bool autofocus; - /// Paints some extra visual ornamentation to help with /// debugging. final DebugPaintConfig debugPaint; + /// Whether the scroll view used by the reader should shrink-wrap its contents. + /// Only used when reader is not inside an scrollable. + final bool shrinkWrap; + @override State createState() => SuperReaderState(); } class SuperReaderState extends State { - late DocumentEditor _editor; @visibleForTesting - Document get document => _editor.document; + Document get document => widget.editor.document; - late final ValueNotifier _selection; @visibleForTesting - DocumentSelection? get selection => _selection.value; + DocumentSelection? get selection => widget.editor.composer.selection; // GlobalKey used to access the [DocumentLayoutState] to figure // out where in the document the user taps or drags. late GlobalKey _docLayoutKey; + final _documentLayoutLink = LayerLink(); SingleColumnLayoutPresenter? _docLayoutPresenter; late SingleColumnStylesheetStyler _docStylesheetStyler; + final _customUnderlineStyler = CustomUnderlineStyler(); late SingleColumnLayoutCustomComponentStyler _docLayoutPerComponentBlockStyler; late SingleColumnLayoutSelectionStyler _docLayoutSelectionStyler; + ContentTapDelegate? _contentTapDelegate; + + late DocumentScroller _scroller; + late ScrollController _scrollController; late AutoScrollController _autoScrollController; - late ReaderContext _readerContext; + late SuperReaderContext _readerContext; @visibleForTesting FocusNode get focusNode => _focusNode; late FocusNode _focusNode; + // Leader links that connect leader widgets near the user's selection + // to carets, handles, and other things that want to follow the selection. + late SelectionLayerLinks _selectionLinks; + + // GlobalKey for the iOS editor controls context so that the context data doesn't + // continuously replace itself every time we rebuild. We want to retain the same + // controls because they're shared throughout a number of disconnected widgets. + final _iosControlsContextKey = GlobalKey(); + final _iosControlsController = SuperReaderIosControlsController(); + @override void initState() { super.initState(); - _editor = _ReadOnlyDocumentEditor(document: widget.document); - _selection = widget.selection ?? ValueNotifier(null); - _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); + _scroller = DocumentScroller(); + _scrollController = widget.scrollController ?? ScrollController(); _autoScrollController = AutoScrollController(); + _selectionLinks = widget.selectionLayerLinks ?? SelectionLayerLinks(); + _docLayoutKey = widget.documentLayoutKey ?? GlobalKey(); - _readerContext = ReaderContext( - document: widget.document, - getDocumentLayout: () => _docLayoutKey.currentState as DocumentLayout, - selection: _selection, - scrollController: _autoScrollController, - ); + _createReaderContext(); _createLayoutPresenter(); } @@ -206,35 +279,72 @@ class SuperReaderState extends State { void didUpdateWidget(SuperReader oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.document != oldWidget.document) { - _editor = _ReadOnlyDocumentEditor(document: widget.document); + if (widget.scrollController != oldWidget.scrollController) { + _scrollController = widget.scrollController ?? ScrollController(); + } + + if (widget.selectionLayerLinks != oldWidget.selectionLayerLinks) { + _selectionLinks = widget.selectionLayerLinks ?? SelectionLayerLinks(); } - if (widget.selection != oldWidget.selection) { - _selection = widget.selection ?? ValueNotifier(null); + + if (widget.editor.document != oldWidget.editor.document || widget.scrollController != oldWidget.scrollController) { + _createReaderContext(); + } + + if (widget.stylesheet != oldWidget.stylesheet) { + _createLayoutPresenter(); } } + @override + void dispose() { + _contentTapDelegate?.dispose(); + + _focusNode.removeListener(_onFocusChange); + if (widget.focusNode == null) { + // We are using our own private FocusNode. Dispose it. + _focusNode.dispose(); + } + + super.dispose(); + } + + void _createReaderContext() { + _readerContext = SuperReaderContext( + editor: widget.editor, + getDocumentLayout: () => _docLayoutKey.currentState as DocumentLayout, + scroller: _scroller, + ); + + _contentTapDelegate?.dispose(); + _contentTapDelegate = widget.contentTapDelegateFactory?.call(_readerContext); + } + void _createLayoutPresenter() { if (_docLayoutPresenter != null) { _docLayoutPresenter!.dispose(); } - _docStylesheetStyler = SingleColumnStylesheetStyler(stylesheet: widget.stylesheet); + _docStylesheetStyler = SingleColumnStylesheetStyler( + stylesheet: widget.stylesheet, + ); _docLayoutPerComponentBlockStyler = SingleColumnLayoutCustomComponentStyler(); _docLayoutSelectionStyler = SingleColumnLayoutSelectionStyler( - document: _editor.document, - selection: _selection, + document: widget.editor.document, + selection: widget.editor.composer.selectionNotifier, selectionStyles: widget.selectionStyles, + selectedTextColorStrategy: widget.stylesheet.selectedTextColorStrategy, ); _docLayoutPresenter = SingleColumnLayoutPresenter( - document: _editor.document, + document: widget.editor.document, componentBuilders: widget.componentBuilders, pipeline: [ _docStylesheetStyler, _docLayoutPerComponentBlockStyler, + _customUnderlineStyler, ...widget.customStylePhases, // Selection changes are very volatile. Put that phase last // to minimize view model recalculations. @@ -270,154 +380,349 @@ class SuperReaderState extends State { @override Widget build(BuildContext context) { - return _buildInputSystem( - child: _buildGestureSystem( - documentLayout: SingleColumnDocumentLayout( - key: _docLayoutKey, - presenter: _docLayoutPresenter!, - componentBuilders: widget.componentBuilders, - showDebugPaint: widget.debugPaint.layout, - ), - ), + return _buildGestureControlsScope( + // We add a Builder immediately beneath the gesture controls scope so that + // all descendant widgets built within SuperReader can access that scope. + child: Builder(builder: (controlsScopeContext) { + return ReadOnlyDocumentKeyboardInteractor( + // In a read-only document, we don't expect the software keyboard + // to ever be open. Therefore, we only respond to key presses, such + // as arrow keys. + focusNode: _focusNode, + readerContext: _readerContext, + keyboardActions: widget.keyboardActions, + autofocus: widget.autofocus, + child: DocumentScaffold( + viewportDecorationBuilder: _buildPlatformSpecificViewportDecorations, + documentLayoutLink: _documentLayoutLink, + documentLayoutKey: _docLayoutKey, + gestureBuilder: _buildGestureInteractor, + scrollController: _scrollController, + autoScrollController: _autoScrollController, + scroller: _scroller, + presenter: _docLayoutPresenter!, + componentBuilders: widget.componentBuilders, + shrinkWrap: widget.shrinkWrap, + underlays: [ + // Add any underlays that were provided by the client. + for (final underlayBuilder in widget.documentUnderlayBuilders) // + (context) => underlayBuilder.build(context, _readerContext), + ], + overlays: [ + // Layer that positions and sizes leader widgets at the bounds + // of the users selection so that carets, handles, toolbars, and + // other things can follow the selection. + (context) => _SelectionLeadersDocumentLayerBuilder( + links: _selectionLinks, + ).build(context, _readerContext), + // Add any overlays that were provided by the client. + for (final overlayBuilder in widget.documentOverlayBuilders) // + (context) => overlayBuilder.build(context, _readerContext), + ], + debugPaint: widget.debugPaint, + ), + ); + }), ); } - Widget _buildInputSystem({ + /// Builds an [InheritedWidget] that holds a shared context for editor controls, + /// e.g., caret, handles, magnifier, toolbar. + /// + /// This context may be shared by multiple widgets within [SuperEditor]. It's also + /// possible that a client app has wrapped [SuperEditor] with its own context + /// [InheritedWidget], in which case the context is shared with widgets inside + /// of [SuperEditor], and widgets outside of [SuperEditor]. + Widget _buildGestureControlsScope({ required Widget child, }) { - // In a read-only document, we don't expect the software keyboard - // to ever be open. Therefore, we only respond to key presses, such - // as arrow keys. - return ReadOnlyDocumentKeyboardInteractor( - focusNode: _focusNode, - readerContext: _readerContext, - keyboardActions: widget.keyboardActions, - autofocus: widget.autofocus, - child: child, - ); + switch (_gestureMode) { + // case DocumentGestureMode.mouse: + // TODO: create context for mouse mode (#1533) + // case DocumentGestureMode.android: + // TODO: create context for Android (#1509) + case DocumentGestureMode.iOS: + default: + return SuperReaderIosControlsScope( + key: _iosControlsContextKey, + controller: _iosControlsController, + child: child, + ); + } } - /// Builds the widget tree that handles user gesture interaction - /// with the document, e.g., mouse input on desktop, or touch input - /// on mobile. - Widget _buildGestureSystem({ - required Widget documentLayout, + /// Builds any widgets that a platform wants to wrap around the editor viewport, + /// e.g., reader toolbar. + Widget _buildPlatformSpecificViewportDecorations( + BuildContext context, { + required Widget child, }) { switch (_gestureMode) { + case DocumentGestureMode.iOS: + return SuperReaderIosToolbarOverlayManager( + tapRegionGroupId: widget.tapRegionGroupId, + defaultToolbarBuilder: (overlayContext, mobileToolbarKey, focalPoint) => defaultIosReaderToolbarBuilder( + overlayContext, + mobileToolbarKey, + focalPoint, + document, + widget.editor.composer.selectionNotifier, + SuperReaderIosControlsScope.rootOf(context), + ), + child: SuperReaderIosMagnifierOverlayManager( + child: child, + ), + ); case DocumentGestureMode.mouse: - return _buildDesktopGestureSystem(documentLayout); + case DocumentGestureMode.android: + return child; + } + } + + Widget _buildGestureInteractor(BuildContext context, {required Widget child}) { + // Ensure that gesture object fill entire viewport when not being + // in user specified scrollable. + final fillViewport = context.findAncestorScrollableWithVerticalScroll == null; + switch (_gestureMode) { + case DocumentGestureMode.mouse: + return ReadOnlyDocumentMouseInteractor( + focusNode: _focusNode, + readerContext: _readerContext, + contentTapHandler: _contentTapDelegate, + autoScroller: _autoScrollController, + fillViewport: fillViewport, + showDebugPaint: widget.debugPaint.gestures, + child: child, + ); case DocumentGestureMode.android: return ReadOnlyAndroidDocumentTouchInteractor( focusNode: _focusNode, - document: _readerContext.document, + tapRegionGroupId: widget.tapRegionGroupId, + readerContext: _readerContext, documentKey: _docLayoutKey, getDocumentLayout: () => _readerContext.documentLayout, - selection: _readerContext.selection, - scrollController: widget.scrollController, + selectionLinks: _selectionLinks, + contentTapHandler: _contentTapDelegate, + scrollController: _scrollController, handleColor: widget.androidHandleColor ?? Theme.of(context).primaryColor, popoverToolbarBuilder: widget.androidToolbarBuilder ?? (_) => const SizedBox(), createOverlayControlsClipper: widget.createOverlayControlsClipper, showDebugPaint: widget.debugPaint.gestures, - child: documentLayout, + overlayController: widget.overlayController, + fillViewport: fillViewport, + child: child, ); case DocumentGestureMode.iOS: - return ReadOnlyIOSDocumentTouchInteractor( + return SuperReaderIosDocumentTouchInteractor( focusNode: _focusNode, - document: _readerContext.document, - getDocumentLayout: () => _readerContext.documentLayout, - selection: _readerContext.selection, - scrollController: widget.scrollController, + readerContext: _readerContext, documentKey: _docLayoutKey, - handleColor: widget.iOSHandleColor ?? Theme.of(context).primaryColor, - popoverToolbarBuilder: widget.iOSToolbarBuilder ?? (_) => const SizedBox(), - createOverlayControlsClipper: widget.createOverlayControlsClipper, + getDocumentLayout: () => _readerContext.documentLayout, + contentTapHandler: _contentTapDelegate, + scrollController: _scrollController, + fillViewport: fillViewport, showDebugPaint: widget.debugPaint.gestures, - child: documentLayout, + child: child, ); } } +} - Widget _buildDesktopGestureSystem(Widget documentLayout) { - return LayoutBuilder(builder: (context, viewportConstraints) { - return DocumentScrollable( - autoScroller: _autoScrollController, - scrollController: widget.scrollController, - scrollingMinimapId: widget.debugPaint.scrollingMinimapId, - showDebugPaint: widget.debugPaint.scrolling, - child: ConstrainedBox( - constraints: BoxConstraints( - // When SuperReader installs its own Viewport, we want the gesture - // detection to span throughout the Viewport. Because the gesture - // system sits around the DocumentLayout, within the Viewport, we - // have to explicitly tell the gesture area to be at least as tall - // as the viewport (in case the document content is shorter than - // the viewport). - minWidth: viewportConstraints.maxWidth < double.infinity ? viewportConstraints.maxWidth : 0, - minHeight: viewportConstraints.maxHeight < double.infinity ? viewportConstraints.maxHeight : 0, - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - // A layer that sits beneath the document and handles gestures. - // It's beneath the document so that components that include - // interactive UI, like a Checkbox, can intercept their own - // touch events. - Positioned.fill( - child: ReadOnlyDocumentMouseInteractor( - focusNode: _focusNode, - readerContext: _readerContext, - autoScroller: _autoScrollController, - showDebugPaint: widget.debugPaint.gestures, - child: const SizedBox(), - ), - ), - // The document that the user is editing. - Align( - alignment: Alignment.topCenter, - child: Stack( - children: [ - documentLayout, - // We display overlay builders in this inner-Stack so that they - // match the document size, rather than the viewport size. - for (final overlayBuilder in widget.documentOverlayBuilders) - Positioned.fill( - child: overlayBuilder.build(context, _readerContext), - ), - ], - ), - ), - ], - ), - ), - ); - }); +/// Builds a standard reader-style iOS floating toolbar. +Widget defaultIosReaderToolbarBuilder( + BuildContext context, + Key floatingToolbarKey, + LeaderLink focalPoint, + Document document, + ValueListenable selection, + SuperReaderIosControlsController editorControlsController, +) { + if (CurrentPlatform.isWeb) { + // On web, we defer to the browser's internal overlay controls for mobile. + return const SizedBox(); } + + return DefaultIosReaderToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + document: document, + selection: selection, + editorControlsController: editorControlsController, + ); } -/// A [DocumentEditor] that doesn't edit the given [Document]. -/// -/// A [_ReadOnlyDocumentEditor] can be used to display a [SuperReader], while -/// forcibly preventing any changes to the underlying document. -class _ReadOnlyDocumentEditor implements DocumentEditor { - const _ReadOnlyDocumentEditor({ +/// An iOS floating toolbar, which includes standard buttons for a reader use-case. +class DefaultIosReaderToolbar extends StatelessWidget { + const DefaultIosReaderToolbar({ + super.key, + this.floatingToolbarKey, + required this.focalPoint, required this.document, + required this.selection, + required this.editorControlsController, }); - @override + final Key? floatingToolbarKey; + final LeaderLink focalPoint; final Document document; + final ValueListenable selection; + final SuperReaderIosControlsController editorControlsController; @override - void executeCommand(EditorCommand command) { - if (kDebugMode) { - throw Exception("Attempted to edit a read-only document: $command"); + Widget build(BuildContext context) { + return IOSTextEditingFloatingToolbar( + floatingToolbarKey: floatingToolbarKey, + focalPoint: focalPoint, + onCopyPressed: _copy, + ); + } + + /// Copies selected content to the OS clipboard. + void _copy() { + editorControlsController.hideToolbar(); + + if (selection.value == null) { + return; } + + final textToCopy = extractTextFromSelection( + document: document, + documentSelection: selection.value!, + ); + // TODO: figure out a general approach for asynchronous behaviors that + // need to be carried out in response to user input. + Clipboard.setData(ClipboardData(text: textToCopy)); + } +} + +/// Default list of document overlays that are displayed on top of the document +/// layout in a [SuperReader]. +const defaultSuperReaderDocumentOverlayBuilders = [ + // Adds a Leader around the document selection at a focal point for the + // iOS floating toolbar. + SuperReaderIosToolbarFocalPointDocumentLayerBuilder(), + // Displays caret and drag handles, specifically for iOS. + SuperReaderIosHandlesDocumentLayerBuilder(), +]; + +/// A [SuperReaderDocumentLayerBuilder] that builds a [SelectionLeadersDocumentLayer], which positions +/// leader widgets at the base and extent of the user's selection, so that other widgets +/// can position themselves relative to the user's selection. +class _SelectionLeadersDocumentLayerBuilder implements SuperReaderDocumentLayerBuilder { + const _SelectionLeadersDocumentLayerBuilder({ + required this.links, + // TODO(srawlins): `unused_element`, when reporting a parameter, is being + // renamed to `unused_element_parameter`. For now, ignore each; when the SDK + // constraint is >= 3.6.0, just ignore `unused_element_parameter`. + // ignore: unused_element, unused_element_parameter + this.showDebugLeaderBounds = false, + }); + + /// Collections of [LayerLink]s, which are given to leader widgets that are + /// positioned at the selection bounds, and around the full selection. + final SelectionLayerLinks links; + + /// Whether to paint colorful bounds around the leader widgets, for debugging purposes. + final bool showDebugLeaderBounds; + + @override + ContentLayerWidget build(BuildContext context, SuperReaderContext readerContext) { + return SelectionLeadersDocumentLayer( + document: readerContext.document, + selection: readerContext.composer.selectionNotifier, + links: links, + showDebugLeaderBounds: showDebugLeaderBounds, + ); + } +} + +/// A [SuperReaderDocumentLayerBuilder] that builds a [IosToolbarFocalPointDocumentLayer], which +/// positions a `Leader` widget around the document selection, as a focal point for an +/// iOS floating toolbar. +class SuperReaderIosToolbarFocalPointDocumentLayerBuilder implements SuperReaderDocumentLayerBuilder { + const SuperReaderIosToolbarFocalPointDocumentLayerBuilder({ + // ignore: unused_element + this.showDebugLeaderBounds = false, + }); + + /// Whether to paint colorful bounds around the leader widget. + final bool showDebugLeaderBounds; + + @override + ContentLayerWidget build(BuildContext context, SuperReaderContext readerContext) { + return IosToolbarFocalPointDocumentLayer( + document: readerContext.document, + selection: readerContext.composer.selectionNotifier, + toolbarFocalPointLink: SuperReaderIosControlsScope.rootOf(context).toolbarFocalPoint, + showDebugLeaderBounds: showDebugLeaderBounds, + ); } } /// Builds widgets that are displayed at the same position and size as /// the document layout within a [SuperReader]. -abstract class ReadOnlyDocumentLayerBuilder { - Widget build(BuildContext context, ReaderContext documentContext); +abstract class SuperReaderDocumentLayerBuilder { + ContentLayerWidget build(BuildContext context, SuperReaderContext documentContext); +} + +typedef SuperReaderContentTapDelegateFactory = ContentTapDelegate Function(SuperReaderContext editContext); + +SuperReaderLaunchLinkTapHandler superReaderLaunchLinkTapHandlerFactory(SuperReaderContext readerContext) => + SuperReaderLaunchLinkTapHandler(readerContext.document); + +/// A [ContentTapDelegate] that opens links when the user taps text with +/// a [LinkAttribution]. +class SuperReaderLaunchLinkTapHandler extends ContentTapDelegate { + SuperReaderLaunchLinkTapHandler(this.document); + + final Document document; + + @override + MouseCursor? mouseCursorForContentHover(DocumentPosition hoverPosition) { + final link = _getLinkAtPosition(hoverPosition); + return link != null ? SystemMouseCursors.click : null; + } + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); + if (tapPosition == null) { + return TapHandlingInstruction.continueHandling; + } + + final link = _getLinkAtPosition(tapPosition); + if (link != null) { + // The user tapped on a link. Launch it. + UrlLauncher.instance.launchUrl(link); + return TapHandlingInstruction.halt; + } else { + // The user didn't tap on a link. + return TapHandlingInstruction.continueHandling; + } + } + + Uri? _getLinkAtPosition(DocumentPosition position) { + final nodePosition = position.nodePosition; + if (nodePosition is! TextNodePosition) { + return null; + } + + final textNode = document.getNodeById(position.nodeId); + if (textNode is! TextNode) { + readerGesturesLog + .shout("Received a report of a tap on a TextNodePosition, but the node with that ID is a: $textNode"); + return null; + } + + final tappedAttributions = textNode.text.getAllAttributionsAt(nodePosition.offset); + for (final tappedAttribution in tappedAttributions) { + if (tappedAttribution is LinkAttribution) { + return tappedAttribution.launchableUri; + } + } + + return null; + } } /// Creates visual components for the standard [SuperReader]. @@ -430,6 +735,7 @@ final readOnlyDefaultComponentBuilders = [ const ListItemComponentBuilder(), const ImageComponentBuilder(), const HorizontalRuleComponentBuilder(), + const ReadOnlyTaskComponentBuilder(), ]; /// Stylesheet applied to all [SuperReader]s by default. @@ -439,9 +745,9 @@ final readOnlyDefaultStylesheet = Stylesheet( BlockSelector.all, (doc, docNode) { return { - "maxWidth": 640.0, - "padding": const CascadingPadding.symmetric(horizontal: 24), - "textStyle": const TextStyle( + Styles.maxWidth: 640.0, + Styles.padding: const CascadingPadding.symmetric(horizontal: 24), + Styles.textStyle: const TextStyle( color: Colors.black, fontSize: 18, height: 1.4, @@ -453,8 +759,8 @@ final readOnlyDefaultStylesheet = Stylesheet( const BlockSelector("header1"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 40), - "textStyle": const TextStyle( + Styles.padding: const CascadingPadding.only(top: 40), + Styles.textStyle: const TextStyle( color: Color(0xFF333333), fontSize: 38, fontWeight: FontWeight.bold, @@ -466,8 +772,8 @@ final readOnlyDefaultStylesheet = Stylesheet( const BlockSelector("header2"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 32), - "textStyle": const TextStyle( + Styles.padding: const CascadingPadding.only(top: 32), + Styles.textStyle: const TextStyle( color: Color(0xFF333333), fontSize: 26, fontWeight: FontWeight.bold, @@ -479,8 +785,8 @@ final readOnlyDefaultStylesheet = Stylesheet( const BlockSelector("header3"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 28), - "textStyle": const TextStyle( + Styles.padding: const CascadingPadding.only(top: 28), + Styles.textStyle: const TextStyle( color: Color(0xFF333333), fontSize: 22, fontWeight: FontWeight.bold, @@ -492,7 +798,7 @@ final readOnlyDefaultStylesheet = Stylesheet( const BlockSelector("paragraph"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 24), + Styles.padding: const CascadingPadding.only(top: 24), }; }, ), @@ -500,7 +806,7 @@ final readOnlyDefaultStylesheet = Stylesheet( const BlockSelector("paragraph").after("header1"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 0), + Styles.padding: const CascadingPadding.only(top: 0), }; }, ), @@ -508,7 +814,7 @@ final readOnlyDefaultStylesheet = Stylesheet( const BlockSelector("paragraph").after("header2"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 0), + Styles.padding: const CascadingPadding.only(top: 0), }; }, ), @@ -516,7 +822,7 @@ final readOnlyDefaultStylesheet = Stylesheet( const BlockSelector("paragraph").after("header3"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 0), + Styles.padding: const CascadingPadding.only(top: 0), }; }, ), @@ -524,7 +830,7 @@ final readOnlyDefaultStylesheet = Stylesheet( const BlockSelector("listItem"), (doc, docNode) { return { - "padding": const CascadingPadding.only(top: 24), + Styles.padding: const CascadingPadding.only(top: 24), }; }, ), @@ -532,7 +838,7 @@ final readOnlyDefaultStylesheet = Stylesheet( const BlockSelector("blockquote"), (doc, docNode) { return { - "textStyle": const TextStyle( + Styles.textStyle: const TextStyle( color: Colors.grey, fontSize: 20, fontWeight: FontWeight.bold, @@ -545,12 +851,13 @@ final readOnlyDefaultStylesheet = Stylesheet( BlockSelector.all.last(), (doc, docNode) { return { - "padding": const CascadingPadding.only(bottom: 96), + Styles.padding: const CascadingPadding.only(bottom: 96), }; }, ), ], inlineTextStyler: readOnlyDefaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, ); TextStyle readOnlyDefaultInlineTextStyler(Set attributions, TextStyle existingStyle) { @@ -582,6 +889,10 @@ TextStyle readOnlyDefaultStyleBuilder(Set attributions) { ? TextDecoration.lineThrough : TextDecoration.combine([TextDecoration.lineThrough, newStyle.decoration!]), ); + } else if (attribution is ColorAttribution) { + newStyle = newStyle.copyWith( + color: attribution.color, + ); } else if (attribution is LinkAttribution) { newStyle = newStyle.copyWith( color: Colors.lightBlue, diff --git a/super_editor/lib/src/super_reader/tasks.dart b/super_editor/lib/src/super_reader/tasks.dart new file mode 100644 index 0000000000..36862bab71 --- /dev/null +++ b/super_editor/lib/src/super_reader/tasks.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/default_editor/layout_single_column/layout_single_column.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; + +/// Builds [TaskComponentViewModel]s and [TaskComponent]s for every +/// [TaskNode] in a document. +/// +/// A [TaskComponent] built by this builder is read-only, meaning that +/// the user cannot toggle it. +class ReadOnlyTaskComponentBuilder implements ComponentBuilder { + const ReadOnlyTaskComponentBuilder(); + + @override + TaskComponentViewModel? createViewModel(Document document, DocumentNode node) { + if (node is! TaskNode) { + return null; + } + + final textDirection = getParagraphDirection(node.text.toPlainText()); + + return TaskComponentViewModel( + nodeId: node.id, + createdAt: node.metadata[NodeMetadata.createdAt], + padding: EdgeInsets.zero, + indent: node.indent, + isComplete: node.isComplete, + setComplete: null, + text: node.text, + textDirection: textDirection, + textAlignment: textDirection == TextDirection.ltr ? TextAlign.left : TextAlign.right, + textStyleBuilder: noStyleBuilder, + selectionColor: const Color(0x00000000), + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! TaskComponentViewModel) { + return null; + } + + return TaskComponent( + key: componentContext.componentKey, + viewModel: componentViewModel, + ); + } +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/android/_caret.dart b/super_editor/lib/src/super_textfield/android/_caret.dart similarity index 97% rename from super_editor/lib/src/infrastructure/super_textfield/android/_caret.dart rename to super_editor/lib/src/super_textfield/android/_caret.dart index 1e329116e2..16622baa50 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/android/_caret.dart +++ b/super_editor/lib/src/super_textfield/android/_caret.dart @@ -43,7 +43,7 @@ class AndroidTextFieldCaret extends StatefulWidget { final BorderRadius caretBorderRadius; @override - _AndroidTextFieldCaretState createState() => _AndroidTextFieldCaretState(); + State createState() => _AndroidTextFieldCaretState(); } class _AndroidTextFieldCaretState extends State with SingleTickerProviderStateMixin { @@ -138,7 +138,7 @@ class AndroidCursorPainter extends CustomPainter { void _drawCaret({ required Canvas canvas, }) { - caretPaint.color = caretColor.withOpacity(blinkController.opacity); + caretPaint.color = caretColor.withValues(alpha: blinkController.opacity); double caretHeight = textLayout.getHeightForCaret(selection.extent) ?? emptyTextCaretHeight; final caretOffset = textLayout.getOffsetAtPosition(selection.extent); diff --git a/super_editor/lib/src/infrastructure/super_textfield/android/_editing_controls.dart b/super_editor/lib/src/super_textfield/android/_editing_controls.dart similarity index 75% rename from super_editor/lib/src/infrastructure/super_textfield/android/_editing_controls.dart rename to super_editor/lib/src/super_textfield/android/_editing_controls.dart index 044b13dcd5..bee06b9c5a 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/android/_editing_controls.dart +++ b/super_editor/lib/src/super_textfield/android/_editing_controls.dart @@ -1,17 +1,24 @@ import 'dart:async'; import 'dart:math'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/platforms/android/magnifier.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/android/android_textfield.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_controller.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/text_scrollview.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; +import 'package:super_editor/src/super_textfield/android/drag_handle_selection.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/attributed_text_editing_controller.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_scrollview.dart'; import 'package:super_editor/src/infrastructure/toolbar_position_delegate.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; import 'package:super_text_layout/super_text_layout.dart'; +import 'package:super_editor/src/super_textfield/metrics.dart'; + final _log = androidTextFieldLog; /// Overlay editing controls for an Android-style text field. @@ -32,6 +39,7 @@ class AndroidEditingOverlayControls extends StatefulWidget { required this.textContentKey, required this.textFieldLayerLink, required this.textContentLayerLink, + this.tapRegionGroupId, required this.handleColor, required this.popoverToolbarBuilder, this.showDebugPaint = false, @@ -62,6 +70,10 @@ class AndroidEditingOverlayControls extends StatefulWidget { /// the text field. final GlobalKey textContentKey; + /// A group ID that's assigned to a [TagRegion] around each widget added + /// by this overlay. + final String? tapRegionGroupId; + /// The color of the selection handles. final Color handleColor; @@ -72,10 +84,10 @@ class AndroidEditingOverlayControls extends StatefulWidget { /// selected text. /// /// Typically, this bar includes actions like "copy", "cut", "paste", etc. - final Widget Function(BuildContext, AndroidEditingOverlayController) popoverToolbarBuilder; + final Widget Function(BuildContext, AndroidEditingOverlayController, ToolbarConfig) popoverToolbarBuilder; @override - _AndroidEditingOverlayControlsState createState() => _AndroidEditingOverlayControlsState(); + State createState() => _AndroidEditingOverlayControlsState(); } class _AndroidEditingOverlayControlsState extends State with WidgetsBindingObserver { @@ -101,12 +113,27 @@ class _AndroidEditingOverlayControlsState extends State + widget.editingController.textController.selection.isCollapsed && !_isDraggingBase && !_isDraggingExtent; + + /// Holds the offset in text layout space where the collapsed drag handle is displayed. + Offset? _collapsedHandleOffset; @override void initState() { @@ -115,6 +142,12 @@ class _AndroidEditingOverlayControlsState extends State _updateOffsetForCollapsedHandle()); + } } @override @@ -124,6 +157,12 @@ class _AndroidEditingOverlayControlsState extends State _updateOffsetForCollapsedHandle()); + } } } @@ -141,13 +180,7 @@ class _AndroidEditingOverlayControlsState extends State widget.textContentKey.currentState!.textLayout; @@ -156,19 +189,15 @@ class _AndroidEditingOverlayControlsState extends State _updateOffsetForCollapsedHandle()); + return; + } + + setState(() { + _collapsedHandleOffset = offset; + }); + } + + /// Computes the offset for the collapsed handle in text layout space. + /// + /// Returns `null` if the offset can't be computed at the current frame. + Offset? _computeOffsetForCollapsedHandle() { + final extentTextPosition = widget.editingController.textController.selection.extent; + _log.finer('Collapsed handle text position: $extentTextPosition'); + final extentHandleOffsetInText = _textPositionToTextOffset(extentTextPosition); + _log.finer('Collapsed handle text offset: $extentHandleOffsetInText'); + + if (extentHandleOffsetInText == const Offset(0, 0) && extentTextPosition.offset != 0) { + // The caret offset is (0, 0), but the caret text position isn't at the + // beginning of the text. This means that there's a layout timing + // issue and we should reschedule this calculation for the next frame. + return null; + } + + double extentLineHeight = + _textLayout.getCharacterBox(extentTextPosition)?.toRect().height ?? _textLayout.estimatedLineHeight; + if (widget.editingController.textController.text.isEmpty) { + extentLineHeight = _textLayout.getLineHeightAtPosition(extentTextPosition); + } + + if (extentLineHeight == 0) { + _log.finer('Not building collapsed handle because the text layout reported a zero line-height'); + // A line height of zero indicates that the text isn't laid out yet. + // We need to wait until the next frame. + return null; + } + + return extentHandleOffsetInText + Offset(0, extentLineHeight); + } + @override Widget build(BuildContext context) { final textFieldRenderObject = context.findRenderObject(); if (textFieldRenderObject == null) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - setState(() {}); - }); + scheduleBuildAfterBuild(); return const SizedBox(); } @@ -405,7 +534,6 @@ class _AndroidEditingOverlayControlsState extends State _onHandleTap(handleType), onPanStart: onPanStart, onPanUpdate: _onPanUpdate, onPanEnd: _onPanEnd, @@ -647,9 +773,12 @@ class _AndroidEditingOverlayControlsState extends State _magnifierFocalPoint; + final LeaderLink _magnifierFocalPoint; + LeaderLink get magnifierFocalPoint => _magnifierFocalPoint; bool _isMagnifierVisible = false; bool get isMagnifierVisible => _isMagnifierVisible; diff --git a/super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart b/super_editor/lib/src/super_textfield/android/_user_interaction.dart similarity index 65% rename from super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart rename to super_editor/lib/src/super_textfield/android/_user_interaction.dart index cdec72c1f5..d6758e4cbb 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/android/_user_interaction.dart +++ b/super_editor/lib/src/super_textfield/android/_user_interaction.dart @@ -1,8 +1,13 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; import '_editing_controls.dart'; @@ -18,6 +23,8 @@ final _log = androidTextFieldLog; /// /// * Tap: Place a collapsed text selection at the tapped location /// in text. +/// * Long-Press (over text): select surrounding word. +/// * Long-Press (in empty space with a selection): show the toolbar. /// * Double-Tap: Select the word surrounding the tapped location /// * Triple-Tap: Select the paragraph surrounding the tapped location /// * Drag: Move a collapsed selection wherever the user drags, while @@ -44,8 +51,10 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { required this.editingOverlayController, required this.textScrollController, required this.textKey, + required this.getGlobalCaretRect, required this.isMultiline, required this.handleColor, + this.tapHandlers = const [], this.showDebugPaint = false, required this.child, }) : super(key: key); @@ -77,6 +86,10 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { /// this [AndroidTextFieldTouchInteractor]. final GlobalKey textKey; + /// A function that returns the current caret global rect, or `null` if no + /// caret exists. + final Rect? Function() getGlobalCaretRect; + /// Whether the text field that owns this [AndroidTextFieldInteractor] is /// a multiline text field. final bool isMultiline; @@ -84,6 +97,9 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { /// The color of expanded selection drag handles. final Color handleColor; + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + /// Whether to paint debugging guides and regions. final bool showDebugPaint; @@ -96,6 +112,10 @@ class AndroidTextFieldTouchInteractor extends StatefulWidget { class AndroidTextFieldTouchInteractorState extends State with TickerProviderStateMixin { + /// The maximum horizontal distance that a user can press near the caret to enable + /// a caret drag. + static const _closeEnoughToDragCaret = 48.0; + final _textViewportOffsetLink = LayerLink(); // Whether the user is dragging a collapsed selection. @@ -109,6 +129,7 @@ class AndroidTextFieldTouchInteractorState extends State widget.textKey.currentState!.textLayout; - void _onTapDown(TapDownDetails details) { - _log.fine("User tapped down"); - if (!widget.focusNode.hasFocus) { - _log.finer("Field isn't focused. Ignoring press."); - return; + void _onTextOrSelectionChange() { + if (!_isDraggingCaret) { + // The user isn't dragging the caret. Ensure the current selection is visible. The + // user may have typed beyond the viewport, or something may have changed the controller's + // selection to sit beyond the viewport. + // + // We don't do this when the user is dragging the caret because the user's finger position + // and the auto-scrolling system should control the scroll offset in that case. + onNextFrame((timeStamp) { + // We adjust for the extent offset in the next frame because we need the + // underlying RenderParagraph to update first, so that we can inspect the + // text layout for the most recent text and selection. + widget.textScrollController.ensureExtentIsVisible(); + }); } + } - // When the user drags, the toolbar should not be visible. - // A drag can begin with a tap down, so we hide the toolbar - // preemptively. - widget.editingOverlayController.hideToolbar(); + void _onTapDown(TapDownDetails details) { + final textOffset = _globalOffsetToTextOffset(details.globalPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onTapDown( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); - _selectAtOffset(details.localPosition); + if (result == TapHandlingInstruction.halt) { + return; + } + } } void _onTapUp(TapUpDetails details) { _log.fine('User released a tap'); + final textOffset = _globalOffsetToTextOffset(details.globalPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onTapUp( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + if (widget.focusNode.hasFocus && widget.textController.isAttachedToIme) { widget.textController.showKeyboard(); } else { @@ -169,9 +235,8 @@ class AndroidTextFieldTouchInteractorState extends State= previousSelection.start && tapTextPosition.offset <= previousSelection.end; - if (didTapOnExistingSelection) { - // Toggle the toolbar display when the user taps on the collapsed caret, - // or on top of an existing selection. + if (didTapOnExistingSelection && previousSelection.isCollapsed) { + // Toggle the toolbar display when the user taps on the collapsed caret. widget.editingOverlayController.toggleToolbar(); } else { // The user tapped somewhere in the text outside any existing selection. @@ -189,6 +254,16 @@ class AndroidTextFieldTouchInteractorState extends State= 0 ? TextSelection.collapsed(offset: tapTextPosition.offset) : const TextSelection.collapsed(offset: 0); } + widget.textController.composingRegion = TextRange.empty; widget.editingOverlayController.showHandles(); } + void _onLongPress() { + if (!widget.textController.selection.isValid) { + // There's no user selection. Don't show the toolbar when there's + // nothing to apply it to. + return; + } + + widget.editingOverlayController.showToolbar(); + } + void _onDoubleTapDown(TapDownDetails details) { _log.fine("User double-tapped down"); + + final textOffset = _globalOffsetToTextOffset(details.globalPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onDoubleTapDown( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + widget.focusNode.requestFocus(); final tapTextPosition = _getTextPositionAtOffset(details.localPosition); @@ -234,8 +339,57 @@ class AndroidTextFieldTouchInteractorState extends State _closeEnoughToDragCaret) { + // There's a caret, but the user's drag offset is far away. Fizzle. + return; + } + setState(() { _isDraggingCaret = true; _globalDragOffset = details.globalPosition; @@ -260,16 +455,28 @@ class AndroidTextFieldTouchInteractorState extends State( + () => LongPressGestureRecognizer(), + (LongPressGestureRecognizer recognizer) { + recognizer + ..onLongPress = _onLongPress + ..gestureSettings = gestureSettings; }, ), PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => PanGestureRecognizer(), (PanGestureRecognizer recognizer) { recognizer - ..onStart = widget.focusNode.hasFocus ? _onTextPanStart : null + ..onStart = widget.focusNode.hasFocus ? _onPanStart : null ..onUpdate = widget.focusNode.hasFocus ? _onPanUpdate : null ..onEnd = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanEnd : null - ..onCancel = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanCancel : null; + ..onCancel = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanCancel : null + ..gestureSettings = gestureSettings; }, ), }, @@ -465,7 +692,7 @@ class AndroidTextFieldTouchInteractorState extends State tapHandlers; + + /// Whether to paint debug guides. + final bool showDebugPaint; + + /// Builder that creates the popover toolbar widget that appears when text is selected. + final Widget Function(BuildContext, AndroidEditingOverlayController, ToolbarConfig) popoverToolbarBuilder; + + /// Padding placed around the text content of this text field, but within the + /// scrollable viewport. + final EdgeInsets? padding; + + @override + State createState() => SuperAndroidTextFieldState(); +} + +class SuperAndroidTextFieldState extends State + with TickerProviderStateMixin, WidgetsBindingObserver + implements ProseTextBlock, ImeInputOwner { + static const Duration _autoScrollAnimationDuration = Duration(milliseconds: 100); + static const Curve _autoScrollAnimationCurve = Curves.fastOutSlowIn; + + final _textFieldKey = GlobalKey(); + final _textFieldLayerLink = LayerLink(); + final _textContentLayerLink = LayerLink(); + final _scrollKey = GlobalKey(); + final _textContentKey = GlobalKey(); + + late FocusNode _focusNode; + + late ImeAttributedTextEditingController _textEditingController; + + /// The text direction of the first character in the text. + /// + /// Used to align and position the caret depending on whether + /// the text is RTL or LTR. + TextDirection? _contentTextDirection; + + /// The text direction applied to the inner text. + TextDirection get _textDirection => _contentTextDirection ?? TextDirection.ltr; + + TextAlign get _textAlign => + widget.textAlign ?? + ((_textDirection == TextDirection.ltr) // + ? TextAlign.left + : TextAlign.right); + + final _magnifierLayerLink = LeaderLink(); + late AndroidEditingOverlayController _editingOverlayController; + + late TextScrollController _textScrollController; + + /// Opens/closes the popover that displays the toolbar and magnifier, and + // positions the invisible touch targets for base/extent dragging. + final _popoverController = OverlayPortalController(); + + late final BlinkController _caretBlinkController; + + /// Notifies the popover toolbar to rebuild itself. + final _popoverRebuildSignal = SignalNotifier(); + + @override + void initState() { + super.initState(); + + switch (widget.blinkTimingMode) { + case BlinkTimingMode.ticker: + _caretBlinkController = BlinkController(tickerProvider: this); + case BlinkTimingMode.timer: + _caretBlinkController = BlinkController.withTimer(); + } + + _focusNode = (widget.focusNode ?? FocusNode())..addListener(_updateSelectionAndImeConnectionOnFocusChange); + + _textEditingController = (widget.textController ?? ImeAttributedTextEditingController()) + ..addListener(_onTextOrSelectionChange) + ..onPerformActionPressed ??= _onPerformActionPressed; + + _textScrollController = TextScrollController( + textController: _textEditingController, + tickerProvider: this, + )..addListener(_onTextScrollChange); + + _editingOverlayController = AndroidEditingOverlayController( + textController: _textEditingController, + caretBlinkController: _caretBlinkController, + magnifierFocalPoint: _magnifierLayerLink, + ); + + _contentTextDirection = getParagraphDirection(_textEditingController.text.toPlainText()); + + WidgetsBinding.instance.addObserver(this); + + if (_focusNode.hasFocus) { + // The given FocusNode already has focus, we need to update selection and attach to IME. + onNextFrame((_) => _updateSelectionAndImeConnectionOnFocusChange()); + } + + if (_textEditingController.selection.isValid) { + // The text field was initialized with a selection - immediately ensure that the + // extent is visible. + onNextFrame((_) => _textScrollController.ensureExtentIsVisible()); + } + } + + @override + void didUpdateWidget(SuperAndroidTextField oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.focusNode != oldWidget.focusNode) { + _focusNode.removeListener(_updateSelectionAndImeConnectionOnFocusChange); + _focusNode = (widget.focusNode ?? FocusNode())..addListener(_updateSelectionAndImeConnectionOnFocusChange); + } + + if (widget.textInputAction != oldWidget.textInputAction && + widget.textInputAction != null && + _textEditingController.isAttachedToIme) { + _textEditingController.updateTextInputConfiguration( + viewId: View.of(context).viewId, + textInputAction: widget.textInputAction!, + textInputType: _isMultiline ? TextInputType.multiline : TextInputType.text, + ); + } + + if (widget.imeConfiguration != oldWidget.imeConfiguration && + widget.imeConfiguration != null && + (oldWidget.imeConfiguration == null || !widget.imeConfiguration!.isEquivalentTo(oldWidget.imeConfiguration!)) && + _textEditingController.isAttachedToIme) { + _textEditingController.updateTextInputConfiguration( + viewId: View.of(context).viewId, + textInputAction: widget.imeConfiguration!.inputAction, + textInputType: widget.imeConfiguration!.inputType, + autocorrect: widget.imeConfiguration!.autocorrect, + enableSuggestions: widget.imeConfiguration!.enableSuggestions, + keyboardAppearance: widget.imeConfiguration!.keyboardAppearance, + textCapitalization: widget.imeConfiguration!.textCapitalization, + ); + } + + if (widget.textController != oldWidget.textController) { + _textEditingController.removeListener(_onTextOrSelectionChange); + if (_textEditingController.onPerformActionPressed == _onPerformActionPressed) { + _textEditingController.onPerformActionPressed = null; + } + if (widget.textController != null) { + _textEditingController = widget.textController!; + } else { + _textEditingController = ImeAttributedTextEditingController(); + } + _textEditingController + ..addListener(_onTextOrSelectionChange) + ..onPerformActionPressed ??= _onPerformActionPressed; + } + + if (widget.showDebugPaint != oldWidget.showDebugPaint) { + onNextFrame((_) => _rebuildEditingOverlayControls()); + } + } + + @override + void reassemble() { + super.reassemble(); + + // On Hot Reload we need to remove any visible overlay controls and then + // bring them back a frame later to avoid having the controls attempt + // to access the layout of the text. The text layout is not immediately + // available upon Hot Reload. Accessing it results in an exception. + _removeEditingOverlayControls(); + + onNextFrame((_) => _showEditingControlsOverlay()); + } + + @override + void dispose() { + _removeEditingOverlayControls(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // Dispose after the current frame so that other widgets have + // time to remove their listeners. + _editingOverlayController.dispose(); + }); + + _textEditingController + ..removeListener(_onTextOrSelectionChange) + ..detachFromIme(); + if (widget.textController == null) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // Dispose after the current frame so that other widgets have + // time to remove their listeners. + _textEditingController.dispose(); + }); + } + + _focusNode.removeListener(_updateSelectionAndImeConnectionOnFocusChange); + if (widget.focusNode == null) { + _focusNode.dispose(); + } + + _textScrollController + ..removeListener(_onTextScrollChange) + ..dispose(); + + _caretBlinkController.dispose(); + + _popoverRebuildSignal.dispose(); + + WidgetsBinding.instance.removeObserver(this); + + super.dispose(); + } + + @override + void didChangeMetrics() { + // The available screen dimensions may have changed, e.g., due to keyboard + // appearance/disappearance. + onNextFrame((_) { + if (!_focusNode.hasFocus) { + return; + } + + _autoScrollToKeepTextFieldVisible(); + }); + } + + @visibleForTesting + TextScrollController get scrollController => _textScrollController; + + @override + ProseTextLayout get textLayout => _textContentKey.currentState!.textLayout; + + /// Calculates and returns the `Offset` from the top-left corner of this text field + /// to the top-left corner of the [textLayout] within this text field. + Offset get textLayoutOffsetInField { + final fieldBox = context.findRenderObject() as RenderBox; + final textLayoutBox = _textContentKey.currentContext!.findRenderObject() as RenderBox; + return textLayoutBox.localToGlobal(Offset.zero, ancestor: fieldBox); + } + + @visibleForTesting + bool get isCollapsedHandleVisible => + _editingOverlayController.areHandlesVisible && !_editingOverlayController.isCollapsedHandleAutoHidden; + + Rect? _getGlobalCaretRect() { + if (!_textEditingController.selection.isValid || !_textEditingController.selection.isCollapsed) { + // Either there's no selection, or the selection is expanded. In either case, there's no caret. + return null; + } + + final globalTextOffset = + (_textContentKey.currentContext!.findRenderObject() as RenderBox).localToGlobal(Offset.zero); + + final caretPosition = _textEditingController.selection.extent; + final caretOffset = textLayout.getOffsetForCaret(caretPosition) + globalTextOffset; + final caretHeight = textLayout.getHeightForCaret(caretPosition)!; + + return Rect.fromLTWH(caretOffset.dx, caretOffset.dy, 1, caretHeight); + } + + @override + DeltaTextInputClient get imeClient => _textEditingController; + + bool get _isMultiline => (widget.minLines ?? 1) != 1 || widget.maxLines != 1; + + void _updateSelectionAndImeConnectionOnFocusChange() { + // The focus change callback might be invoked in the build phase, usually when used inside + // an OverlayPortal. If that's the case, defer the setState call until the end of the frame. + WidgetsBinding.instance.runAsSoonAsPossible(() { + if (!mounted) { + return; + } + + if (_focusNode.hasFocus) { + if (!_textEditingController.isAttachedToIme) { + _log.info('Attaching TextInputClient to TextInput'); + setState(() { + if (!_textEditingController.selection.isValid) { + _textEditingController.selection = TextSelection.collapsed(offset: _textEditingController.text.length); + } + + if (widget.imeConfiguration != null) { + _textEditingController.attachToImeWithConfig(widget.imeConfiguration!); + } else { + _textEditingController.attachToIme( + viewId: View.of(context).viewId, + textInputAction: widget.textInputAction ?? TextInputAction.done, + textInputType: _isMultiline ? TextInputType.multiline : TextInputType.text, + ); + } + + _autoScrollToKeepTextFieldVisible(); + _showEditingControlsOverlay(); + }); + } + } else { + _log.info('Lost focus. Detaching TextInputClient from TextInput.'); + setState(() { + _textEditingController.detachFromIme(); + _textEditingController.selection = const TextSelection.collapsed(offset: -1); + _textEditingController.composingRegion = TextRange.empty; + _removeEditingOverlayControls(); + }); + } + }); + } + + void _onTextOrSelectionChange() { + if (_textEditingController.selection.isCollapsed) { + _editingOverlayController.hideToolbar(); + } + + setState(() { + _contentTextDirection = getParagraphDirection(_textEditingController.text.toPlainText()); + }); + } + + void _onTextScrollChange() { + if (_popoverController.isShowing) { + _rebuildEditingOverlayControls(); + } + } + + /// Displays [AndroidEditingOverlayControls] in the [OverlayPortal], if not already + /// displayed. + void _showEditingControlsOverlay() { + if (!_popoverController.isShowing) { + _popoverController.show(); + } + } + + /// Rebuilds the [AndroidEditingOverlayControls] in the [OverlayPortal], if + /// they're currently displayed. + void _rebuildEditingOverlayControls() { + if (_popoverController.isShowing) { + _popoverRebuildSignal.notifyListeners(); + } + } + + /// Hides the [AndroidEditingOverlayControls] in the [OverlayPortal], if they're + /// currently displayed. + void _removeEditingOverlayControls() { + if (_popoverController.isShowing) { + _popoverController.hide(); + } + } + + /// Handles actions from the IME + void _onPerformActionPressed(TextInputAction action) { + switch (action) { + case TextInputAction.done: + _focusNode.unfocus(); + break; + case TextInputAction.next: + _focusNode.nextFocus(); + break; + case TextInputAction.previous: + _focusNode.previousFocus(); + break; + default: + _log.warning("User pressed unhandled action button: $action"); + } + } + + /// Handles key presses + /// + /// Some third party keyboards report backspace as a key press + /// rather than a deletion delta, so we need to handle them manually + KeyEventResult _onKeyEventPressed(FocusNode focusNode, KeyEvent keyEvent) { + _log.finer('_onKeyEventPressed - keyEvent: ${keyEvent.character}'); + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + _log.finer('_onKeyEventPressed - not a "down" event. Ignoring.'); + return KeyEventResult.ignored; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return KeyEventResult.ignored; + } + + if (_textEditingController.selection.isCollapsed) { + _textEditingController.deletePreviousCharacter(); + } else { + _textEditingController.deleteSelection(); + } + + return KeyEventResult.handled; + } + + /// Scrolls the ancestor [Scrollable], if any, so [SuperTextField] + /// is visible on the viewport when it's focused + void _autoScrollToKeepTextFieldVisible() { + // If we are not inside a [Scrollable] we don't autoscroll + final ancestorScrollable = context.findAncestorScrollableWithVerticalScroll; + if (ancestorScrollable == null) { + return; + } + + // Compute the text field offset that should be visible to the user + final textFieldFocalPoint = widget.maxLines == null && _textEditingController.selection.isValid + ? _textContentKey.currentState!.textLayout.getOffsetAtPosition( + TextPosition(offset: _textEditingController.selection.extentOffset), + ) + : Offset.zero; + + final lineHeight = _textContentKey.currentState!.textLayout.getLineHeightAtPosition( + TextPosition(offset: _textEditingController.selection.extentOffset), + ); + final fieldBox = context.findRenderObject() as RenderBox; + + // The area of the text field that should be revealed. + // We add a small margin to leave some space between the text field and the keyboard. + final textFieldFocalRect = Rect.fromLTWH( + textFieldFocalPoint.dx, + textFieldFocalPoint.dy, + fieldBox.size.width, + lineHeight + gapBetweenCaretAndKeyboard, + ); + + fieldBox.showOnScreen( + rect: textFieldFocalRect, + duration: _autoScrollAnimationDuration, + curve: _autoScrollAnimationCurve, + ); + } + + @override + Widget build(BuildContext context) { + return TapRegion( + groupId: widget.tapRegionGroupId, + child: Focus( + key: _textFieldKey, + focusNode: _focusNode, + onKeyEvent: _onKeyEventPressed, + child: CompositedTransformTarget( + link: _textFieldLayerLink, + child: AndroidTextFieldTouchInteractor( + focusNode: _focusNode, + tapHandlers: widget.tapHandlers, + textKey: _textContentKey, + getGlobalCaretRect: _getGlobalCaretRect, + textFieldLayerLink: _textFieldLayerLink, + textController: _textEditingController, + editingOverlayController: _editingOverlayController, + textScrollController: _textScrollController, + isMultiline: _isMultiline, + handleColor: widget.handlesColor, + showDebugPaint: widget.showDebugPaint, + child: TextScrollView( + key: _scrollKey, + textScrollController: _textScrollController, + textKey: _textContentKey, + textEditingController: _textEditingController, + textAlign: _textAlign, + minLines: widget.minLines, + maxLines: widget.maxLines, + lineHeight: widget.lineHeight, + padding: EdgeInsets.only(top: widget.padding?.top ?? 0, bottom: widget.padding?.bottom ?? 0), + perLineAutoScrollDuration: const Duration(milliseconds: 100), + showDebugPaint: widget.showDebugPaint, + child: FillWidthIfConstrained( + child: Padding( + padding: EdgeInsets.only(left: widget.padding?.left ?? 0, right: widget.padding?.right ?? 0), + child: CompositedTransformTarget( + link: _textContentLayerLink, + child: ListenableBuilder( + listenable: _textEditingController, + builder: (context, _) { + return _buildSelectableText(); + }, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildSelectableText() { + final textSpan = _textEditingController.text.isNotEmpty + ? _textEditingController.text.computeInlineSpan(context, widget.textStyleBuilder, widget.inlineWidgetBuilders) + : TextSpan(text: "", style: widget.textStyleBuilder({})); + + return Directionality( + textDirection: _textDirection, + child: SuperText( + key: _textContentKey, + richText: textSpan, + textAlign: _textAlign, + textDirection: _textDirection, + textScaler: MediaQuery.textScalerOf(context), + layerBeneathBuilder: (context, textLayout) { + final isTextEmpty = _textEditingController.text.isEmpty; + final showHint = widget.hintBuilder != null && + ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || + (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); + + return Stack( + children: [ + if (widget.textController?.selection.isValid == true) + // Selection highlight beneath the text. + TextLayoutSelectionHighlight( + textLayout: textLayout, + style: SelectionHighlightStyle( + color: widget.selectionColor, + ), + selection: widget.textController?.selection, + ), + // Underline beneath the composing region. + if (widget.textController?.composingRegion.isValid == true && widget.showComposingUnderline) + TextUnderlineLayer( + textLayout: textLayout, + style: StraightUnderlineStyle( + color: widget.textStyleBuilder({}).color ?? // + (Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white), + ), + underlines: [ + TextLayoutUnderline( + range: widget.textController!.composingRegion, + ), + ], + ), + if (showHint) // + widget.hintBuilder!(context), + ], + ); + }, + layerAboveBuilder: (context, textLayout) { + if (!_focusNode.hasFocus) { + return const SizedBox(); + } + + return OverlayPortal( + controller: _popoverController, + overlayChildBuilder: _buildPopoverToolbar, + child: TextLayoutCaret( + textLayout: textLayout, + style: widget.caretStyle, + position: _textEditingController.selection.isCollapsed // + ? _textEditingController.selection.extent + : null, + blinkController: _caretBlinkController, + ), + ); + }, + ), + ); + } + + Widget _buildPopoverToolbar(BuildContext context) { + return ListenableBuilder( + listenable: _popoverRebuildSignal, + builder: (context, _) { + return AndroidEditingOverlayControls( + editingController: _editingOverlayController, + textScrollController: _textScrollController, + textFieldLayerLink: _textFieldLayerLink, + textFieldKey: _textFieldKey, + textContentLayerLink: _textContentLayerLink, + textContentKey: _textContentKey, + tapRegionGroupId: widget.tapRegionGroupId, + handleColor: widget.handlesColor, + popoverToolbarBuilder: widget.popoverToolbarBuilder, + showDebugPaint: widget.showDebugPaint, + ); + }, + ); + } +} + +Widget _defaultAndroidToolbarBuilder( + BuildContext context, + AndroidEditingOverlayController controller, + ToolbarConfig config, +) { + final isSelectionExpanded = !controller.textController.selection.isCollapsed; + + return AndroidTextEditingFloatingToolbar( + onCutPressed: isSelectionExpanded // + ? () => _onToolbarCutPressed(controller) + : null, + onCopyPressed: isSelectionExpanded // + ? () => _onToolbarCopyPressed(controller) + : null, + onPastePressed: () => _onToolbarPastePressed(controller), + onSelectAllPressed: () => _onToolbarSelectAllPressed(controller), + ); +} + +void _onToolbarCutPressed(AndroidEditingOverlayController controller) { + final textController = controller.textController; + + final selection = textController.selection; + if (selection.isCollapsed) { + return; + } + + final selectedText = selection.textInside(textController.text.toPlainText()); + + textController.deleteSelectedText(); + + Clipboard.setData(ClipboardData(text: selectedText)); +} + +void _onToolbarCopyPressed(AndroidEditingOverlayController controller) { + final textController = controller.textController; + final selection = textController.selection; + final selectedText = selection.textInside(textController.text.toPlainText()); + + Clipboard.setData(ClipboardData(text: selectedText)); +} + +Future _onToolbarPastePressed(AndroidEditingOverlayController controller) async { + final clipboardContent = await Clipboard.getData('text/plain'); + if (clipboardContent == null || clipboardContent.text == null) { + return; + } + + final textController = controller.textController; + final selection = textController.selection; + if (selection.isCollapsed) { + textController.insertAtCaret(text: clipboardContent.text!); + } else { + textController.replaceSelectionWithUnstyledText(replacementText: clipboardContent.text!); + } +} + +void _onToolbarSelectAllPressed(AndroidEditingOverlayController controller) { + controller.textController.selectAll(); +} diff --git a/super_editor/lib/src/super_textfield/android/drag_handle_selection.dart b/super_editor/lib/src/super_textfield/android/drag_handle_selection.dart new file mode 100644 index 0000000000..e45823d5f4 --- /dev/null +++ b/super_editor/lib/src/super_textfield/android/drag_handle_selection.dart @@ -0,0 +1,199 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/touch_controls.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +/// A strategy for selecting text while the user is dragging a drag handle, +/// similar to how the Android OS selects text during a handle drag. +/// +/// The following behaviors are implemented: +/// +/// - When the user drags a downstream handle in downstream direction, +/// the selection expands by word. +/// +/// - When the user drags a downstream handle in upstream direction, +/// the selection expands by character. +/// +/// - When the user drags an upstream handle in upstream direction, +/// the selection expands by word. +/// +/// - When the user drags an upstream handle in downstream direction, +/// the selection expands by character. +/// +/// - When the user drags a collapsed handle, the selection is placed +/// at the drag handle focal point. +class AndroidDocumentDragHandleSelectionStrategy { + AndroidDocumentDragHandleSelectionStrategy({ + required GlobalKey textContentKey, + required ProseTextLayout textLayout, + required void Function(TextSelection) select, + }) : _textContentKey = textContentKey, + _textLayout = textLayout, + _select = select; + + final GlobalKey _textContentKey; + final ProseTextLayout _textLayout; + final void Function(TextSelection) _select; + + TextSelection? _lastSelection; + + /// The last position pointed by the drag handle. + TextPosition? _lastFocalPosition; + + /// Whether the user is dragging upstream or downstream. + TextAffinity? _currentDragDirection; + + /// The drag handle used to start the gesture. + HandleType? _dragHandleType; + + /// The effective drag handle type based on the selection affinity. + /// + /// When the user the starts dragging a handle and causes the selection + /// to invert the affinity, for example, dragging the extent handle until the + /// extent position is upstream of the base position, the downstream handle + /// will behave as if it were the upstream handle, i.e., it will select by word + /// upstream and by character downstream. + HandleType? _effectiveDragHandleType; + + /// Whether the user is selecting by character or by word. + _SelectionModifier? _selectionModifier; + + /// Clients should call this method when a drag handle gesture is initially recognized. + void onHandlePanStart(DragStartDetails details, TextSelection initialSelection, HandleType handleType) { + if (handleType == HandleType.collapsed && !initialSelection.isCollapsed) { + throw Exception("Tried to drag a collapsed Android handle but the selection is expanded."); + } + if (handleType != HandleType.collapsed && initialSelection.isCollapsed) { + throw Exception("Tried to drag an expanded Android handle but the selection is collapsed."); + } + + final globalOffsetInMiddleOfLine = _getGlobalOffsetOfMiddleOfLine(initialSelection.base); + final touchHandleOffsetFromLineOfText = globalOffsetInMiddleOfLine - details.globalPosition; + + final textBox = (_textContentKey.currentContext!.findRenderObject() as RenderBox); + final textOffset = textBox.globalToLocal(details.globalPosition + touchHandleOffsetFromLineOfText); + + _dragHandleType = handleType; + + _lastFocalPosition = _textLayout.getPositionNearestToOffset(textOffset); + _lastSelection = initialSelection; + } + + /// Clients should call this method when a drag handle gesture is updated. + void onHandlePanUpdate(Offset handleFocalPoint) { + final nearestPosition = _textLayout.getPositionNearestToOffset(handleFocalPoint); + if (nearestPosition.offset < 0) { + return; + } + + if (_dragHandleType == HandleType.collapsed) { + // A collapsed handle always produces a collapsed selection. + _lastSelection = TextSelection.collapsed(offset: nearestPosition.offset); + _select(_lastSelection!); + return; + } + + final nearestPositionTextOffset = nearestPosition.offset; + final previousNearestPositioTextOffset = _lastFocalPosition!.offset; + + final didFocalPointMoveDownstream = nearestPositionTextOffset > previousNearestPositioTextOffset; + final didFocalPointMoveUpstream = nearestPositionTextOffset < previousNearestPositioTextOffset; + + _lastFocalPosition = nearestPosition; + + if (_currentDragDirection == null) { + // The user just started dragging the handle. + _currentDragDirection = didFocalPointMoveDownstream ? TextAffinity.downstream : TextAffinity.upstream; + + if (_dragHandleType == HandleType.upstream && didFocalPointMoveUpstream) { + _selectionModifier = _SelectionModifier.word; + } else if (_dragHandleType == HandleType.downstream && didFocalPointMoveDownstream) { + _selectionModifier = _SelectionModifier.word; + } else { + _selectionModifier = _SelectionModifier.character; + } + } else { + // Check if the user started dragging the handle in the opposite direction. + late TextAffinity newDragDirection; + if (_currentDragDirection == TextAffinity.upstream) { + newDragDirection = didFocalPointMoveDownstream // + ? TextAffinity.downstream + : TextAffinity.upstream; + } else { + newDragDirection = didFocalPointMoveUpstream // + ? TextAffinity.upstream + : TextAffinity.downstream; + } + + // Invert the drag handle type if the selection has upstream affinity. + final newEffectiveHandleType = _lastSelection!.baseOffset < _lastSelection!.extentOffset + ? _dragHandleType! + : (_dragHandleType == HandleType.upstream ? HandleType.downstream : HandleType.upstream); + + if (newDragDirection != _currentDragDirection || newEffectiveHandleType != _effectiveDragHandleType) { + _currentDragDirection = newDragDirection; + _effectiveDragHandleType = newEffectiveHandleType; + + if (_effectiveDragHandleType == HandleType.downstream && newDragDirection == TextAffinity.downstream) { + _selectionModifier = _SelectionModifier.word; + } else if (_effectiveDragHandleType == HandleType.upstream && newDragDirection == TextAffinity.upstream) { + _selectionModifier = _SelectionModifier.word; + } else { + _selectionModifier = _SelectionModifier.character; + } + } + } + + final rangeToExpandSelection = _selectionModifier == _SelectionModifier.word + ? _dragHandleType == _effectiveDragHandleType + ? _textLayout.getWordSelectionAt(nearestPosition) + : _flipSelection(_textLayout.getWordSelectionAt(nearestPosition)) + : TextSelection.collapsed(offset: nearestPosition.offset); + + if (rangeToExpandSelection.isValid) { + _lastSelection = _lastSelection!.copyWith( + baseOffset: _dragHandleType == HandleType.upstream // + ? rangeToExpandSelection.baseOffset + : _lastSelection!.baseOffset, + extentOffset: _dragHandleType == HandleType.downstream + ? rangeToExpandSelection.extentOffset + : _lastSelection!.extentOffset, + ); + _select(_lastSelection!); + } + + if (rangeToExpandSelection.isValid) { + _lastSelection = _lastSelection!.copyWith( + baseOffset: + _dragHandleType == HandleType.upstream ? rangeToExpandSelection.baseOffset : _lastSelection!.baseOffset, + extentOffset: _dragHandleType == HandleType.downstream + ? rangeToExpandSelection.extentOffset + : _lastSelection!.extentOffset, + ); + _select(_lastSelection!); + } + } + + Offset _getGlobalOffsetOfMiddleOfLine(TextPosition position) { + // TODO: can we de-dup this ? + final textLayout = _textLayout; + final extentOffsetInText = textLayout.getOffsetAtPosition(position); + final extentLineHeight = textLayout.getCharacterBox(position)?.toRect().height ?? textLayout.estimatedLineHeight; + final extentGlobalOffset = + (_textContentKey.currentContext!.findRenderObject() as RenderBox).localToGlobal(extentOffsetInText); + + return extentGlobalOffset + Offset(0, extentLineHeight / 2); + } + + /// Invert the selection so that the base and extent are swapped. + TextSelection _flipSelection(TextSelection selection) { + return selection.copyWith( + baseOffset: selection.extentOffset, + extentOffset: selection.baseOffset, + ); + } +} + +enum _SelectionModifier { + character, + word, +} diff --git a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart new file mode 100644 index 0000000000..ec61c9382a --- /dev/null +++ b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart @@ -0,0 +1,3429 @@ +import 'dart:math'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' hide SelectableText; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/actions.dart'; +import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/material_scrollbar.dart'; +import 'package:super_editor/src/infrastructure/flutter/text_input_configuration.dart'; +import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; +import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/text_input.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_scroller.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +import '../infrastructure/fill_width_if_constrained.dart'; + +final _log = textFieldLog; + +/// Highly configurable text field intended for web and desktop uses. +/// +/// [SuperDesktopTextField] provides two advantages over a typical [TextField]. +/// First, [SuperDesktopTextField] is based on [AttributedText], which is a far +/// more useful foundation for styled text display than [TextSpan]. Second, +/// [SuperDesktopTextField] provides deeper control over various visual properties +/// including selection painting, caret painting, hint display, and keyboard +/// interaction. +/// +/// If [SuperDesktopTextField] does not provide the desired level of configuration, +/// look at its implementation. Unlike Flutter's [TextField], [SuperDesktopTextField] +/// is composed of a few widgets that you can recompose to create your own +/// flavor of a text field. +class SuperDesktopTextField extends StatefulWidget { + const SuperDesktopTextField({ + Key? key, + this.focusNode, + this.tapRegionGroupId, + this.textController, + this.textStyleBuilder = defaultTextFieldStyleBuilder, + this.inlineWidgetBuilders = const [], + this.textAlign = TextAlign.left, + this.hintBehavior = HintBehavior.displayHintUntilFocus, + this.hintBuilder, + this.selectionHighlightStyle = const SelectionHighlightStyle( + color: Color(0xFFACCEF7), + ), + this.caretStyle = const CaretStyle( + color: Colors.black, + width: 1, + borderRadius: BorderRadius.zero, + ), + this.blinkTimingMode = BlinkTimingMode.ticker, + this.padding = EdgeInsets.zero, + this.minLines, + this.maxLines = 1, + this.decorationBuilder, + this.onRightClick, + this.inputSource = TextInputSource.keyboard, + this.textInputAction, + this.imeConfiguration, + this.showComposingUnderline, + this.selectorHandlers, + this.tapHandlers = const [], + List? keyboardHandlers, + }) : keyboardHandlers = keyboardHandlers ?? + (inputSource == TextInputSource.keyboard + ? defaultTextFieldKeyboardHandlers + : defaultTextFieldImeKeyboardHandlers), + super(key: key); + + final FocusNode? focusNode; + + /// {@macro super_text_field_tap_region_group_id} + final String? tapRegionGroupId; + + final AttributedTextEditingController? textController; + + /// Text style factory that creates styles for the content in + /// [textController] based on the attributions in that content. + final AttributionStyleBuilder textStyleBuilder; + + /// {@macro super_text_field_inline_widget_builders} + final InlineWidgetBuilderChain inlineWidgetBuilders; + + /// Policy for when the hint should be displayed. + final HintBehavior hintBehavior; + + /// Builder that creates the hint widget, when a hint is displayed. + /// + /// To easily build a hint with styled text, see [StyledHintBuilder]. + final WidgetBuilder? hintBuilder; + + /// The alignment to use for text in this text field. + /// + /// If `null`, the text alignment is determined by the text direction + /// of the content. + final TextAlign? textAlign; + + /// The visual representation of the user's selection highlight. + final SelectionHighlightStyle selectionHighlightStyle; + + /// The visual representation of the caret in this `SelectableText` widget. + final CaretStyle caretStyle; + + /// The timing mechanism used to blink, e.g., `Ticker` or `Timer`. + /// + /// `Timer`s are not expected to work in tests. + final BlinkTimingMode blinkTimingMode; + + final EdgeInsetsGeometry padding; + + final int? minLines; + final int? maxLines; + + final DecorationBuilder? decorationBuilder; + + @Deprecated('Use tapHandlers instead') + final RightClickListener? onRightClick; + + /// The [SuperDesktopTextField] input source, e.g., keyboard or Input Method Engine. + final TextInputSource inputSource; + + /// Priority list of handlers that process all physical keyboard + /// key presses, for text input, deletion, caret movement, etc. + /// + /// If the [inputSource] is [TextInputSource.ime], text input is already handled + /// using [TextEditingDelta]s, so this list shouldn't include handlers + /// that input text based on individual character key presses. + final List keyboardHandlers; + + /// Handlers for all Mac OS "selectors" reported by the IME. + /// + /// The IME reports selectors as unique `String`s, therefore selector handlers are + /// defined as a mapping from selector names to handler functions. + final Map? selectorHandlers; + + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + + /// The type of action associated with ENTER key. + /// + /// This property is ignored when an [imeConfiguration] is provided. + @Deprecated('This will be removed in a future release. Use imeConfiguration instead') + final TextInputAction? textInputAction; + + /// Preferences for how the platform IME should look and behave during editing. + final TextInputConfiguration? imeConfiguration; + + /// Whether to show an underline beneath the text in the composing region, or `null` + /// to let [SuperDesktopTextField] decide when to show the underline. + final bool? showComposingUnderline; + + @override + SuperDesktopTextFieldState createState() => SuperDesktopTextFieldState(); +} + +class SuperDesktopTextFieldState extends State implements ProseTextBlock, ImeInputOwner { + final _textKey = GlobalKey(); + final _textScrollKey = GlobalKey(); + late FocusNode _focusNode; + bool _hasFocus = false; // cache whether we have focus so we know when it changes + + late SuperTextFieldContext _textFieldContext; + late ImeAttributedTextEditingController _controller; + + /// The text direction of the first character in the text. + /// + /// Used to align and position the caret depending on whether + /// the text is RTL or LTR. + TextDirection? _contentTextDirection; + + /// The text direction applied to the inner text. + TextDirection get _textDirection => _contentTextDirection ?? TextDirection.ltr; + + TextAlign get _textAlign => + widget.textAlign ?? + ((_textDirection == TextDirection.ltr) // + ? TextAlign.left + : TextAlign.right); + + late ScrollController _scrollController; + late TextFieldScroller _textFieldScroller; + + double? _viewportHeight; + + final _estimatedLineHeight = _EstimatedLineHeight(); + + @override + void initState() { + super.initState(); + + _focusNode = (widget.focusNode ?? FocusNode())..addListener(_updateSelectionAndComposingRegionOnFocusChange); + + _controller = widget.textController != null + ? widget.textController is ImeAttributedTextEditingController + ? (widget.textController as ImeAttributedTextEditingController) + : ImeAttributedTextEditingController(controller: widget.textController, disposeClientController: false) + : ImeAttributedTextEditingController(); + _controller.addListener(_onSelectionOrContentChange); + + _scrollController = ScrollController(); + _textFieldScroller = TextFieldScroller() // + ..attach(_scrollController); + + _createTextFieldContext(); + + // Check if we need to update the selection. + _updateSelectionAndComposingRegionOnFocusChange(); + + _contentTextDirection = getParagraphDirection(_controller.text.toPlainText()); + } + + @override + void didUpdateWidget(SuperDesktopTextField oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.focusNode != oldWidget.focusNode) { + _focusNode.removeListener(_updateSelectionAndComposingRegionOnFocusChange); + if (oldWidget.focusNode == null) { + _focusNode.dispose(); + } + _focusNode = (widget.focusNode ?? FocusNode())..addListener(_updateSelectionAndComposingRegionOnFocusChange); + + // Check if we need to update the selection. + _updateSelectionAndComposingRegionOnFocusChange(); + } + + if (widget.textController != oldWidget.textController) { + _controller.removeListener(_onSelectionOrContentChange); + // When the given textController isn't an ImeAttributedTextEditingController, + // we wrap it with one. So we need to dispose it. + if (oldWidget.textController == null || oldWidget.textController is! ImeAttributedTextEditingController) { + _controller.dispose(); + } + _controller = widget.textController != null + ? widget.textController is ImeAttributedTextEditingController + ? (widget.textController as ImeAttributedTextEditingController) + : ImeAttributedTextEditingController(controller: widget.textController, disposeClientController: false) + : ImeAttributedTextEditingController(); + + _controller.addListener(_onSelectionOrContentChange); + + _createTextFieldContext(); + } + + if (widget.padding != oldWidget.padding || + widget.minLines != oldWidget.minLines || + widget.maxLines != oldWidget.maxLines) { + _onSelectionOrContentChange(); + } + } + + @override + void dispose() { + _textFieldScroller.detach(); + _scrollController.dispose(); + _focusNode.removeListener(_updateSelectionAndComposingRegionOnFocusChange); + if (widget.focusNode == null) { + _focusNode.dispose(); + } + _controller + ..removeListener(_onSelectionOrContentChange) + ..detachFromIme(); + if (widget.textController == null) { + _controller.dispose(); + } + + super.dispose(); + } + + void _createTextFieldContext() { + _textFieldContext = SuperTextFieldContext( + textFieldBuildContext: context, + focusNode: _focusNode, + controller: _controller, + getTextLayout: () => textLayout, + scroller: _textFieldScroller, + ); + } + + @visibleForTesting + ScrollController get scrollController => _scrollController; + + @override + ProseTextLayout get textLayout => _textKey.currentState!.textLayout; + + /// Calculates and returns the `Offset` from the top-left corner of this text field + /// to the top-left corner of the [textLayout] within this text field. + Offset get textLayoutOffsetInField { + final fieldBox = context.findRenderObject() as RenderBox; + final textLayoutBox = _textKey.currentContext!.findRenderObject() as RenderBox; + return textLayoutBox.localToGlobal(Offset.zero, ancestor: fieldBox); + } + + @override + @visibleForTesting + DeltaTextInputClient get imeClient => _controller; + + FocusNode get focusNode => _focusNode; + + TextScaler get _textScaler => MediaQuery.textScalerOf(context); + + void requestFocus() { + _focusNode.requestFocus(); + } + + void _updateSelectionAndComposingRegionOnFocusChange() { + // If our FocusNode just received focus, automatically set our + // controller's text position to the end of the available content. + // + // This behavior matches Flutter's standard behavior. + if (_focusNode.hasFocus && !_hasFocus && _controller.selection.extentOffset == -1) { + _controller.selection = TextSelection.collapsed(offset: _controller.text.length); + } + if (!_focusNode.hasFocus) { + // We lost focus. Clear the composing region. + _controller.composingRegion = TextRange.empty; + } + _hasFocus = _focusNode.hasFocus; + } + + AttributedTextEditingController get controller => _controller; + + void _onSelectionOrContentChange() { + // Use a post-frame callback to "ensure selection extent is visible" + // so that any pending visual content changes can happen before + // attempting to calculate the visual position of the selection extent. + onNextFrame((_) => _updateViewportHeight()); + + // Even though we calling `onNextFrame`, it doesn't necessarily mean + // a new frame will be scheduled. Call setState to ensure the text direction is updated. + setState(() { + _contentTextDirection = getParagraphDirection(_controller.text.toPlainText()); + }); + } + + /// Returns true if the viewport height changed, false otherwise. + bool _updateViewportHeight() { + final estimatedLineHeight = _getEstimatedLineHeight(); + final estimatedLinesOfText = _getEstimatedLinesOfText(); + final estimatedContentHeight = (estimatedLinesOfText * estimatedLineHeight) + widget.padding.vertical; + final minHeight = widget.minLines != null ? widget.minLines! * estimatedLineHeight + widget.padding.vertical : null; + final maxHeight = widget.maxLines != null ? widget.maxLines! * estimatedLineHeight + widget.padding.vertical : null; + double? viewportHeight; + if (maxHeight != null && estimatedContentHeight > maxHeight) { + viewportHeight = maxHeight; + } else if (minHeight != null && estimatedContentHeight < minHeight) { + viewportHeight = minHeight; + } + + if (viewportHeight == _viewportHeight) { + // The height of the viewport hasn't changed. Return. + return false; + } + + setState(() { + _viewportHeight = viewportHeight; + }); + + return true; + } + + int _getEstimatedLinesOfText() { + if (_controller.text.isEmpty) { + return 0; + } + + if (_textKey.currentState == null) { + return 0; + } + + final offsetAtEndOfText = textLayout.getOffsetAtPosition(TextPosition(offset: _controller.text.length)); + int lineCount = (offsetAtEndOfText.dy / _getEstimatedLineHeight()).ceil(); + + if (_controller.text.toPlainText().endsWith('\n')) { + lineCount += 1; + } + + return lineCount; + } + + double _getEstimatedLineHeight() { + // After hot reloading, the text layout might be null, so we can't + // directly use _textKey.currentState!.textLayout because using it + // we can't check for null. + final textLayout = RenderSuperTextLayout.textLayoutFrom(_textKey); + + // We don't expect getHeightForCaret to ever return null, but since its return type is nullable, + // we use getLineHeightAtPosition as a backup. + // More information in https://github.com/flutter/flutter/issues/145507. + final lineHeight = _controller.text.isEmpty || textLayout == null + ? 0.0 + : textLayout.getHeightForCaret(const TextPosition(offset: 0)) ?? + textLayout.getLineHeightAtPosition(const TextPosition(offset: 0)); + if (lineHeight > 0) { + return lineHeight; + } + final defaultStyle = widget.textStyleBuilder({}); + return _estimatedLineHeight.calculate(defaultStyle, _textScaler); + } + + bool get _shouldShowComposingUnderline => + widget.showComposingUnderline ?? defaultTargetPlatform == TargetPlatform.macOS; + + @override + Widget build(BuildContext context) { + if (_textKey.currentContext == null) { + // The text hasn't been laid out yet, which means our calculations + // for text height is probably wrong. Schedule a post frame callback + // to re-calculate the height after initial layout. + scheduleBuildAfterBuild(() { + _updateViewportHeight(); + }); + } + + final isMultiline = widget.minLines != 1 || widget.maxLines != 1; + + return TapRegion( + groupId: widget.tapRegionGroupId, + child: _buildTextInputSystem( + isMultiline: isMultiline, + // As we handle the scrolling gestures ourselves, + // we use NeverScrollableScrollPhysics to prevent SingleChildScrollView + // from scrolling. This also prevents the user from interacting + // with the scrollbar. + // We use a modified version of Flutter's Scrollbar that allows + // configuring it with a different scroll physics. + // + // See https://github.com/superlistapp/super_editor/issues/1628 for more details. + child: ScrollbarWithCustomPhysics( + controller: _scrollController, + physics: ScrollConfiguration.of(context).getScrollPhysics(context), + child: SuperTextFieldGestureInteractor( + focusNode: _focusNode, + textController: _controller, + textKey: _textKey, + textScrollKey: _textScrollKey, + isMultiline: isMultiline, + onRightClick: widget.onRightClick, + tapHandlers: widget.tapHandlers, + child: MultiListenableBuilder( + listenables: { + _focusNode, + _controller, + }, + builder: (context) { + return _buildDecoration( + child: SuperTextFieldScrollview( + key: _textScrollKey, + textKey: _textKey, + textController: _controller, + textAlign: _textAlign, + scrollController: _scrollController, + viewportHeight: _viewportHeight, + estimatedLineHeight: _getEstimatedLineHeight(), + isMultiline: isMultiline, + child: FillWidthIfConstrained( + child: Padding( + // WARNING: Padding within the text scroll view must be placed here, under + // FillWidthIfConstrained, rather than around it, because FillWidthIfConstrained makes + // decisions about sizing that expects its child to fill all available space in the + // ancestor Scrollable. + padding: widget.padding, + child: _buildSelectableText(), + ), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } + + Widget _buildDecoration({ + required Widget child, + }) { + return widget.decorationBuilder != null ? widget.decorationBuilder!(context, child) : child; + } + + Widget _buildTextInputSystem({ + required bool isMultiline, + required Widget child, + }) { + return IntentBlocker( + intents: CurrentPlatform.isApple ? appleBlockedIntents : nonAppleBlockedIntents, + child: SuperTextFieldKeyboardInteractor( + focusNode: _focusNode, + textFieldContext: _textFieldContext, + textKey: _textKey, + keyboardActions: widget.keyboardHandlers, + child: widget.inputSource == TextInputSource.ime + ? SuperTextFieldImeInteractor( + textKey: _textKey, + focusNode: _focusNode, + textFieldContext: _textFieldContext, + isMultiline: isMultiline, + selectorHandlers: widget.selectorHandlers ?? defaultTextFieldSelectorHandlers, + textInputAction: widget.textInputAction, + imeConfiguration: widget.imeConfiguration, + textStyleBuilder: widget.textStyleBuilder, + textAlign: widget.textAlign, + textDirection: Directionality.of(context), + child: child, + ) + : child, + ), + ); + } + + Widget _buildSelectableText() { + return Directionality( + textDirection: _textDirection, + child: SuperText( + key: _textKey, + richText: _controller.text.computeInlineSpan(context, widget.textStyleBuilder, widget.inlineWidgetBuilders), + textAlign: _textAlign, + textDirection: _textDirection, + textScaler: _textScaler, + layerBeneathBuilder: (context, textLayout) { + final isTextEmpty = _controller.text.isEmpty; + final showHint = widget.hintBuilder != null && + ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || + (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); + + return Stack( + children: [ + if (widget.textController?.selection.isValid == true) + // Selection highlight beneath the text. + TextLayoutSelectionHighlight( + textLayout: textLayout, + style: widget.selectionHighlightStyle, + selection: widget.textController?.selection, + ), + // Underline beneath the composing region. + if (widget.textController?.composingRegion.isValid == true && _shouldShowComposingUnderline) + TextUnderlineLayer( + textLayout: textLayout, + style: StraightUnderlineStyle( + color: widget.textStyleBuilder({}).color ?? // + (Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white), + ), + underlines: [ + TextLayoutUnderline( + range: widget.textController!.composingRegion, + ), + ], + ), + if (showHint) // + Align( + alignment: Alignment.centerLeft, + child: widget.hintBuilder!(context), + ), + ], + ); + }, + layerAboveBuilder: (context, textLayout) { + if (!_focusNode.hasFocus) { + return const SizedBox(); + } + + return TextLayoutCaret( + textLayout: textLayout, + style: widget.caretStyle, + position: _controller.selection.extent, + blinkTimingMode: widget.blinkTimingMode, + ); + }, + ), + ); + } +} + +typedef DecorationBuilder = Widget Function(BuildContext, Widget child); + +/// Handles all user gesture interactions for text entry. +/// +/// [SuperTextFieldGestureInteractor] is intended to operate as a piece within +/// a larger composition that behaves as a text field. [SuperTextFieldGestureInteractor] +/// is defined on its own so that it can be replaced with a widget that handles +/// gestures differently. +/// +/// The gestures are applied to a [SuperSelectableText] widget that is +/// tied to [textKey]. +/// +/// A [SuperTextFieldScrollview] must sit between this [SuperTextFieldGestureInteractor] +/// and the underlying [SuperSelectableText]. That [SuperTextFieldScrollview] must +/// be tied to [textScrollKey]. +class SuperTextFieldGestureInteractor extends StatefulWidget { + const SuperTextFieldGestureInteractor({ + Key? key, + required this.focusNode, + required this.textController, + required this.textKey, + required this.textScrollKey, + required this.isMultiline, + this.onRightClick, + this.tapHandlers = const [], + required this.child, + }) : super(key: key); + + /// [FocusNode] for this text field. + final FocusNode focusNode; + + /// [TextController] for the text/selection within this text field. + final AttributedTextEditingController textController; + + /// [GlobalKey] that links this [SuperTextFieldGestureInteractor] to + /// the [ProseTextLayout] widget that paints the text for this text field. + final GlobalKey textKey; + + /// [GlobalKey] that links this [SuperTextFieldGestureInteractor] to + /// the [SuperTextFieldScrollview] that's responsible for scrolling + /// content that exceeds the available space within this text field. + final GlobalKey textScrollKey; + + /// Whether or not this text field supports multiple lines of text. + final bool isMultiline; + + /// Callback invoked when the user right clicks on this text field. + @Deprecated('Use tapHandlers instead') + final RightClickListener? onRightClick; + + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + + /// The rest of the subtree for this text field. + final Widget child; + + @override + State createState() => _SuperTextFieldGestureInteractorState(); +} + +class _SuperTextFieldGestureInteractorState extends State { + _SelectionType _selectionType = _SelectionType.position; + Offset? _dragStartInViewport; + Offset? _dragStartInText; + Offset? _dragEndInViewport; + Offset? _dragEndInText; + Rect? _dragRectInViewport; + + final _dragGutterExtent = 24; + final _maxDragSpeed = 20; + + /// Holds which kind of device started a pan gesture, e.g., a mouse or a trackpad. + PointerDeviceKind? _panGestureDevice; + + ProseTextLayout get _textLayout => widget.textKey.currentState!.textLayout; + + SuperTextFieldScrollviewState get _textScroll => widget.textScrollKey.currentState!; + + final _mouseCursor = ValueNotifier(SystemMouseCursors.text); + + void _onMouseMove(PointerHoverEvent event) { + _updateMouseCursor(event.position); + } + + void _updateMouseCursor(Offset globalPosition) { + final localPosition = (context.findRenderObject() as RenderBox).globalToLocal(globalPosition); + final textOffset = _getTextOffset(localPosition); + + for (final handler in widget.tapHandlers) { + final cursorForContent = handler.mouseCursorForContentHover( + SuperTextFieldGestureDetails( + textController: widget.textController, + textLayout: _textLayout, + globalOffset: globalPosition, + layoutOffset: localPosition, + textOffset: textOffset, + ), + ); + if (cursorForContent != null) { + _mouseCursor.value = cursorForContent; + return; + } + } + + _mouseCursor.value = SystemMouseCursors.text; + } + + void _onTapDown(TapDownDetails details) { + _log.fine('Tap down on SuperTextField'); + + final textOffset = _getTextOffset(details.localPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onTapDown( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + + _selectionType = _SelectionType.position; + + final tapTextPosition = _getPositionNearestToTextOffset(textOffset); + _log.finer("Tap text position: $tapTextPosition"); + + final expandSelection = HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shift); + + setState(() { + widget.textController.selection = expandSelection + ? TextSelection( + baseOffset: widget.textController.selection.baseOffset, + extentOffset: tapTextPosition.offset, + ) + : TextSelection.collapsed(offset: tapTextPosition.offset); + widget.textController.composingRegion = TextRange.empty; + + _log.finer("New text field selection: ${widget.textController.selection}"); + }); + + widget.focusNode.requestFocus(); + } + + void _onTapUp(TapUpDetails details) { + final textOffset = _getTextOffset(details.localPosition); + for (final handler in widget.tapHandlers) { + final result = handler.onTapUp( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onTapCancel() { + for (final handler in widget.tapHandlers) { + final result = handler.onTapCancel(); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onDoubleTapDown(TapDownDetails details) { + _log.finer('_onDoubleTapDown - EditableDocument: onDoubleTap()'); + + final textOffset = _getTextOffset(details.localPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onDoubleTapDown( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + + _selectionType = _SelectionType.word; + + final tapTextPosition = _getPositionAtOffset(details.localPosition); + + if (tapTextPosition != null) { + setState(() { + widget.textController.selection = _textLayout.getWordSelectionAt(tapTextPosition); + }); + } else { + _clearSelection(); + } + + widget.focusNode.requestFocus(); + } + + void _onDoubleTapUp(TapUpDetails details) { + final textOffset = _getTextOffset(details.localPosition); + for (final handler in widget.tapHandlers) { + final result = handler.onDoubleTapUp( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onDoubleTap() { + _selectionType = _SelectionType.position; + } + + void _onDoubleTapCancel() { + for (final handler in widget.tapHandlers) { + final result = handler.onDoubleTapCancel(); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onTripleTapDown(TapDownDetails details) { + _log.finer('_onTripleTapDown - EditableDocument: onTripleTapDown()'); + + final textOffset = _getTextOffset(details.localPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onTripleTapDown( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + + _selectionType = _SelectionType.paragraph; + + final tapTextPosition = _getPositionAtOffset(details.localPosition); + + if (tapTextPosition != null) { + setState(() { + widget.textController.selection = _getParagraphSelectionAt(tapTextPosition, TextAffinity.downstream); + }); + } else { + _clearSelection(); + } + + widget.focusNode.requestFocus(); + } + + void _onTripleTapUp(TapUpDetails details) { + final textOffset = _getTextOffset(details.localPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onTripleTapUp( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onTripleTap() { + _selectionType = _SelectionType.position; + } + + void _onTripleTapCancel() { + for (final handler in widget.tapHandlers) { + final result = handler.onTripleTapCancel(); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onRightClickDown(TapDownDetails details) { + final textOffset = _getTextOffset(details.localPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onSecondaryTapDown( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onRightClickUp(TapUpDetails details) { + final textOffset = _getTextOffset(details.localPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onSecondaryTapUp( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + widget.onRightClick?.call(context, widget.textController, details.localPosition); + } + + void _onRightClickCancel() { + for (final handler in widget.tapHandlers) { + final result = handler.onSecondaryTapCancel(); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onPanStart(DragStartDetails details) { + _panGestureDevice = details.kind; + + if (_panGestureDevice == PointerDeviceKind.trackpad) { + // After flutter 3.3, dragging with two fingers on a trackpad triggers a pan gesture. + // This gesture should scroll the content and keep the selection unchanged. + return; + } + + _log.fine("User started pan"); + _dragStartInViewport = details.localPosition; + _dragStartInText = _getTextOffset(_dragStartInViewport!); + + _dragRectInViewport = Rect.fromLTWH(_dragStartInViewport!.dx, _dragStartInViewport!.dy, 1, 1); + + widget.focusNode.requestFocus(); + } + + void _onPanUpdate(DragUpdateDetails details) { + _log.finer("User moved during pan"); + + if (_panGestureDevice == PointerDeviceKind.trackpad) { + // The user dragged using two fingers on a trackpad. + // Scroll the content and keep the selection unchanged. + // We multiply by -1 because the scroll should be in the opposite + // direction of the drag, e.g., dragging up on a trackpad scrolls + // the content to downstream direction. + _scrollVertically(details.delta.dy * -1); + return; + } + + setState(() { + _dragEndInViewport = details.localPosition; + _dragEndInText = _getTextOffset(_dragEndInViewport!); + _dragRectInViewport = Rect.fromPoints(_dragStartInViewport!, _dragEndInViewport!); + _log.finer('_onPanUpdate - drag rect: $_dragRectInViewport'); + _updateDragSelection(); + + _scrollIfNearBoundary(); + }); + } + + void _onPanEnd(DragEndDetails details) { + _log.finer("User ended a pan"); + + if (_panGestureDevice == PointerDeviceKind.trackpad) { + // The user ended a pan gesture with two fingers on a trackpad. + // We already scrolled the document. + _textScroll.goBallistic(-details.velocity.pixelsPerSecond.dy); + return; + } + + setState(() { + _dragStartInText = null; + _dragEndInText = null; + _dragRectInViewport = null; + }); + + _textScroll.stopScrollingToStart(); + _textScroll.stopScrollingToEnd(); + } + + void _onPanCancel() { + _log.finer("User cancelled a pan"); + setState(() { + _dragStartInText = null; + _dragEndInText = null; + _dragRectInViewport = null; + }); + + _textScroll.stopScrollingToStart(); + _textScroll.stopScrollingToEnd(); + } + + void _updateDragSelection() { + if (_dragStartInText == null || _dragEndInText == null) { + return; + } + + setState(() { + final startDragOffset = _getPositionNearestToTextOffset(_dragStartInText!).offset; + final endDragOffset = _getPositionNearestToTextOffset(_dragEndInText!).offset; + final affinity = startDragOffset <= endDragOffset ? TextAffinity.downstream : TextAffinity.upstream; + + if (_selectionType == _SelectionType.paragraph) { + final baseParagraphSelection = _getParagraphSelectionAt(TextPosition(offset: startDragOffset), affinity); + final extentParagraphSelection = _getParagraphSelectionAt(TextPosition(offset: endDragOffset), affinity); + + widget.textController.selection = _combineSelections( + baseParagraphSelection, + extentParagraphSelection, + affinity, + ); + } else if (_selectionType == _SelectionType.word) { + final baseParagraphSelection = _textLayout.getWordSelectionAt(TextPosition(offset: startDragOffset)); + final extentParagraphSelection = _textLayout.getWordSelectionAt(TextPosition(offset: endDragOffset)); + + widget.textController.selection = _combineSelections( + baseParagraphSelection, + extentParagraphSelection, + affinity, + ); + } else { + widget.textController.selection = TextSelection( + baseOffset: startDragOffset, + extentOffset: endDragOffset, + ); + } + }); + } + + TextSelection _combineSelections( + TextSelection selection1, + TextSelection selection2, + TextAffinity affinity, + ) { + return affinity == TextAffinity.downstream + ? TextSelection( + baseOffset: min(selection1.start, selection2.start), + extentOffset: max(selection1.end, selection2.end), + ) + : TextSelection( + baseOffset: max(selection1.end, selection2.end), + extentOffset: min(selection1.start, selection2.start), + ); + } + + void _clearSelection() { + setState(() { + widget.textController.selection = const TextSelection.collapsed(offset: -1); + }); + } + + /// We prevent SingleChildScrollView from processing mouse events because + /// it scrolls by drag by default, which we don't want. However, we do + /// still want mouse scrolling. This method re-implements a primitive + /// form of mouse scrolling. + void _onPointerSignal(PointerSignalEvent event) { + if (event is PointerScrollEvent) { + _scrollVertically(event.scrollDelta.dy); + } + } + + void _scrollIfNearBoundary() { + if (_dragEndInViewport == null) { + _log.finer("_scrollIfNearBoundary - Can't scroll near boundary because _dragEndInViewport is null"); + assert(_dragEndInViewport != null); + return; + } + + if (!widget.isMultiline) { + _scrollIfNearHorizontalBoundary(); + } else { + _scrollIfNearVerticalBoundary(); + } + } + + void _scrollIfNearHorizontalBoundary() { + final editorBox = context.findRenderObject() as RenderBox; + + if (_dragEndInViewport!.dx < _dragGutterExtent) { + _startScrollingToStart(); + } else { + _stopScrollingToStart(); + } + if (editorBox.size.width - _dragEndInViewport!.dx < _dragGutterExtent) { + _startScrollingToEnd(); + } else { + _stopScrollingToEnd(); + } + } + + void _scrollIfNearVerticalBoundary() { + final editorBox = context.findRenderObject() as RenderBox; + + if (_dragEndInViewport!.dy < _dragGutterExtent) { + _startScrollingToStart(); + return; + } else { + _stopScrollingToStart(); + } + + if (editorBox.size.height - _dragEndInViewport!.dy < _dragGutterExtent) { + _startScrollingToEnd(); + return; + } else { + _stopScrollingToEnd(); + } + } + + void _startScrollingToStart() { + if (_dragEndInViewport == null) { + _log.finer("_scrollUp - Can't scroll up because _dragEndInViewport is null"); + assert(_dragEndInViewport != null); + return; + } + + final gutterAmount = _dragEndInViewport!.dy.clamp(0.0, _dragGutterExtent); + final speedPercent = 1.0 - (gutterAmount / _dragGutterExtent); + final scrollAmount = ui.lerpDouble(0, _maxDragSpeed, speedPercent)!; + + _textScroll.startScrollingToStart(amountPerFrame: scrollAmount); + } + + void _stopScrollingToStart() { + _textScroll.stopScrollingToStart(); + } + + void _startScrollingToEnd() { + if (_dragEndInViewport == null) { + _log.finer("_scrollDown - Can't scroll down because _dragEndInViewport is null"); + assert(_dragEndInViewport != null); + return; + } + + final editorBox = context.findRenderObject() as RenderBox; + final gutterAmount = (editorBox.size.height - _dragEndInViewport!.dy).clamp(0.0, _dragGutterExtent); + final speedPercent = 1.0 - (gutterAmount / _dragGutterExtent); + final scrollAmount = ui.lerpDouble(0, _maxDragSpeed, speedPercent)!; + + _textScroll.startScrollingToEnd(amountPerFrame: scrollAmount); + } + + void _stopScrollingToEnd() { + _textScroll.stopScrollingToEnd(); + } + + /// Scrolls the document vertically by [delta] pixels. + void _scrollVertically(double delta) { + // TODO: remove access to _textScroll.widget + final newScrollOffset = (_textScroll.widget.scrollController.offset + delta) + .clamp(0.0, _textScroll.widget.scrollController.position.maxScrollExtent); + _textScroll.widget.scrollController.jumpTo(newScrollOffset); + _updateDragSelection(); + } + + /// Beginning with Flutter 3.3.3, we are responsible for starting and + /// stopping scroll momentum. This method cancels any scroll momentum + /// in our scroll controller. + void _cancelScrollMomentum() { + _textScroll.goIdle(); + } + + TextPosition? _getPositionAtOffset(Offset textFieldOffset) { + final textOffset = _getTextOffset(textFieldOffset); + final textBox = widget.textKey.currentContext!.findRenderObject() as RenderBox; + + return textBox.size.contains(textOffset) ? _textLayout.getPositionAtOffset(textOffset) : null; + } + + TextSelection _getParagraphSelectionAt(TextPosition textPosition, TextAffinity affinity) { + return _textLayout.expandSelection(textPosition, paragraphExpansionFilter, affinity); + } + + TextPosition _getPositionNearestToTextOffset(Offset textOffset) { + return _textLayout.getPositionNearestToOffset(textOffset); + } + + Offset _getTextOffset(Offset textFieldOffset) { + final textFieldBox = context.findRenderObject() as RenderBox; + final textBox = widget.textKey.currentContext!.findRenderObject() as RenderBox; + return textBox.globalToLocal(textFieldOffset, ancestor: textFieldBox); + } + + @override + Widget build(BuildContext context) { + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + return Listener( + onPointerSignal: _onPointerSignal, + onPointerHover: (event) => _cancelScrollMomentum(), + child: GestureDetector( + onSecondaryTapDown: _onRightClickDown, + onSecondaryTapUp: _onRightClickUp, + onSecondaryTapCancel: _onRightClickCancel, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapUp = _onTapUp + ..onTapCancel = _onTapCancel + ..onDoubleTapDown = _onDoubleTapDown + ..onDoubleTapUp = _onDoubleTapUp + ..onDoubleTap = _onDoubleTap + ..onDoubleTapCancel = _onDoubleTapCancel + ..onTripleTapDown = _onTripleTapDown + ..onTripleTapUp = _onTripleTapUp + ..onTripleTapCancel = _onTripleTapCancel + ..onTripleTap = _onTripleTap + ..gestureSettings = gestureSettings; + }, + ), + PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (PanGestureRecognizer recognizer) { + recognizer + ..onStart = _onPanStart + ..onUpdate = _onPanUpdate + ..onEnd = _onPanEnd + ..onCancel = _onPanCancel + ..gestureSettings = gestureSettings; + }, + ), + }, + child: Listener( + onPointerHover: _onMouseMove, + child: ValueListenableBuilder( + valueListenable: _mouseCursor, + builder: (context, mouseCursor, child) { + return MouseRegion( + cursor: mouseCursor, + child: child, + ); + }, + child: widget.child, + ), + ), + ), + ), + ); + } +} + +/// Handles all keyboard interactions for text entry in a text field. +/// +/// [SuperTextFieldKeyboardInteractor] is intended to operate as a piece within +/// a larger composition that behaves as a text field. [SuperTextFieldKeyboardInteractor] +/// is defined on its own so that it can be replaced with a widget that handles +/// key events differently. +/// +/// The key events are passed down the [keyboardActions] Chain of Responsibility. +/// Each handler is given a reference to the [textController], to manipulate the +/// text content, and a [TextLayout] via the [textKey], which can be used to make +/// decisions about manipulations, such as moving the caret to the beginning/end +/// of a line. +class SuperTextFieldKeyboardInteractor extends StatefulWidget { + const SuperTextFieldKeyboardInteractor({ + Key? key, + required this.focusNode, + required this.textFieldContext, + required this.textKey, + required this.keyboardActions, + required this.child, + }) : super(key: key); + + /// [FocusNode] for this text field. + final FocusNode focusNode; + + /// Shared control over the text field. + final SuperTextFieldContext textFieldContext; + + /// [GlobalKey] that links this [SuperTextFieldGestureInteractor] to + /// the [ProseTextLayout] widget that paints the text for this text field. + final GlobalKey textKey; + + /// Ordered list of actions that correspond to various key events. + /// + /// Each handler in the list may be given a key event from the keyboard. That + /// handler chooses to take an action, or not. A handler must respond with + /// a [TextFieldKeyboardHandlerResult], which indicates how the key event was handled, + /// or not. + /// + /// When a handler reports [TextFieldKeyboardHandlerResult.notHandled], the key event + /// is sent to the next handler. + /// + /// As soon as a handler reports [TextFieldKeyboardHandlerResult.handled], no other + /// handler is executed and the key event is prevented from propagating up + /// the widget tree. + /// + /// When a handler reports [TextFieldKeyboardHandlerResult.blocked], no other + /// handler is executed, but the key event **continues** to propagate up + /// the widget tree for other listeners to act upon. + /// + /// If all handlers report [TextFieldKeyboardHandlerResult.notHandled], the key + /// event propagates up the widget tree for other listeners to act upon. + final List keyboardActions; + + /// The rest of the subtree for this text field. + final Widget child; + + @override + State createState() => _SuperTextFieldKeyboardInteractorState(); +} + +class _SuperTextFieldKeyboardInteractorState extends State { + @override + void initState() { + super.initState(); + widget.focusNode.addListener(_onFocusChange); + } + + @override + void didUpdateWidget(SuperTextFieldKeyboardInteractor oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_onFocusChange); + widget.focusNode.addListener(_onFocusChange); + } + } + + @override + void dispose() { + widget.focusNode.removeListener(_onFocusChange); + super.dispose(); + } + + void _onFocusChange() { + if (widget.focusNode.hasFocus) { + return; + } + + _log.fine("Clearing selection because SuperTextField lost focus"); + widget.textFieldContext.controller.selection = const TextSelection.collapsed(offset: -1); + } + + KeyEventResult _onKeyPressed(FocusNode focusNode, KeyEvent keyEvent) { + _log.fine('_onKeyPressed - keyEvent: ${keyEvent.logicalKey}, character: ${keyEvent.character}'); + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + _log.finer('_onKeyPressed - not a "down" event. Ignoring.'); + return KeyEventResult.ignored; + } + + TextFieldKeyboardHandlerResult result = TextFieldKeyboardHandlerResult.notHandled; + int index = 0; + while (result == TextFieldKeyboardHandlerResult.notHandled && index < widget.keyboardActions.length) { + result = widget.keyboardActions[index]( + textFieldContext: widget.textFieldContext, + keyEvent: keyEvent, + ); + index += 1; + } + + _log.finest("Key handler result: $result"); + switch (result) { + case TextFieldKeyboardHandlerResult.handled: + return KeyEventResult.handled; + case TextFieldKeyboardHandlerResult.sendToOperatingSystem: + return KeyEventResult.skipRemainingHandlers; + case TextFieldKeyboardHandlerResult.blocked: + case TextFieldKeyboardHandlerResult.notHandled: + return KeyEventResult.ignored; + } + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: widget.focusNode, + onKeyEvent: _onKeyPressed, + child: widget.child, + ); + } +} + +/// Opens and closes an IME connection based on changes to focus and selection. +/// +/// This widget watches [focusNode] for focus changes, and [textController] for +/// selection changes. +/// +/// All IME commands are handled and applied to text field text by the given [textController]. +/// +/// When [focusNode] gains focus, if the [textController] doesn't have a selection, the caret is +/// placed at the end of the text. +/// +/// When [focusNode] loses focus, the [textController]'s selection is cleared. +class SuperTextFieldImeInteractor extends StatefulWidget { + const SuperTextFieldImeInteractor({ + Key? key, + required this.textKey, + required this.focusNode, + required this.textFieldContext, + required this.isMultiline, + required this.selectorHandlers, + this.textInputAction, + this.imeConfiguration, + required this.textStyleBuilder, + this.textAlign, + this.textDirection, + required this.child, + }) : super(key: key); + + /// [FocusNode] for this text field. + final FocusNode focusNode; + + final SuperTextFieldContext textFieldContext; + + /// Whether or not this text field supports multiple lines of text. + final bool isMultiline; + + /// [GlobalKey] that links this [SuperTextFieldGestureInteractor] to + /// the [ProseTextLayout] widget that paints the text for this text field. + final GlobalKey textKey; + + /// Handlers for all Mac OS "selectors" reported by the IME. + /// + /// The IME reports selectors as unique `String`s, therefore selector handlers are + /// defined as a mapping from selector names to handler functions. + final Map selectorHandlers; + + /// The type of action associated with ENTER key. + final TextInputAction? textInputAction; + + /// Preferences for how the platform IME should look and behave during editing. + final TextInputConfiguration? imeConfiguration; + + /// Text style factory that creates styles for the content in + /// [textController] based on the attributions in that content. + /// + /// On web, we can't set the position of IME popovers (e.g, emoji picker, + /// character selection panel) ourselves. Because of that, we need + /// to report to the IME what is our text style, so the browser can position + /// the popovers based on text metrics computed for the given style. + /// + /// This should be the same [AttributionStyleBuilder] used to + /// render the text. + final AttributionStyleBuilder textStyleBuilder; + + final TextAlign? textAlign; + + final TextDirection? textDirection; + + /// The rest of the subtree for this text field. + final Widget child; + + @override + State createState() => _SuperTextFieldImeInteractorState(); +} + +class _SuperTextFieldImeInteractorState extends State { + late ImeAttributedTextEditingController _textController; + + @override + void initState() { + super.initState(); + widget.focusNode.addListener(_updateSelectionAndImeConnectionOnFocusChange); + + _textController = widget.textFieldContext.imeController! + ..inputConnectionNotifier.addListener(_onImeConnectionChanged) + ..onPerformActionPressed ??= _onPerformAction + ..onPerformSelector ??= _onPerformSelector; + + if (widget.focusNode.hasFocus) { + // We got an already focused FocusNode, we need to attach to the IME. + onNextFrame((_) => _updateSelectionAndImeConnectionOnFocusChange()); + } + } + + @override + void didUpdateWidget(SuperTextFieldImeInteractor oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_updateSelectionAndImeConnectionOnFocusChange); + widget.focusNode.addListener(_updateSelectionAndImeConnectionOnFocusChange); + + if (widget.focusNode.hasFocus) { + // We got an already focused FocusNode, we need to attach to the IME. + onNextFrame((_) => _updateSelectionAndImeConnectionOnFocusChange()); + } + } + + if (widget.textFieldContext.imeController != _textController) { + if (_textController.onPerformActionPressed == _onPerformAction) { + _textController.onPerformActionPressed = null; + } + if (_textController.onPerformSelector == _onPerformSelector) { + _textController.onPerformSelector = null; + } + _textController.inputConnectionNotifier.removeListener(_onImeConnectionChanged); + + _textController = widget.textFieldContext.imeController! + ..inputConnectionNotifier.addListener(_onImeConnectionChanged) + ..onPerformActionPressed ??= _onPerformAction + ..onPerformSelector ??= _onPerformSelector; + } + + if (widget.imeConfiguration != oldWidget.imeConfiguration && + widget.imeConfiguration != null && + (oldWidget.imeConfiguration == null || !widget.imeConfiguration!.isEquivalentTo(oldWidget.imeConfiguration!)) && + _textController.isAttachedToIme) { + _textController.updateTextInputConfiguration( + viewId: View.of(context).viewId, + textInputAction: widget.imeConfiguration!.inputAction, + textInputType: widget.imeConfiguration!.inputType, + autocorrect: widget.imeConfiguration!.autocorrect, + enableSuggestions: widget.imeConfiguration!.enableSuggestions, + keyboardAppearance: widget.imeConfiguration!.keyboardAppearance, + textCapitalization: widget.imeConfiguration!.textCapitalization, + ); + } + } + + @override + void dispose() { + widget.focusNode.removeListener(_updateSelectionAndImeConnectionOnFocusChange); + _textController.inputConnectionNotifier.removeListener(_onImeConnectionChanged); + if (_textController.onPerformSelector == _onPerformSelector) { + _textController.onPerformSelector = null; + } + if (_textController.onPerformActionPressed == _onPerformAction) { + _textController.onPerformActionPressed = null; + } + super.dispose(); + } + + void _updateSelectionAndImeConnectionOnFocusChange() { + if (widget.focusNode.hasFocus) { + if (!_textController.isAttachedToIme) { + _log.info('Attaching TextInputClient to TextInput'); + setState(() { + if (!_textController.selection.isValid) { + _textController.selection = TextSelection.collapsed(offset: _textController.text.length); + } + + if (widget.imeConfiguration != null) { + _textController.attachToImeWithConfig(widget.imeConfiguration!); + } else { + _textController.attachToIme( + viewId: View.of(context).viewId, + textInputType: widget.isMultiline ? TextInputType.multiline : TextInputType.text, + textInputAction: + widget.textInputAction ?? (widget.isMultiline ? TextInputAction.newline : TextInputAction.done), + ); + } + }); + } + } else { + _log.info('Lost focus. Detaching TextInputClient from TextInput.'); + setState(() { + _textController.detachFromIme(); + _textController.selection = const TextSelection.collapsed(offset: -1); + }); + } + } + + void _onImeConnectionChanged() { + if (!_textController.isAttachedToIme) { + return; + } + + _reportVisualInformationToIme(); + } + + /// Report our size, transform to the root node coordinates, and caret rect to the IME. + /// + /// This is needed to display the OS emoji & symbols panel at the text field selected position. + /// + /// This methods is re-scheduled to run at the end of every frame while we are attached to the IME. + void _reportVisualInformationToIme() { + if (!_textController.isAttachedToIme) { + return; + } + + _reportSizeAndTransformToIme(); + _reportCaretRectToIme(); + _reportTextStyleToIme(); + + // Without showing the keyboard, the panel is always positioned at the screen center after the first time. + // I'm not sure why this is needed in SuperTextField, but not in SuperEditor. + _textController.showKeyboard(); + + // There are some operations that might affect our transform or the caret rect but we can't react to them. + // For example, the text field might be resized or moved around the screen. + // Because of this, we update our size, transform and caret rect at every frame. + onNextFrame((_) => _reportVisualInformationToIme()); + } + + /// Report the global size and transform of the text field to the IME. + /// + /// This is needed to display the OS emoji & symbols panel at the selected position. + void _reportSizeAndTransformToIme() { + final renderBox = widget.textKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) { + return; + } + + _textController.inputConnectionNotifier.value! + .setEditableSizeAndTransform(renderBox.size, renderBox.getTransformTo(null)); + } + + void _reportCaretRectToIme() { + if (CurrentPlatform.isWeb) { + // On web, setting the caret rect isn't supported. + // To position the IME popovers, we report our size, transform and text style + // and let the browser position the popovers. + return; + } + + final caretRect = _computeCaretRectInContentSpace(); + if (caretRect != null) { + _textController.inputConnectionNotifier.value!.setCaretRect(caretRect); + } + } + + /// Report our text style to the IME. + /// + /// This is used on web to set the text style of the hidden native input, + /// to try to match the text size on the browser with our text size. + /// + /// As our content can have multiple styles, the sizes won't be 100% in sync. + void _reportTextStyleToIme() { + late TextStyle textStyle; + + final selection = _textController.selection; + if (!selection.isValid) { + return; + } + + // We have a selection, compute the style based on the attributions present + // at the selection extent. + final text = _textController.text; + final attributions = text.getAllAttributionsAt(selection.extentOffset); + textStyle = widget.textStyleBuilder(attributions); + + _textController.inputConnectionNotifier.value!.setStyle( + fontFamily: textStyle.fontFamily, + fontSize: textStyle.fontSize, + fontWeight: textStyle.fontWeight, + textDirection: widget.textDirection ?? TextDirection.ltr, + textAlign: widget.textAlign ?? TextAlign.left, + ); + } + + Rect? _computeCaretRectInContentSpace() { + final text = widget.textKey.currentState; + if (text == null) { + return null; + } + + final selection = _textController.selection; + if (!selection.isValid) { + return null; + } + + final renderBox = context.findRenderObject() as RenderBox; + + // Compute the caret rect in the text layout space. + final position = TextPosition(offset: selection.baseOffset); + final textLayout = text.textLayout; + final caretOffset = textLayout.getOffsetForCaret(position); + final caretHeight = textLayout.getHeightForCaret(position) ?? textLayout.estimatedLineHeight; + final caretRect = caretOffset & Size(1, caretHeight); + + // Convert the coordinates from the text layout space to the text field space. + final textRenderBox = text.context.findRenderObject() as RenderBox; + final textOffset = renderBox.globalToLocal(textRenderBox.localToGlobal(Offset.zero)); + final caretOffsetInTextFieldSpace = caretRect.shift(textOffset); + + return caretOffsetInTextFieldSpace; + } + + void _onPerformSelector(String selectorName) { + final handler = widget.selectorHandlers[selectorName]; + if (handler == null) { + editorImeLog.warning("No handler found for $selectorName"); + return; + } + + handler(textFieldContext: widget.textFieldContext); + } + + /// Handles actions from the IME. + void _onPerformAction(TextInputAction action) { + switch (action) { + case TextInputAction.newline: + // Do nothing for IME newline actions. + // + // Mac: Key presses flow, unhandled, to the OS and turn into IME selectors. We handle newlines there. + // Windows/Linux: Key presses flow, unhandled, to the OS and turn into text deltas. We handle newlines there. + // Android/iOS: This text field implementation is only for desktop, mobile is handled elsewhere. + break; + case TextInputAction.done: + widget.focusNode.unfocus(); + break; + case TextInputAction.next: + widget.focusNode.nextFocus(); + break; + case TextInputAction.previous: + widget.focusNode.previousFocus(); + break; + default: + _log.warning("User pressed unhandled action button: $action"); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// Handles all scrolling behavior for a text field. +/// +/// [SuperTextFieldScrollview] is intended to operate as a piece within +/// a larger composition that behaves as a text field. [SuperTextFieldScrollview] +/// is defined on its own so that it can be replaced with a widget that handles +/// scrolling differently. +/// +/// [SuperTextFieldScrollview] determines when and where to scroll by working +/// with a corresponding [SuperSelectableText] widget that is tied to [textKey]. +class SuperTextFieldScrollview extends StatefulWidget { + const SuperTextFieldScrollview({ + Key? key, + required this.textKey, + required this.textController, + required this.scrollController, + required this.viewportHeight, + required this.estimatedLineHeight, + required this.isMultiline, + this.textAlign = TextAlign.left, + required this.child, + }) : super(key: key); + + /// [TextController] for the text/selection within this text field. + final AttributedTextEditingController textController; + + /// [GlobalKey] that links this [SuperTextFieldScrollview] to + /// the [ProseTextLayout] widget that paints the text for this text field. + final GlobalKey textKey; + + /// [ScrollController] that controls the scroll offset of this [SuperTextFieldScrollview]. + final ScrollController scrollController; + + /// The height of the viewport for this text field. + /// + /// If [null] then the viewport is permitted to grow/shrink to any desired height. + final double? viewportHeight; + + /// An estimate for the height in pixels of a single line of text within this + /// text field. + final double estimatedLineHeight; + + /// Whether or not this text field allows multiple lines of text. + final bool isMultiline; + + /// The text alignment within the scrollview. + final TextAlign textAlign; + + /// The rest of the subtree for this text field. + final Widget child; + + @override + SuperTextFieldScrollviewState createState() => SuperTextFieldScrollviewState(); +} + +class SuperTextFieldScrollviewState extends State with SingleTickerProviderStateMixin { + bool _scrollToStartOnTick = false; + bool _scrollToEndOnTick = false; + double _scrollAmountPerFrame = 0; + late Ticker _ticker; + + @override + void initState() { + super.initState(); + _ticker = createTicker(_onTick); + + widget.textController.addListener(_onSelectionOrContentChange); + } + + @override + void didUpdateWidget(SuperTextFieldScrollview oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.textController != oldWidget.textController) { + oldWidget.textController.removeListener(_onSelectionOrContentChange); + widget.textController.addListener(_onSelectionOrContentChange); + } + + if (widget.viewportHeight != oldWidget.viewportHeight) { + // After the current layout, ensure that the current text + // selection is visible. + onNextFrame((_) => _ensureSelectionExtentIsVisible()); + } + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + ProseTextLayout get _textLayout => widget.textKey.currentState!.textLayout; + + void _onSelectionOrContentChange() { + // Use a post-frame callback to "ensure selection extent is visible" + // so that any pending visual content changes can happen before + // attempting to calculate the visual position of the selection extent. + onNextFrame((_) => _ensureSelectionExtentIsVisible()); + } + + void _ensureSelectionExtentIsVisible() { + if (!widget.isMultiline) { + _ensureSelectionExtentIsVisibleInSingleLineTextField(); + } else { + _ensureSelectionExtentIsVisibleInMultilineTextField(); + } + } + + void _ensureSelectionExtentIsVisibleInSingleLineTextField() { + final selection = widget.textController.selection; + if (selection.extentOffset == -1) { + return; + } + + final viewportBox = context.findRenderObject() as RenderBox; + final textBox = widget.textKey.currentContext!.findRenderObject() as RenderBox; + // Note: the textBoxOffset will be negative. + final textBoxOffset = textBox.globalToLocal(Offset.zero, ancestor: viewportBox); + + final selectionExtentOffsetInText = _textLayout.getOffsetAtPosition(selection.extent); + + const gutterExtent = 0; // _dragGutterExtent + + final beyondLeftViewportEdge = min(-textBoxOffset.dx + selectionExtentOffsetInText.dx - gutterExtent, 0).abs(); + final beyondRightViewportEdge = + max((-textBoxOffset.dx + selectionExtentOffsetInText.dx + gutterExtent) - viewportBox.size.width, 0); + + if (beyondLeftViewportEdge > 0) { + final newScrollPosition = (widget.scrollController.offset - beyondLeftViewportEdge) + .clamp(0.0, widget.scrollController.position.maxScrollExtent); + + widget.scrollController.animateTo( + newScrollPosition, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } else if (beyondRightViewportEdge > 0) { + final newScrollPosition = (beyondRightViewportEdge + widget.scrollController.offset) + .clamp(0.0, widget.scrollController.position.maxScrollExtent); + + widget.scrollController.animateTo( + newScrollPosition, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + } + + void _ensureSelectionExtentIsVisibleInMultilineTextField() { + final selection = widget.textController.selection; + if (selection.extentOffset == -1) { + return; + } + + final extentOffset = _textLayout.getOffsetAtPosition(selection.extent); + + const gutterExtent = 0; // _dragGutterExtent + final extentLineIndex = (extentOffset.dy / widget.estimatedLineHeight).round(); + + final firstCharY = _textLayout.getCharacterBox(const TextPosition(offset: 0))?.top ?? 0.0; + final isAtFirstLine = extentOffset.dy == firstCharY; + + final myBox = context.findRenderObject() as RenderBox; + final beyondTopExtent = min( + extentOffset.dy - // + widget.scrollController.offset - + gutterExtent - + (isAtFirstLine ? _textLayout.getLineHeightAtPosition(selection.extent) / 2 : 0), + 0) + .abs(); + + final lastCharY = + _textLayout.getCharacterBox(TextPosition(offset: widget.textController.text.length - 1))?.top ?? 0.0; + final isAtLastLine = extentOffset.dy == lastCharY; + + final beyondBottomExtent = max( + ((extentLineIndex + 1) * widget.estimatedLineHeight) - + myBox.size.height - + widget.scrollController.offset + + gutterExtent + + (isAtLastLine ? _textLayout.getLineHeightAtPosition(selection.extent) / 2 : 0) + + (widget.estimatedLineHeight / 2), // manual adjustment to avoid line getting half cut off + 0); + + _log.finer('_ensureSelectionExtentIsVisible - Ensuring extent is visible.'); + _log.finer('_ensureSelectionExtentIsVisible - interaction size: ${myBox.size}'); + _log.finer('_ensureSelectionExtentIsVisible - scroll extent: ${widget.scrollController.offset}'); + _log.finer('_ensureSelectionExtentIsVisible - extent rect: $extentOffset'); + _log.finer('_ensureSelectionExtentIsVisible - beyond top: $beyondTopExtent'); + _log.finer('_ensureSelectionExtentIsVisible - beyond bottom: $beyondBottomExtent'); + + if (beyondTopExtent > 0) { + final newScrollPosition = (widget.scrollController.offset - beyondTopExtent) + .clamp(0.0, widget.scrollController.position.maxScrollExtent); + + widget.scrollController.animateTo( + newScrollPosition, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } else if (beyondBottomExtent > 0) { + final newScrollPosition = (beyondBottomExtent + widget.scrollController.offset) + .clamp(0.0, widget.scrollController.position.maxScrollExtent); + + widget.scrollController.animateTo( + newScrollPosition, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + } + + void startScrollingToStart({required double amountPerFrame}) { + assert(amountPerFrame > 0); + + if (_scrollToStartOnTick) { + _scrollAmountPerFrame = amountPerFrame; + return; + } + + _scrollToStartOnTick = true; + _log.finer("Starting Ticker to auto-scroll up"); + _ticker.start(); + } + + void stopScrollingToStart() { + if (!_scrollToStartOnTick) { + return; + } + + _scrollToStartOnTick = false; + _scrollAmountPerFrame = 0; + _log.finer("Stopping Ticker after auto-scroll up"); + _ticker.stop(); + } + + void scrollToStart() { + if (widget.scrollController.offset <= 0) { + return; + } + + widget.scrollController.position.jumpTo(widget.scrollController.offset - _scrollAmountPerFrame); + } + + void startScrollingToEnd({required double amountPerFrame}) { + assert(amountPerFrame > 0); + + if (_scrollToEndOnTick) { + _scrollAmountPerFrame = amountPerFrame; + return; + } + + _scrollToEndOnTick = true; + _log.finer("Starting Ticker to auto-scroll down"); + _ticker.start(); + } + + void stopScrollingToEnd() { + if (!_scrollToEndOnTick) { + return; + } + + _scrollToEndOnTick = false; + _scrollAmountPerFrame = 0; + _log.finer("Stopping Ticker after auto-scroll down"); + _ticker.stop(); + } + + void scrollToEnd() { + if (widget.scrollController.offset >= widget.scrollController.position.maxScrollExtent) { + return; + } + + widget.scrollController.position.jumpTo(widget.scrollController.offset + _scrollAmountPerFrame); + } + + /// Animates the scroll position like a ballistic particle with friction, beginning + /// with the given [pixelsPerSecond] velocity. + void goBallistic(double pixelsPerSecond) { + final pos = widget.scrollController.position; + + if (pos is ScrollPositionWithSingleContext) { + if (pos.maxScrollExtent > 0) { + pos.goBallistic(pixelsPerSecond); + } + pos.context.setIgnorePointer(false); + } + } + + /// Immediately stops scrolling animation/momentum. + void goIdle() { + final pos = widget.scrollController.position; + + if (pos is ScrollPositionWithSingleContext) { + if (pos.pixels > pos.minScrollExtent && pos.pixels < pos.maxScrollExtent) { + pos.goIdle(); + } + } + } + + void _onTick(elapsedTime) { + if (_scrollToStartOnTick) { + scrollToStart(); + } + if (_scrollToEndOnTick) { + scrollToEnd(); + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.viewportHeight, + // As we handle the scrolling gestures ourselves, + // we use NeverScrollableScrollPhysics to prevent SingleChildScrollView + // from scrolling. This also prevents the user from interacting + // with the scrollbar. + // We use a modified version of Flutter's Scrollbar that allows + // configuring it with a different scroll physics. + // + // See https://github.com/superlistapp/super_editor/issues/1628 for more details. + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + controller: widget.scrollController, + physics: const NeverScrollableScrollPhysics(), + scrollDirection: widget.isMultiline ? Axis.vertical : Axis.horizontal, + child: widget.child, + ), + ), + ); + } +} + +typedef RightClickListener = void Function( + BuildContext textFieldContext, AttributedTextEditingController textController, Offset textFieldOffset); + +enum _SelectionType { + /// The selection bound is set on a per-character basis. + /// + /// This is standard text selection behavior. + position, + + /// The selection bound expands to include any word that the + /// cursor touches. + word, + + /// The selection bound expands to include any paragraph that + /// the cursor touches. + paragraph, +} + +enum TextFieldKeyboardHandlerResult { + /// The handler recognized the key event and chose to + /// take an action. + /// + /// No other handler should receive the key event. + /// + /// The key event **shouldn't** bubble up the tree. + handled, + + /// The handler recognized the key event but chose to + /// take no action. + /// + /// No other handler should receive the key event. + /// + /// The key event **should** bubble up the tree to + /// (possibly) be handled by other keyboard/shortcut + /// listeners. + blocked, + + /// The handler recognized the key event but chose to + /// take no action. + /// + /// No other handler should receive the key event. + /// + /// The key event shouldn't bubble up the Flutter tree, + /// but it should be sent to the operating system (rather + /// than being consumed and disposed). + /// + /// Use this result, for example, when Mac OS needs to + /// convert a key event into a selector, and send that + /// selector through the IME. + sendToOperatingSystem, + + /// The handler has no relation to the key event and + /// took no action. + /// + /// Other handlers should be given a chance to act on + /// the key press. + notHandled, +} + +typedef TextFieldKeyboardHandler = TextFieldKeyboardHandlerResult Function({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, +}); + +/// A [TextFieldKeyboardHandler] that reports [TextFieldKeyboardHandlerResult.blocked] +/// for any key combination that matches one of the given [keys]. +TextFieldKeyboardHandler ignoreTextFieldKeyCombos(List keys) { + return ({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + for (final key in keys) { + if (key.accepts(keyEvent, HardwareKeyboard.instance)) { + return TextFieldKeyboardHandlerResult.blocked; + } + } + return TextFieldKeyboardHandlerResult.notHandled; + }; +} + +/// The keyboard actions that a [SuperTextField] uses by default. +/// +/// It's common for developers to want all of these actions, but also +/// want to add more actions that take priority. To achieve that, +/// add the new actions to the front of the list: +/// +/// ``` +/// SuperTextField( +/// keyboardActions: [ +/// myNewAction1, +/// myNewAction2, +/// ...defaultTextfieldKeyboardActions, +/// ], +/// ); +/// ``` +const defaultTextFieldKeyboardHandlers = [ + DefaultSuperTextFieldKeyboardHandlers.scrollOnPageUp, + DefaultSuperTextFieldKeyboardHandlers.scrollOnPageDown, + DefaultSuperTextFieldKeyboardHandlers.scrollToBeginningOfDocumentOnCtrlOrCmdAndHome, + DefaultSuperTextFieldKeyboardHandlers.scrollToEndOfDocumentOnCtrlOrCmdAndEnd, + DefaultSuperTextFieldKeyboardHandlers.scrollToBeginningOfDocumentOnHomeOnMacOrWeb, + DefaultSuperTextFieldKeyboardHandlers.scrollToEndOfDocumentOnEndOnMacOrWeb, + DefaultSuperTextFieldKeyboardHandlers.copyTextWhenCmdCIsPressed, + DefaultSuperTextFieldKeyboardHandlers.pasteTextWhenCmdVIsPressed, + DefaultSuperTextFieldKeyboardHandlers.selectAllTextFieldWhenCmdAIsPressed, + DefaultSuperTextFieldKeyboardHandlers.moveCaretToStartOrEnd, + DefaultSuperTextFieldKeyboardHandlers.moveUpDownLeftAndRightWithArrowKeys, + DefaultSuperTextFieldKeyboardHandlers.moveToLineStartWithHome, + DefaultSuperTextFieldKeyboardHandlers.moveToLineEndWithEnd, + DefaultSuperTextFieldKeyboardHandlers.deleteWordWhenAltBackSpaceIsPressedOnMac, + DefaultSuperTextFieldKeyboardHandlers.deleteWordWhenCtlBackSpaceIsPressedOnWindowsAndLinux, + DefaultSuperTextFieldKeyboardHandlers.deleteTextOnLineBeforeCaretWhenShortcutKeyAndBackspaceIsPressed, + DefaultSuperTextFieldKeyboardHandlers.deleteTextWhenBackspaceOrDeleteIsPressed, + DefaultSuperTextFieldKeyboardHandlers.insertNewlineWhenEnterIsPressed, + DefaultSuperTextFieldKeyboardHandlers.blockControlKeys, + DefaultSuperTextFieldKeyboardHandlers.insertCharacterWhenKeyIsPressed, +]; + +/// The keyboard actions that a [SuperTextField] uses by default when using [TextInputSource.ime]. +/// +/// Using the IME on desktop involves partial input from the IME and partial input from non-content keys, +/// like arrow keys. +/// +/// This list has the same handlers as [defaultTextFieldKeyboardHandlers], except the handlers that +/// input text. Text input is handled using [TextEditingDelta]s from the IME. +/// +/// It's common for developers to want all of these actions, but also +/// want to add more actions that take priority. To achieve that, +/// add the new actions to the front of the list: +/// +/// ``` +/// SuperTextField( +/// keyboardActions: [ +/// myNewAction1, +/// myNewAction2, +/// ...defaultTextFieldImeKeyboardHandlers, +/// ], +/// ); +/// ``` +const defaultTextFieldImeKeyboardHandlers = [ + DefaultSuperTextFieldKeyboardHandlers.copyTextWhenCmdCIsPressed, + DefaultSuperTextFieldKeyboardHandlers.pasteTextWhenCmdVIsPressed, + DefaultSuperTextFieldKeyboardHandlers.selectAllTextFieldWhenCmdAIsPressed, + DefaultSuperTextFieldKeyboardHandlers.scrollToBeginningOfDocumentOnCtrlOrCmdAndHome, + DefaultSuperTextFieldKeyboardHandlers.scrollToEndOfDocumentOnCtrlOrCmdAndEnd, + // WARNING: No keyboard handlers below this point will run on Mac. On Mac, most + // common shortcuts are recognized by the OS. This line short circuits SuperTextField + // handlers, passing the key combo to the OS on Mac. Place all custom Mac key + // combos above this handler. + DefaultSuperTextFieldKeyboardHandlers.sendKeyEventToMacOs, + DefaultSuperTextFieldKeyboardHandlers.scrollOnPageUp, + DefaultSuperTextFieldKeyboardHandlers.scrollOnPageDown, + DefaultSuperTextFieldKeyboardHandlers.scrollToBeginningOfDocumentOnHomeOnMacOrWeb, + DefaultSuperTextFieldKeyboardHandlers.scrollToEndOfDocumentOnEndOnMacOrWeb, + DefaultSuperTextFieldKeyboardHandlers.moveCaretToStartOrEnd, + DefaultSuperTextFieldKeyboardHandlers.moveUpDownLeftAndRightWithArrowKeys, + DefaultSuperTextFieldKeyboardHandlers.moveToLineStartWithHome, + DefaultSuperTextFieldKeyboardHandlers.moveToLineEndWithEnd, + DefaultSuperTextFieldKeyboardHandlers.deleteWordWhenAltBackSpaceIsPressedOnMac, + DefaultSuperTextFieldKeyboardHandlers.deleteWordWhenCtlBackSpaceIsPressedOnWindowsAndLinux, + DefaultSuperTextFieldKeyboardHandlers.deleteTextOnLineBeforeCaretWhenShortcutKeyAndBackspaceIsPressed, + DefaultSuperTextFieldKeyboardHandlers.deleteTextWhenBackspaceOrDeleteIsPressed, +]; + +class DefaultSuperTextFieldKeyboardHandlers { + /// [copyTextWhenCmdCIsPressed] copies text to clipboard when primary shortcut key + /// (CMD on Mac, CTL on Windows) + C is pressed. + static TextFieldKeyboardHandlerResult copyTextWhenCmdCIsPressed({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (!keyEvent.isPrimaryShortcutKeyPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.keyC) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (textFieldContext.controller.selection.extentOffset == -1) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + textFieldContext.controller.copySelectedTextToClipboard(); + + return TextFieldKeyboardHandlerResult.handled; + } + + /// [pasteTextWhenCmdVIsPressed] pastes text from clipboard to document when primary shortcut key + /// (CMD on Mac, CTL on Windows) + V is pressed. + static TextFieldKeyboardHandlerResult pasteTextWhenCmdVIsPressed({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (!keyEvent.isPrimaryShortcutKeyPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.keyV) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (textFieldContext.controller.selection.extentOffset == -1) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelectedText(); + } + + textFieldContext.controller.pasteClipboard(); + + return TextFieldKeyboardHandlerResult.handled; + } + + /// [selectAllTextFieldWhenCmdAIsPressed] selects all text when primary shortcut key + /// (CMD on Mac, CTL on Windows) + A is pressed. + static TextFieldKeyboardHandlerResult selectAllTextFieldWhenCmdAIsPressed({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (!keyEvent.isPrimaryShortcutKeyPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.keyA) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + textFieldContext.controller.selectAll(); + + return TextFieldKeyboardHandlerResult.handled; + } + + /// [moveCaretToStartOrEnd] moves caret to start (using CTL+A) or end of line (using CTL+E) + /// on MacOS platforms. This is part of expected behavior on MacOS. Not applicable to Windows. + static TextFieldKeyboardHandlerResult moveCaretToStartOrEnd({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + bool moveLeft = false; + if (!HardwareKeyboard.instance.isControlPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (defaultTargetPlatform != TargetPlatform.macOS) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.keyA && keyEvent.logicalKey != LogicalKeyboardKey.keyE) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (textFieldContext.controller.selection.extentOffset == -1) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + keyEvent.logicalKey == LogicalKeyboardKey.keyA + ? moveLeft = true + : keyEvent.logicalKey == LogicalKeyboardKey.keyE + ? moveLeft = false + : null; + + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: false, + moveLeft: moveLeft, + movementModifier: MovementModifier.line, + ); + + return TextFieldKeyboardHandlerResult.handled; + } + + /// [moveUpDownLeftAndRightWithArrowKeys] moves caret according to the directional key which was pressed. + /// If there is no caret selection. it does nothing. + static TextFieldKeyboardHandlerResult moveUpDownLeftAndRightWithArrowKeys({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + const arrowKeys = [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + ]; + if (!arrowKeys.contains(keyEvent.logicalKey)) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (CurrentPlatform.isWeb && (textFieldContext.controller.composingRegion.isValid)) { + // We are composing a character on web. It's possible that a native element is being displayed, + // like an emoji picker or a character selection panel. + // We need to let the OS handle the key so the user can navigate + // on the list of possible characters. + // TODO: update this after https://github.com/flutter/flutter/issues/134268 is resolved. + return TextFieldKeyboardHandlerResult.blocked; + } + + if (textFieldContext.controller.selection.extentOffset == -1) { + // The result is reported as "handled" because an arrow + // key was pressed, but we return early because there is + // nowhere to move without a selection. + return TextFieldKeyboardHandlerResult.handled; + } + + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (defaultTargetPlatform == TargetPlatform.linux && + HardwareKeyboard.instance.isAltPressed && + (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp || keyEvent.logicalKey == LogicalKeyboardKey.arrowDown)) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey == LogicalKeyboardKey.arrowLeft) { + _log.finer('moveUpDownLeftAndRightWithArrowKeys - handling left arrow key'); + + MovementModifier? movementModifier; + if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && + HardwareKeyboard.instance.isControlPressed) { + movementModifier = MovementModifier.word; + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isMetaPressed) { + movementModifier = MovementModifier.line; + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isAltPressed) { + movementModifier = MovementModifier.word; + } + + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, + moveLeft: true, + movementModifier: movementModifier, + ); + } else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowRight) { + _log.finer('moveUpDownLeftAndRightWithArrowKeys - handling right arrow key'); + + MovementModifier? movementModifier; + if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && + HardwareKeyboard.instance.isControlPressed) { + movementModifier = MovementModifier.word; + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isMetaPressed) { + movementModifier = MovementModifier.line; + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isAltPressed) { + movementModifier = MovementModifier.word; + } + + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, + moveLeft: false, + movementModifier: movementModifier, + ); + } else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp) { + _log.finer('moveUpDownLeftAndRightWithArrowKeys - handling up arrow key'); + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, + moveUp: true, + ); + } else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowDown) { + _log.finer('moveUpDownLeftAndRightWithArrowKeys - handling down arrow key'); + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, + moveUp: false, + ); + } + + return TextFieldKeyboardHandlerResult.handled; + } + + static TextFieldKeyboardHandlerResult moveToLineStartWithHome({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey == LogicalKeyboardKey.home) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, + moveLeft: true, + movementModifier: MovementModifier.line, + ); + return TextFieldKeyboardHandlerResult.handled; + } + + return TextFieldKeyboardHandlerResult.notHandled; + } + + static TextFieldKeyboardHandlerResult moveToLineEndWithEnd({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey == LogicalKeyboardKey.end) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, + moveLeft: false, + movementModifier: MovementModifier.line, + ); + return TextFieldKeyboardHandlerResult.handled; + } + + return TextFieldKeyboardHandlerResult.notHandled; + } + + /// [insertCharacterWhenKeyIsPressed] adds any character when that key is pressed. + /// Certain keys are currently checked against a blacklist of characters for web + /// since their behavior is unexpected. Check definition for more details. + static TextFieldKeyboardHandlerResult insertCharacterWhenKeyIsPressed({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.character == null || keyEvent.character == '') { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (LogicalKeyboardKey.isControlCharacter(keyEvent.character!)) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + // On web, keys like shift and alt are sending their full name + // as a character, e.g., "Shift" and "Alt". This check prevents + // those keys from inserting their name into content. + // + // This filter is a blacklist, and therefore it will fail to + // catch any key that isn't explicitly listed. The eventual solution + // to this is for the web to honor the standard key event contract, + // but that's out of our control. + if (isKeyEventCharacterBlacklisted(keyEvent.character)) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + textFieldContext.controller.insertCharacter(keyEvent.character!); + + return TextFieldKeyboardHandlerResult.handled; + } + + /// Deletes text between the beginning of the line and the caret, when the user + /// presses CMD + Backspace, or CTL + Backspace. + static TextFieldKeyboardHandlerResult deleteTextOnLineBeforeCaretWhenShortcutKeyAndBackspaceIsPressed({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (textFieldContext.controller.selection.extentOffset < 0) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelection(); + return TextFieldKeyboardHandlerResult.handled; + } + + if (textFieldContext + .getTextLayout() + .getPositionAtStartOfLine(textFieldContext.controller.selection.extent) + .offset == + textFieldContext.controller.selection.extentOffset) { + // The caret is sitting at the beginning of a line. There's nothing for us to + // delete upstream on this line. But we also don't want a regular BACKSPACE to + // run, either. Report this key combination as handled. + return TextFieldKeyboardHandlerResult.handled; + } + + textFieldContext.controller.deleteTextOnLineBeforeCaret(textLayout: textFieldContext.getTextLayout()); + + return TextFieldKeyboardHandlerResult.handled; + } + + /// [deleteTextWhenBackspaceOrDeleteIsPressed] deletes single characters when delete or backspace is pressed. + static TextFieldKeyboardHandlerResult deleteTextWhenBackspaceOrDeleteIsPressed({ + required SuperTextFieldContext textFieldContext, + ProseTextLayout? textLayout, + required KeyEvent keyEvent, + }) { + final isBackspace = keyEvent.logicalKey == LogicalKeyboardKey.backspace; + final isDelete = keyEvent.logicalKey == LogicalKeyboardKey.delete; + if (!isBackspace && !isDelete) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (textFieldContext.controller.selection.extentOffset < 0) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteCharacter(isBackspace ? TextAffinity.upstream : TextAffinity.downstream); + } else { + textFieldContext.controller.deleteSelectedText(); + } + + return TextFieldKeyboardHandlerResult.handled; + } + + /// [deleteWordWhenAltBackSpaceIsPressedOnMac] deletes single words when Alt+Backspace is pressed on Mac. + static TextFieldKeyboardHandlerResult deleteWordWhenAltBackSpaceIsPressedOnMac({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (defaultTargetPlatform != TargetPlatform.macOS) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace || !HardwareKeyboard.instance.isAltPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (textFieldContext.controller.selection.extentOffset < 0) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + _deleteUpstreamWord(textFieldContext.controller, textFieldContext.getTextLayout()); + + return TextFieldKeyboardHandlerResult.handled; + } + + /// [deleteWordWhenAltBackSpaceIsPressedOnMac] deletes single words when Ctl+Backspace is pressed on Windows/Linux. + static TextFieldKeyboardHandlerResult deleteWordWhenCtlBackSpaceIsPressedOnWindowsAndLinux({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace || !HardwareKeyboard.instance.isControlPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (textFieldContext.controller.selection.extentOffset < 0) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + _deleteUpstreamWord(textFieldContext.controller, textFieldContext.getTextLayout()); + + return TextFieldKeyboardHandlerResult.handled; + } + + static void _deleteUpstreamWord(AttributedTextEditingController controller, ProseTextLayout textLayout) { + if (!controller.selection.isCollapsed) { + controller.deleteSelectedText(); + return; + } + + controller.moveCaretHorizontally( + textLayout: textLayout, + expandSelection: true, + moveLeft: true, + movementModifier: MovementModifier.word, + ); + controller.deleteSelectedText(); + } + + /// [insertNewlineWhenEnterIsPressed] inserts a new line character when the enter key is pressed. + static TextFieldKeyboardHandlerResult insertNewlineWhenEnterIsPressed({ + required SuperTextFieldContext textFieldContext, + ProseTextLayout? textLayout, + required KeyEvent keyEvent, + }) { + if (keyEvent.logicalKey != LogicalKeyboardKey.enter && keyEvent.logicalKey != LogicalKeyboardKey.numpadEnter) { + return TextFieldKeyboardHandlerResult.notHandled; + } + if (!textFieldContext.controller.selection.isCollapsed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + textFieldContext.controller.insertNewline(); + + return TextFieldKeyboardHandlerResult.handled; + } + + static TextFieldKeyboardHandlerResult sendKeyEventToMacOs({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (defaultTargetPlatform == TargetPlatform.macOS && !CurrentPlatform.isWeb) { + // On macOS, we let the IME handle all key events. Then, the IME might generate + // selectors which express the user intent, e.g, moveLeftAndModifySelection:. + // + // For the full list of selectors handled by SuperEditor, see the MacOsSelectors class. + // + // This is needed for the interaction with the accent panel to work. + return TextFieldKeyboardHandlerResult.sendToOperatingSystem; + } + + return TextFieldKeyboardHandlerResult.notHandled; + } + + /// Scrolls up by the viewport height, or as high as possible, + /// when the user presses the Page Up key. + /// + /// Scrolls the text field if it has scrollable content, if not then scrolls the + /// ancestor scrollable content if one's present. + static TextFieldKeyboardHandlerResult scrollOnPageUp({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.pageUp) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + final bool scrolled = _scrollPageUp(textFieldContext: textFieldContext); + + /// If scrolled, mark the key event as 'handled', otherwise 'notHandled' to give other + /// key handlers opportunity to handle the key event. + return scrolled ? TextFieldKeyboardHandlerResult.handled : TextFieldKeyboardHandlerResult.notHandled; + } + + /// Scrolls down by the viewport height, or as far as possible, + /// when the user presses the Page Down key. + /// + /// Scrolls the text field if it has scrollable content, if not then scrolls the + /// ancestor scrollable content if one's present. + static TextFieldKeyboardHandlerResult scrollOnPageDown({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.pageDown) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + final bool scrolled = _scrollPageDown(textFieldContext: textFieldContext); + + /// If scrolled, mark the key event as 'handled', otherwise 'notHandled' to give other + /// key handlers opportunity to handle the key event. + return scrolled ? TextFieldKeyboardHandlerResult.handled : TextFieldKeyboardHandlerResult.notHandled; + } + + /// Scrolls the viewport to the top of the content, when the user presses + /// CMD + HOME on Mac, or CTRL + HOME on all other platforms. + /// + /// Scrolls the text field if it has scrollable content, if not then scrolls to the + /// top of the ancestor scrollable content if one's present. + static TextFieldKeyboardHandlerResult scrollToBeginningOfDocumentOnCtrlOrCmdAndHome({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.home) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (CurrentPlatform.isApple && !HardwareKeyboard.instance.isMetaPressed) { + // !HardwareKeyboard.instance.isMetaPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (!CurrentPlatform.isApple && !HardwareKeyboard.instance.isControlPressed) { + // !HardwareKeyboard.instance.isControlPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + final bool scrolled = _scrollToBeginningOfDocument(textFieldContext: textFieldContext); + + /// If scrolled, mark the key event as 'handled', otherwise 'notHandled' to give other + /// key handlers opportunity to handle the key event. + return scrolled ? TextFieldKeyboardHandlerResult.handled : TextFieldKeyboardHandlerResult.notHandled; + } + + /// Scrolls the viewport to the bottom of the content, when the user presses + /// CMD + END on Mac, or CTRL + END on all other platforms. + /// + /// Scrolls the text field if it has scrollable content, if not then scrolls to the + /// bottom of the ancestor scrollable content if one's present. + static TextFieldKeyboardHandlerResult scrollToEndOfDocumentOnCtrlOrCmdAndEnd({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.end) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (CurrentPlatform.isApple && !HardwareKeyboard.instance.isMetaPressed) { + // !HardwareKeyboard.instance.isMetaPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (!CurrentPlatform.isApple && !HardwareKeyboard.instance.isControlPressed) { + // !HardwareKeyboard.instance.isControlPressed) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + final bool scrolled = _scrollToEndOfDocument(textFieldContext: textFieldContext); + + /// If scrolled, mark the key event as 'handled', otherwise 'notHandled' to give other + /// key handlers opportunity to handle the key event. + return scrolled ? TextFieldKeyboardHandlerResult.handled : TextFieldKeyboardHandlerResult.notHandled; + } + + /// Scrolls the viewport to the top of the content, when the user presses + /// HOME on Mac or web. + /// + /// Scrolls the text field if it has scrollable content, if not then scrolls to the + /// top of the ancestor scrollable content if one's present. + static TextFieldKeyboardHandlerResult scrollToBeginningOfDocumentOnHomeOnMacOrWeb({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.home) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (defaultTargetPlatform != TargetPlatform.macOS && !CurrentPlatform.isWeb) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + final bool scrolled = _scrollToBeginningOfDocument(textFieldContext: textFieldContext); + + /// If scrolled, mark the key event as 'handled', otherwise 'notHandled' to give other + /// key handlers opportunity to handle the key event. + return scrolled ? TextFieldKeyboardHandlerResult.handled : TextFieldKeyboardHandlerResult.notHandled; + } + + /// Scrolls the viewport to the bottom of the content, when the user presses + /// END on Mac or web. + /// + /// Scrolls the text field if it has scrollable content, if not then scrolls to the + /// bottom of the ancestor scrollable content if one's present. + static TextFieldKeyboardHandlerResult scrollToEndOfDocumentOnEndOnMacOrWeb({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.end) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + if (defaultTargetPlatform != TargetPlatform.macOS && !CurrentPlatform.isWeb) { + return TextFieldKeyboardHandlerResult.notHandled; + } + + final bool scrolled = _scrollToEndOfDocument(textFieldContext: textFieldContext); + + /// If scrolled, mark the key event as 'handled', otherwise 'notHandled' to give other + /// key handlers opportunity to handle the key event. + return scrolled ? TextFieldKeyboardHandlerResult.handled : TextFieldKeyboardHandlerResult.notHandled; + } + + /// Halt execution of the current key event if the key pressed is one of + /// the functions keys (F1, F2, F3, etc.), or the Page Up/Down, Home/End key. + /// + /// Without this action in place pressing one of the above mentioned keys + /// would display an unknown '?' character in the textfield. + static TextFieldKeyboardHandlerResult blockControlKeys({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (keyEvent.logicalKey == LogicalKeyboardKey.escape || + keyEvent.logicalKey == LogicalKeyboardKey.pageUp || + keyEvent.logicalKey == LogicalKeyboardKey.pageDown || + keyEvent.logicalKey == LogicalKeyboardKey.home || + keyEvent.logicalKey == LogicalKeyboardKey.end || + (keyEvent.logicalKey.keyId >= LogicalKeyboardKey.f1.keyId && + keyEvent.logicalKey.keyId <= LogicalKeyboardKey.f23.keyId)) { + return TextFieldKeyboardHandlerResult.blocked; + } + + return TextFieldKeyboardHandlerResult.notHandled; + } + + DefaultSuperTextFieldKeyboardHandlers._(); +} + +/// Computes the estimated line height of a [TextStyle]. +class _EstimatedLineHeight { + /// Last computed line height. + double? _lastLineHeight; + + /// TextStyle used to compute [_lastLineHeight]. + TextStyle? _lastComputedStyle; + + /// Text scale policy used to compute [_lastLineHeight]. + TextScaler? _lastTextScaleFactor; + + /// Computes the estimated line height for the given [style]. + /// + /// The height is computed by laying out a [Paragraph] with an arbitrary + /// character and inspecting it's height. + /// + /// The result is cached for the last [style] and [textScaler] used, so it's not computed + /// at each call. + double calculate(TextStyle style, TextScaler textScaler) { + if (_lastComputedStyle == style && + _lastLineHeight != null && + _lastTextScaleFactor == textScaler && + _lastTextScaleFactor != null) { + return _lastLineHeight!; + } + + final builder = ui.ParagraphBuilder(style.getParagraphStyle()) + ..pushStyle(style.getTextStyle(textScaler: textScaler)) + ..addText('A'); + + final paragraph = builder.build(); + paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); + + _lastLineHeight = paragraph.height; + _lastComputedStyle = style; + _lastTextScaleFactor = textScaler; + return _lastLineHeight!; + } +} + +/// A callback to handle a `performSelector` call. +typedef SuperTextFieldSelectorHandler = void Function({ + required SuperTextFieldContext textFieldContext, +}); + +const defaultTextFieldSelectorHandlers = { + // Control. + MacOsSelectors.insertTab: _moveFocusNext, + MacOsSelectors.cancelOperation: _giveUpFocus, + + // Caret movement. + MacOsSelectors.moveLeft: _moveCaretUpstream, + MacOsSelectors.moveRight: _moveCaretDownstream, + MacOsSelectors.moveUp: _moveCaretUp, + MacOsSelectors.moveDown: _moveCaretDown, + MacOsSelectors.moveForward: _moveCaretDownstream, + MacOsSelectors.moveBackward: _moveCaretUpstream, + MacOsSelectors.moveWordLeft: _moveWordUpstream, + MacOsSelectors.moveWordRight: _moveWordDownstream, + MacOsSelectors.moveToLeftEndOfLine: _moveLineBeginning, + MacOsSelectors.moveToRightEndOfLine: _moveLineEnd, + + // Selection expanding. + MacOsSelectors.moveLeftAndModifySelection: _expandSelectionUpstream, + MacOsSelectors.moveRightAndModifySelection: _expandSelectionDownstream, + MacOsSelectors.moveUpAndModifySelection: _expandSelectionLineUp, + MacOsSelectors.moveDownAndModifySelection: _expandSelectionLineDown, + MacOsSelectors.moveWordLeftAndModifySelection: _expandSelectionWordUpstream, + MacOsSelectors.moveWordRightAndModifySelection: _expandSelectionWordDownstream, + MacOsSelectors.moveToLeftEndOfLineAndModifySelection: _expandSelectionLineUpstream, + MacOsSelectors.moveToRightEndOfLineAndModifySelection: _expandSelectionLineDownstream, + + // Deletion. + MacOsSelectors.deleteBackward: _deleteUpstream, + MacOsSelectors.deleteForward: _deleteDownstream, + MacOsSelectors.deleteWordBackward: _deleteWordUpstream, + MacOsSelectors.deleteWordForward: _deleteWordDownstream, + MacOsSelectors.deleteToBeginningOfLine: _deleteToBeginningOfLine, + MacOsSelectors.deleteToEndOfLine: _deleteToEndOfLine, + MacOsSelectors.deleteBackwardByDecomposingPreviousCharacter: _deleteUpstream, + + // Scrolling. + MacOsSelectors.scrollToBeginningOfDocument: _scrollToBeginningOfDocument, + MacOsSelectors.scrollToEndOfDocument: _scrollToEndOfDocument, + MacOsSelectors.scrollPageUp: _scrollPageUp, + MacOsSelectors.scrollPageDown: _scrollPageDown, +}; + +void _giveUpFocus({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.focusNode.unfocus(); +} + +void _moveFocusNext({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.focusNode.nextFocus(); +} + +void _moveCaretUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: false, + movementModifier: null, + ); +} + +void _moveCaretDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: false, + movementModifier: null, + ); +} + +void _moveCaretUp({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + moveUp: true, + expandSelection: false, + ); +} + +void _moveCaretDown({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + moveUp: false, + expandSelection: false, + ); +} + +void _moveWordUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: false, + movementModifier: MovementModifier.word, + ); +} + +void _moveWordDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: false, + movementModifier: MovementModifier.word, + ); +} + +void _moveLineBeginning({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: false, + movementModifier: MovementModifier.line, + ); +} + +void _moveLineEnd({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: false, + movementModifier: MovementModifier.line, + ); +} + +void _expandSelectionUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: true, + movementModifier: null, + ); +} + +void _expandSelectionDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: true, + movementModifier: null, + ); +} + +void _expandSelectionLineUp({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + moveUp: true, + expandSelection: true, + ); +} + +void _expandSelectionLineDown({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + moveUp: false, + expandSelection: true, + ); +} + +void _expandSelectionWordUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: true, + movementModifier: MovementModifier.word, + ); +} + +void _expandSelectionWordDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: true, + movementModifier: MovementModifier.word, + ); +} + +void _expandSelectionLineUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: true, + movementModifier: MovementModifier.line, + ); +} + +void _expandSelectionLineDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: true, + movementModifier: MovementModifier.line, + ); +} + +void _deleteUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + if (textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteCharacter(TextAffinity.upstream); + } else { + textFieldContext.controller.deleteSelectedText(); + } +} + +void _deleteDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + if (textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteCharacter(TextAffinity.downstream); + } else { + textFieldContext.controller.deleteSelectedText(); + } +} + +void _deleteWordUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelectedText(); + return; + } + + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: true, + moveLeft: true, + movementModifier: MovementModifier.word, + ); + textFieldContext.controller.deleteSelectedText(); +} + +void _deleteWordDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelectedText(); + return; + } + + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: true, + moveLeft: false, + movementModifier: MovementModifier.word, + ); + + textFieldContext.controller.deleteSelectedText(); +} + +void _deleteToBeginningOfLine({ + required SuperTextFieldContext textFieldContext, +}) { + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelection(); + return; + } + + if (textFieldContext.getTextLayout().getPositionAtStartOfLine(textFieldContext.controller.selection.extent).offset == + textFieldContext.controller.selection.extentOffset) { + // The caret is sitting at the beginning of a line. There's nothing for us to + // delete upstream on this line. But we also don't want a regular BACKSPACE to + // run, either. Report this key combination as handled. + return; + } + + textFieldContext.controller.deleteTextOnLineBeforeCaret(textLayout: textFieldContext.getTextLayout()); +} + +void _deleteToEndOfLine({ + required SuperTextFieldContext textFieldContext, +}) { + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelection(); + return; + } + + if (textFieldContext.getTextLayout().getPositionAtEndOfLine(textFieldContext.controller.selection.extent).offset == + textFieldContext.controller.selection.extentOffset) { + // The caret is sitting at the end of a line. There's nothing for us to + // delete downstream on this line. + return; + } + + textFieldContext.controller.deleteTextOnLineAfterCaret(textLayout: textFieldContext.getTextLayout()); +} + +/// Scrolls to the top of the textfield. +/// +/// In absence of scrollable content within textfield, tries to scroll the ancestor +/// scrollable to its top. +/// +/// Returns `true` if the scroll is performed, otherwise 'false'. +bool _scrollToBeginningOfDocument({ + required SuperTextFieldContext textFieldContext, +}) { + final TextFieldScroller textFieldScroller = textFieldContext.scroller; + final ScrollPosition? ancestorScrollable = + textFieldContext.textFieldBuildContext.findAncestorScrollableWithVerticalScroll?.position; + + if (textFieldScroller.maxScrollExtent == 0 && ancestorScrollable == null) { + // The text field doesn't have any scrollable content. There is no ancestor + // scrollable to scroll. Fizzle. + return false; + } + + if (textFieldScroller.scrollOffset > 0) { + // The text field has more content than can fit, and the text field is partially + // scrolled downward. Scroll back to the top of the text field. + textFieldScroller.animateTo( + textFieldScroller.minScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return true; + } + + if (ancestorScrollable == null) { + // There is no ancestor scrollable to scroll. Fizzle. + return false; + } + + // Scroll to the top of the ancestor scrollable. + ancestorScrollable.animateTo( + ancestorScrollable.minScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return true; +} + +/// Scrolls to the end of the textfield. +/// +/// In absence of scrollable content within textfield, tries to scroll the ancestor +/// scrollable to its end. +/// +/// Returns `true` if the scroll is performed, otherwise false. +bool _scrollToEndOfDocument({ + required SuperTextFieldContext textFieldContext, +}) { + final TextFieldScroller textFieldScroller = textFieldContext.scroller; + final ScrollPosition? ancestorScrollable = + textFieldContext.textFieldBuildContext.findAncestorScrollableWithVerticalScroll?.position; + + if (textFieldScroller.maxScrollExtent == 0 && ancestorScrollable == null) { + // The text field doesn't have any scrollable content. There is no ancestor + // scrollable to scroll. Fizzle. + return false; + } + + if (textFieldScroller.scrollOffset < textFieldScroller.maxScrollExtent) { + // The text field has more content than can fit, and the text field is partially + // scrolled upward. Scroll back to the bottom of the text field. + textFieldScroller.animateTo( + textFieldScroller.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return true; + } + + if (ancestorScrollable == null) { + // There is no ancestor scrollable to scroll. Fizzle. + return false; + } + + if (!ancestorScrollable.maxScrollExtent.isFinite) { + // We want to scroll to the end of the ancestor scrollable, but it's infinitely long, + // so we can't. Fizzle. + return false; + } + + // Scroll to the end of the ancestor scrollable. + ancestorScrollable.animateTo( + ancestorScrollable.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return true; +} + +/// Scrolls up textfield by viewport height. +/// +/// In absence of scrollable content within textfield, tries to scroll the ancestor +/// scrollable up by its viewport height. +/// +/// Returns `true` if the scroll is performed, otherwise false. +bool _scrollPageUp({ + required SuperTextFieldContext textFieldContext, +}) { + final TextFieldScroller textFieldScroller = textFieldContext.scroller; + final ScrollPosition? ancestorScrollable = + textFieldContext.textFieldBuildContext.findAncestorScrollableWithVerticalScroll?.position; + + if (textFieldScroller.maxScrollExtent == 0 && ancestorScrollable == null) { + // No scrollable content within `SuperDesktopField` and ancestor scrollable + // is absent, give other handlers opportunity to handle the key event. + return false; + } + + if (textFieldScroller.scrollOffset > 0) { + // The text field has more content than can fit. Scroll up text field by viewport height. + textFieldScroller.animateTo( + max( + textFieldScroller.scrollOffset - textFieldScroller.viewportDimension, + textFieldScroller.minScrollExtent, + ), + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + return true; + } + + if (ancestorScrollable == null) { + // There is no ancestor scrollable to scroll. Fizzle. + return false; + } + + // Scroll up ancestor scrollable by viewport height. + ancestorScrollable.animateTo( + max(ancestorScrollable.pixels - ancestorScrollable.viewportDimension, ancestorScrollable.minScrollExtent), + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return true; +} + +/// Scrolls down textfield by viewport height. +/// +/// In absence of scrollable content within textfield, tries to scroll the ancestor +/// scrollable down by its viewport height. +/// +/// Returns `true` if the scroll is performed, otherwise false. +bool _scrollPageDown({ + required SuperTextFieldContext textFieldContext, +}) { + final TextFieldScroller textFieldScroller = textFieldContext.scroller; + final ScrollPosition? ancestorScrollable = + textFieldContext.textFieldBuildContext.findAncestorScrollableWithVerticalScroll?.position; + + if (textFieldScroller.maxScrollExtent == 0 && ancestorScrollable == null) { + // No scrollable content within `SuperDesktopField` and ancestor scrollable + // is absent, give other handlers opportunity to handle the key event. + return false; + } + + if (textFieldScroller.scrollOffset < textFieldScroller.maxScrollExtent) { + // The text field has more content than can fit. Scroll down text field by viewport height. + textFieldScroller.animateTo( + min( + textFieldScroller.scrollOffset + textFieldScroller.viewportDimension, + textFieldScroller.maxScrollExtent, + ), + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + return true; + } + + if (ancestorScrollable == null) { + // There is no ancestor scrollable to scroll. Fizzle. + return false; + } + + // Scroll down ancestor scrollable by viewport height. + ancestorScrollable.animateTo( + min(ancestorScrollable.pixels + ancestorScrollable.viewportDimension, ancestorScrollable.maxScrollExtent), + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return true; +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_controller.dart b/super_editor/lib/src/super_textfield/infrastructure/attributed_text_editing_controller.dart similarity index 88% rename from super_editor/lib/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_controller.dart rename to super_editor/lib/src/super_textfield/infrastructure/attributed_text_editing_controller.dart index 0f18113e8a..94f100010f 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_controller.dart +++ b/super_editor/lib/src/super_textfield/infrastructure/attributed_text_editing_controller.dart @@ -33,7 +33,7 @@ class AttributedTextEditingController with ChangeNotifier { } bool isSelectionWithinTextBounds(TextSelection selection) { - return selection.start <= text.text.length && selection.end <= text.text.length; + return selection.start <= text.length && selection.end <= text.length; } /// Updates the [composingAttributions] based on the current [selection] @@ -49,7 +49,7 @@ class AttributedTextEditingController with ChangeNotifier { _composingAttributions ..clear() ..addAll(text.getAllAttributionsThroughout( - SpanRange(start: selection.start, end: selection.end), + selection.toSpanRange(), )); } } @@ -119,7 +119,7 @@ class AttributedTextEditingController with ChangeNotifier { for (final attribution in attributions) { _text.toggleAttribution( attribution, - SpanRange(start: selection.start, end: selection.end - 1), + SpanRange(selection.start, selection.end - 1), ); } @@ -133,7 +133,7 @@ class AttributedTextEditingController with ChangeNotifier { } _text.clearAttributions( - SpanRange(start: selection.start, end: selection.end - 1), + SpanRange(selection.start, selection.end - 1), ); notifyListeners(); @@ -152,6 +152,10 @@ class AttributedTextEditingController with ChangeNotifier { required AttributedText text, required TextSelection selection, }) { + if (text == _text && selection == _selection) { + return; + } + _switchText(text); _selection = selection; @@ -168,10 +172,10 @@ class AttributedTextEditingController with ChangeNotifier { // Ensure that the existing selection does not overshoot // the end of the new text value - if (_selection.end > _text.text.length) { + if (_selection.end > _text.length) { _selection = _selection.copyWith( - baseOffset: _selection.affinity == TextAffinity.downstream ? _selection.baseOffset : _text.text.length, - extentOffset: _selection.affinity == TextAffinity.downstream ? _text.text.length : _selection.extentOffset, + baseOffset: _selection.affinity == TextAffinity.downstream ? _selection.baseOffset : _text.length, + extentOffset: _selection.affinity == TextAffinity.downstream ? _text.length : _selection.extentOffset, ); } @@ -277,7 +281,7 @@ class AttributedTextEditingController with ChangeNotifier { final updatedSelection = _moveSelectionForInsertion( selection: selection, insertIndex: selection.extentOffset, - newTextLength: text.text.length, + newTextLength: text.length, ); update( @@ -341,7 +345,7 @@ class AttributedTextEditingController with ChangeNotifier { _moveSelectionForInsertion( selection: _selection, insertIndex: insertIndex, - newTextLength: newText.text.length, + newTextLength: newText.length, ); update( @@ -425,7 +429,7 @@ class AttributedTextEditingController with ChangeNotifier { startOffset: selection.baseOffset, ); final updatedSelection = TextSelection.collapsed( - offset: selection.baseOffset + attributedReplacementText.text.length, + offset: selection.baseOffset + attributedReplacementText.length, ); update( @@ -487,7 +491,7 @@ class AttributedTextEditingController with ChangeNotifier { newSelection ?? _moveSelectionForDeletion(selection: selection, deleteFrom: from, deleteTo: to); updatedText = updatedText.insert(textToInsert: newText, startOffset: from); updatedSelection = newSelection ?? - _moveSelectionForInsertion(selection: updatedSelection, insertIndex: from, newTextLength: newText.text.length); + _moveSelectionForInsertion(selection: updatedSelection, insertIndex: from, newTextLength: newText.length); text = updatedText; selection = updatedSelection; @@ -511,10 +515,12 @@ class AttributedTextEditingController with ChangeNotifier { return; } + final previousCharacterOffset = getCharacterStartBounds(_text.toPlainText(), selection.extentOffset); + delete( - from: selection.extentOffset - 1, + from: previousCharacterOffset, to: selection.extentOffset, - newSelection: TextSelection.collapsed(offset: selection.extentOffset - 1), + newSelection: TextSelection.collapsed(offset: previousCharacterOffset), newComposingRegion: newComposingRegion, ); } @@ -529,13 +535,15 @@ class AttributedTextEditingController with ChangeNotifier { return; } - if (selection.extentOffset >= text.text.length) { + if (selection.extentOffset >= text.length) { return; } + final nextCharacterOffset = getCharacterEndBounds(_text.toPlainText(), selection.extentOffset); + delete( from: selection.extentOffset, - to: selection.extentOffset + 1, + to: nextCharacterOffset, newSelection: TextSelection.collapsed(offset: selection.extentOffset), newComposingRegion: newComposingRegion, ); @@ -583,7 +591,7 @@ class AttributedTextEditingController with ChangeNotifier { text: updatedText, selection: updatedSelection, ); - + _updateComposingAttributions(); // TODO: do we need to implement composing region update behavior like selections? composingRegion = newComposingRegion ?? TextRange.empty; @@ -625,6 +633,13 @@ class AttributedTextEditingController with ChangeNotifier { TextSelection? selection, TextRange? composingRegion, }) { + if ((text == null || text == _text) && + (selection == null || selection == _selection) && + (composingRegion == null || composingRegion == _composingRegion)) { + // The updated values are the same as existing values. Do nothing. + return; + } + if (text != null) { _switchText(text); } @@ -640,15 +655,36 @@ class AttributedTextEditingController with ChangeNotifier { notifyListeners(); } + @Deprecated('Use text.computeInlineSpan() instead, which adds support for inline widgets.') TextSpan buildTextSpan(AttributionStyleBuilder styleBuilder) { return text.computeTextSpan(styleBuilder); } - void clear() { + /// Clears the text, composing attributions, composing region, and moves + /// the collapsed selection to the start of the now empty text controller. + void clearText() { + _text = AttributedText(); + _selection = const TextSelection.collapsed(offset: 0); + _composingAttributions.clear(); + _composingRegion = TextRange.empty; + + notifyListeners(); + } + + /// Clears the text, selection, composing attributions, and composing region. + void clearTextAndSelection() { _text = AttributedText(); _selection = const TextSelection.collapsed(offset: -1); _composingAttributions.clear(); _composingRegion = TextRange.empty; + + notifyListeners(); + } + + /// Clears the text, selection, composing attributions, and composing region. + @Deprecated('This will be removed in a future release. Use clearText or clearTextAndSelection instead') + void clear() { + clearTextAndSelection(); } //------ START: Methods moved here from extension methods --------- @@ -659,7 +695,7 @@ class AttributedTextEditingController with ChangeNotifier { } Clipboard.setData(ClipboardData( - text: selection.textInside(text.text), + text: selection.textInside(text.toPlainText()), )); } @@ -684,7 +720,7 @@ class AttributedTextEditingController with ChangeNotifier { void selectAll() { selection = TextSelection( baseOffset: 0, - extentOffset: text.text.length, + extentOffset: text.length, ); } @@ -737,7 +773,7 @@ class AttributedTextEditingController with ChangeNotifier { } if (movementModifier == MovementModifier.word) { - final plainText = text.text; + final plainText = text.toPlainText(); int newExtent = selection.extentOffset; newExtent -= 1; // we always want to jump at least 1 character. @@ -752,7 +788,7 @@ class AttributedTextEditingController with ChangeNotifier { return; } - final newExtent = text.text.moveOffsetUpstreamByCharacter(selection.extentOffset) ?? 0; + final newExtent = text.toPlainText().moveOffsetUpstreamByCharacter(selection.extentOffset) ?? 0; selection = TextSelection( baseOffset: expandSelection ? selection.baseOffset : newExtent, extentOffset: newExtent, @@ -772,7 +808,7 @@ class AttributedTextEditingController with ChangeNotifier { return; } - if (selection.extentOffset >= text.text.length) { + if (selection.extentOffset >= text.length) { // Can't move further right. return; } @@ -780,8 +816,8 @@ class AttributedTextEditingController with ChangeNotifier { if (movementModifier == MovementModifier.line) { final endOfLine = textLayout.getPositionAtEndOfLine(TextPosition(offset: selection.extentOffset)); - final endPosition = TextPosition(offset: text.text.length); - final plainText = text.text; + final endPosition = TextPosition(offset: text.length); + final plainText = text.toPlainText(); // Note: we compare offset values because we don't care if the affinitys are equal final isAutoWrapLine = endOfLine.offset != endPosition.offset && (plainText[endOfLine.offset] != '\n'); @@ -809,7 +845,7 @@ class AttributedTextEditingController with ChangeNotifier { if (movementModifier == MovementModifier.word) { final extentPosition = selection.extent; - final plainText = text.text; + final plainText = text.toPlainText(); int newExtent = extentPosition.offset; newExtent += 1; // we always want to jump at least 1 character. @@ -824,7 +860,7 @@ class AttributedTextEditingController with ChangeNotifier { return; } - final newExtent = text.text.moveOffsetDownstreamByCharacter(selection.extentOffset) ?? text.text.length; + final newExtent = text.toPlainText().moveOffsetDownstreamByCharacter(selection.extentOffset) ?? text.length; selection = TextSelection( baseOffset: expandSelection ? selection.baseOffset : newExtent, extentOffset: newExtent, @@ -849,7 +885,7 @@ class AttributedTextEditingController with ChangeNotifier { // If there is no line below the current selection, move selection // to the end of the available text. - newExtent ??= text.text.length; + newExtent ??= text.length; } selection = TextSelection( @@ -886,18 +922,18 @@ class AttributedTextEditingController with ChangeNotifier { if (direction == TextAffinity.upstream) { // Delete the character before the caret deleteEndIndex = selection.extentOffset; - deleteStartIndex = getCharacterStartBounds(text.text, deleteEndIndex); + deleteStartIndex = getCharacterStartBounds(text.toPlainText(), deleteEndIndex); } else { // Delete the character after the caret deleteStartIndex = selection.extentOffset; - deleteEndIndex = getCharacterEndBounds(text.text, deleteStartIndex); + deleteEndIndex = getCharacterEndBounds(text.toPlainText(), deleteStartIndex); } - text = text.removeRegion( - startOffset: deleteStartIndex, - endOffset: deleteEndIndex, + delete( + from: deleteStartIndex, + to: deleteEndIndex, + newSelection: TextSelection.collapsed(offset: deleteStartIndex), ); - selection = TextSelection.collapsed(offset: deleteStartIndex); } void deleteTextOnLineBeforeCaret({ @@ -916,17 +952,33 @@ class AttributedTextEditingController with ChangeNotifier { } } + void deleteTextOnLineAfterCaret({ + required ProseTextLayout textLayout, + }) { + assert(selection.isCollapsed); + + final endOfLinePosition = textLayout.getPositionAtEndOfLine(selection.extent); + selection = TextSelection( + baseOffset: selection.extentOffset, + extentOffset: endOfLinePosition.offset, + ); + + if (!selection.isCollapsed) { + deleteSelectedText(); + } + } + void deleteSelectedText() { assert(!selection.isCollapsed); final deleteStartIndex = selection.start; final deleteEndIndex = selection.end; - text = text.removeRegion( - startOffset: deleteStartIndex, - endOffset: deleteEndIndex, + delete( + from: deleteStartIndex, + to: deleteEndIndex, + newSelection: TextSelection.collapsed(offset: deleteStartIndex), ); - selection = TextSelection.collapsed(offset: deleteStartIndex); } void insertNewline() { diff --git a/super_editor/lib/src/super_textfield/infrastructure/fill_width_if_constrained.dart b/super_editor/lib/src/super_textfield/infrastructure/fill_width_if_constrained.dart new file mode 100644 index 0000000000..b7ae2e500e --- /dev/null +++ b/super_editor/lib/src/super_textfield/infrastructure/fill_width_if_constrained.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// Widget that constrains its [child]s width in different ways depending on the +/// incoming width constraint. +/// +/// Rules: +/// * If the constraints from the parent has a constrained width, then the [child] +/// is forced to be EXACTLY as wide the incoming max width. +/// * If the constraints from the parent has an unbounded width, and if there's an +/// ancestor `Scrollable`, then the [child] is forced to be AT LEAST as wide as the +/// Viewport of the `Scrollable`. +/// * If neither of the above two rules apply, the [child]'s width is set to its +/// intrinsic width. This implies that any provided [child] must have an intrinsic +/// width. +/// +/// This widget is used to correctly align the text of a multiline [SuperText] with +/// a constrained width. It's also used to constrain and align single-line text within +/// a horizontal scrollable. +class FillWidthIfConstrained extends SingleChildRenderObjectWidget { + const FillWidthIfConstrained({ + required Widget child, + }) : super(child: child); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderFillWidthIfConstrained( + findAncestorScrollableWidth: _createViewportWidthLookup(context), + ); + } + + @override + void updateRenderObject(BuildContext context, RenderFillWidthIfConstrained renderObject) { + renderObject.findAncestorScrollableWidth = _createViewportWidthLookup(context); + } + + double? Function() _createViewportWidthLookup(BuildContext context) { + return () { + return _getViewportWidth(context); + }; + } + + double? _getViewportWidth(BuildContext context) { + final scrollable = Scrollable.maybeOf(context); + if (scrollable == null) { + return null; + } + + final direction = scrollable.axisDirection; + // We only need to specify the width if we are inside a horizontal scrollable, + // because in this case we might have an infinity maxWidth. + if (direction == AxisDirection.up || direction == AxisDirection.down) { + return null; + } + return (scrollable.context.findRenderObject() as RenderBox?)?.constraints.maxWidth; + } +} + +class RenderFillWidthIfConstrained extends RenderProxyBox { + RenderFillWidthIfConstrained({ + required double? Function() findAncestorScrollableWidth, + }) : _findAncestorScrollableWidth = findAncestorScrollableWidth; + + /// Informs this [RenderFillWidthIfConstrained] about the width of an ancestor [Scrollable], + /// which may be used to set the width of the [child] `RenderObject`. + set findAncestorScrollableWidth(double? Function() value) { + _findAncestorScrollableWidth = value; + markNeedsLayout(); + } + + double? Function() _findAncestorScrollableWidth; + + @override + void performLayout() { + BoxConstraints childConstraints = constraints; + + final ancestorViewportWidth = _findAncestorScrollableWidth(); + + if (constraints.hasBoundedWidth) { + // The available width is bounded, force the child to be as wide + // as the available width. + childConstraints = BoxConstraints( + minWidth: constraints.maxWidth, + maxWidth: constraints.maxWidth, + minHeight: constraints.minHeight, + maxHeight: constraints.maxHeight, + ); + } else if (ancestorViewportWidth != null && ancestorViewportWidth < double.infinity) { + // The available width is unbounded and we're inside of a Scrollable. + // Make the child at least as wide as the Scrollable viewport. + childConstraints = BoxConstraints( + minWidth: ancestorViewportWidth, + minHeight: constraints.minHeight, + maxHeight: constraints.maxHeight, + ); + } + + child!.layout(childConstraints, parentUsesSize: true); + size = child!.size; + } +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/hint_text.dart b/super_editor/lib/src/super_textfield/infrastructure/hint_text.dart similarity index 100% rename from super_editor/lib/src/infrastructure/super_textfield/infrastructure/hint_text.dart rename to super_editor/lib/src/super_textfield/infrastructure/hint_text.dart diff --git a/super_editor/lib/src/super_textfield/infrastructure/magnifier.dart b/super_editor/lib/src/super_textfield/infrastructure/magnifier.dart new file mode 100644 index 0000000000..03c79cc00e --- /dev/null +++ b/super_editor/lib/src/super_textfield/infrastructure/magnifier.dart @@ -0,0 +1,113 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +/// A magnifying glass that enlarges the content beneath it. +/// +/// Magnifies the content beneath this [MagnifyingGlass] at a level of +/// [magnificationScale] and displays that content in a [shape] of +/// the given [size]. +/// +/// By default, [MagnifyingGlass] expects to be placed directly on top +/// of the content that it magnifies. Due to the way that magnification +/// works, if [MagnifyingGlass] is displayed with an offset from the +/// content that it magnifies, that offset must be provided as +/// [offsetFromFocalPoint]. +/// +/// [MagnifyingGlass] was designed to operate across the entire screen. +/// Using a [MagnifyingGlass] in a confined region may result in the +/// magnifier mis-aligning the content that is magnifies. +class MagnifyingGlass extends StatelessWidget { + const MagnifyingGlass({ + Key? key, + this.offsetFromFocalPoint = Offset.zero, + required this.shape, + required this.size, + required this.magnificationScale, + }) : super(key: key); + + /// The offset from where the magnification is applied, to where this + /// magnifier is displayed, in density independent pixels. + /// + /// An [offsetFromFocalPoint] of `Offset.zero` would indicate that this + /// [MagnifyingGlass] is displayed directly over the point of magnification. + final Offset offsetFromFocalPoint; + + /// The shape of the magnifying glass. + final ShapeBorder shape; + + /// The size of the magnifying glass. + final Size size; + + /// The level of magnification applied to the content beneath this + /// [MagnifyingGlass], expressed as a multiple of the natural dimensions. + final double magnificationScale; + + @override + Widget build(BuildContext context) { + return ClipPath.shape( + shape: shape, + child: BackdropFilter( + filter: _createMagnificationFilter(), + child: SizedBox.fromSize( + size: size, + ), + ), + ); + } + + ImageFilter _createMagnificationFilter() { + // When displayed without scaling, the content inside the magnifier looks + // like this: + // ________________ + // | | + // | center | + // |________________| + // + // Applying scaling causes the content to grow outward shifting the center + // away from the magnifier's center, like this: + // ________________ + // | | + // | | + // |_________c e n t|e r + // + // To correct this, we shift the content in the opposite direction before scaling, + // so it appears like this before scaling: + // ________________ + // |center | + // | | + // |________________| + // + // After scaling, the content shifts again due to the scaling effect. However, + // the pre-shift ensures that the center of the content aligns correctly within + // the magnifier, like this: + // ________________ + // | | + // | c e n t e r | + // |________________| + // + final magnifierMatrix = Matrix4.identity() + // Calculate the extra size introduced by scaling and move the content + // back by half of that amount. + // + // For example: + // + // If the magnifier is 133px wide with a magnification scale of 1.5, + // the scaled width will be: + // 133px * 1.5 = 199.5px. + // + // The width increases by 66.5px in total. Since the growth is symmetric, + // we shift the content left by half the increase (66.5px / 2 = 33.25px) + // to re-center it under the magnifier after the scaling. + ..translate( + -(size.width * magnificationScale - size.width) / 2, + -(size.height * magnificationScale - size.height) / 2, + ) + // Apply the scaling transformation to magnify the content. + ..scale(magnificationScale, magnificationScale) + // Move the content to the center of where the app wants to + // display the magnifier. + ..translate(offsetFromFocalPoint.dx, offsetFromFocalPoint.dy); + return ImageFilter.matrix(magnifierMatrix.storage); + } +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/outer_box_shadow.dart b/super_editor/lib/src/super_textfield/infrastructure/outer_box_shadow.dart similarity index 100% rename from super_editor/lib/src/infrastructure/super_textfield/infrastructure/outer_box_shadow.dart rename to super_editor/lib/src/super_textfield/infrastructure/outer_box_shadow.dart diff --git a/super_editor/lib/src/super_textfield/infrastructure/text_field_border.dart b/super_editor/lib/src/super_textfield/infrastructure/text_field_border.dart new file mode 100644 index 0000000000..27985aab0b --- /dev/null +++ b/super_editor/lib/src/super_textfield/infrastructure/text_field_border.dart @@ -0,0 +1,79 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// A border that displays with different colors based on common text field states, e.g., +/// non-focused, focused, error. +/// +/// The border visuals are chosen by a provided [borderBuilder]. The border state is refreshed, +/// and the [borderBuilder] is re-run, every time focus changes, or the error state changes. +class TextFieldBorder extends StatelessWidget { + const TextFieldBorder({ + super.key, + required this.focusNode, + this.hasError, + required this.borderBuilder, + this.clipBehavior = Clip.none, + required this.child, + }); + + /// The [FocusNode] associated with the [child] text field. + final FocusNode focusNode; + + /// Whether the [child] text field is currently in an error state. + final ValueListenable? hasError; + + /// Creates a visual border decoration based on a given [TextFieldBorderState]. + final TextFieldBorderBuilder borderBuilder; + + /// Clipping strategy, which defaults to [Clip.none], and can be used to clip [child] + /// text field content when rounded corners are used for the border. + final Clip clipBehavior; + + /// The widget subtree that displays a text field, to which this border applies. + final Widget child; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: hasError ?? ValueNotifier(false), + builder: (context, hasError, child) { + return ListenableBuilder( + listenable: focusNode, + builder: (context, child) { + return Container( + decoration: borderBuilder(_borderState), + clipBehavior: clipBehavior, + child: child, + ); + }, + child: child, + ); + }, + child: child, + ); + } + + TextFieldBorderState get _borderState => TextFieldBorderState( + hasFocus: focusNode.hasFocus, + hasPrimaryFocus: focusNode.hasPrimaryFocus, + hasError: hasError?.value ?? false, + ); +} + +/// Properties that might impact the visual appearance of a text field border. +/// +/// [TextFieldBorder] provides a [TextFieldBorderState] to a [TextFieldBorderBuilder] +/// to create the desired visual border for a text field. +class TextFieldBorderState { + const TextFieldBorderState({ + required this.hasFocus, + required this.hasPrimaryFocus, + required this.hasError, + }); + + final bool hasFocus; + final bool hasPrimaryFocus; + final bool hasError; +} + +typedef TextFieldBorderBuilder = BoxDecoration Function(TextFieldBorderState borderState); diff --git a/super_editor/lib/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart b/super_editor/lib/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart new file mode 100644 index 0000000000..9afa65afdf --- /dev/null +++ b/super_editor/lib/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart @@ -0,0 +1,70 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/super_text_field.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +/// Tap handler that can (optionally) respond to single, double, and triple taps, as well as dictate the cursor +/// appearance on desktop. +abstract class SuperTextFieldTapHandler { + MouseCursor? mouseCursorForContentHover(SuperTextFieldGestureDetails details) => null; + + TapHandlingInstruction onTapDown(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTapUp(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTapCancel() => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onDoubleTapDown(SuperTextFieldGestureDetails details) => + TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onDoubleTapUp(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onDoubleTapCancel() => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTripleTapDown(SuperTextFieldGestureDetails details) => + TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTripleTapUp(SuperTextFieldGestureDetails details) => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onTripleTapCancel() => TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onSecondaryTapDown(SuperTextFieldGestureDetails details) => + TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onSecondaryTapUp(SuperTextFieldGestureDetails details) => + TapHandlingInstruction.continueHandling; + + TapHandlingInstruction onSecondaryTapCancel() => TapHandlingInstruction.continueHandling; +} + +/// Information about a gesture that happened within a [SuperTextField]. +class SuperTextFieldGestureDetails { + SuperTextFieldGestureDetails({ + required this.textLayout, + required this.textController, + required this.globalOffset, + required this.layoutOffset, + required this.textOffset, + }); + + /// The text layout of the text field. + /// + /// It can be used to pull information about the logical position + /// where the tap occurred. For example, to find the [TextPosition] + /// that is nearest to the tap. + final ProseTextLayout textLayout; + + /// The controller that holds the current text and selection of the text field. + /// It can be used to pull information about the text and its attributions. + final AttributedTextEditingController textController; + + /// The position of the gesture in global coordinates. + final Offset globalOffset; + + /// The position of the gesture in [SuperTextField]'s coordinate space. This + /// coordinate space contains the text layout and the padding around the text. + final Offset layoutOffset; + + /// The position of the gesture in the text coordinate space. + final Offset textOffset; +} diff --git a/super_editor/lib/src/super_textfield/infrastructure/text_field_scroller.dart b/super_editor/lib/src/super_textfield/infrastructure/text_field_scroller.dart new file mode 100644 index 0000000000..9ff327e692 --- /dev/null +++ b/super_editor/lib/src/super_textfield/infrastructure/text_field_scroller.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; + +/// Scrolling status and controls within a text field. +class TextFieldScroller { + /// The height of a vertically scrolling viewport, or the width of a horizontally + /// scrolling viewport. + double get viewportDimension => _scrollController!.position.viewportDimension; + + /// The smallest possible scrolling offset, which is usually zero. + double get minScrollExtent => _scrollController!.position.minScrollExtent; + + /// The maximum possible scrolling offset, at which point the end of the scrolling + /// content is visible in the viewport. + double get maxScrollExtent => _scrollController!.position.maxScrollExtent; + + /// The current scroll offset in the viewport, which is represented by the number + /// of pixels between the top-left corner of the viewport, and the top-left corner + /// of the content that sits inside the viewport. + double get scrollOffset => _scrollController!.offset; + + /// Immediately moves the [scrollOffset] to [newScrollOffset]. + void jumpTo(double newScrollOffset) { + _scrollController!.jumpTo(newScrollOffset); + } + + /// Immediately moves the [scrollOffset] by [delta] pixels. + void jumpBy(double delta) { + _scrollController!.jumpTo(_scrollController!.offset + delta); + } + + /// Animates [scrollOffset] from its current offset to [to], over the given [duration] + /// of time, following the given animation [curve]. + void animateTo( + double to, { + required Duration duration, + Curve curve = Curves.easeInOut, + }) { + _scrollController!.animateTo(to, duration: duration, curve: curve); + } + + ScrollController? _scrollController; + + void attach(ScrollController scrollController) { + _scrollController = scrollController; + } + + void detach() { + _scrollController = null; + } +} diff --git a/super_editor/lib/src/super_textfield/infrastructure/text_field_tap_handlers.dart b/super_editor/lib/src/super_textfield/infrastructure/text_field_tap_handlers.dart new file mode 100644 index 0000000000..427eb4d52f --- /dev/null +++ b/super_editor/lib/src/super_textfield/infrastructure/text_field_tap_handlers.dart @@ -0,0 +1,46 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/links.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A [SuperTextFieldTapHandler] that opens links when the user taps text with +/// a [LinkAttribution]. +class SuperTextFieldLaunchLinkTapHandler extends SuperTextFieldTapHandler { + @override + MouseCursor? mouseCursorForContentHover(SuperTextFieldGestureDetails details) { + final linkAttribution = _getLinkAttribution(details); + if (linkAttribution == null) { + return null; + } + + return SystemMouseCursors.click; + } + + @override + TapHandlingInstruction onTapUp(SuperTextFieldGestureDetails details) { + final linkAttribution = _getLinkAttribution(details); + if (linkAttribution == null) { + return TapHandlingInstruction.continueHandling; + } + + UrlLauncher.instance.launchUrl( + linkAttribution.launchableUri, + ); + + return TapHandlingInstruction.halt; + } + + /// Returns the [LinkAttribution] at the given [details.textOffset], if any. + LinkAttribution? _getLinkAttribution(SuperTextFieldGestureDetails details) { + final textPosition = details.textLayout.getPositionNearestToOffset(details.textOffset); + + final attributions = details.textController.text // + .getAllAttributionsAt(textPosition.offset) + .whereType(); + + if (attributions.isEmpty) { + return null; + } + + return attributions.first; + } +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/text_scrollview.dart b/super_editor/lib/src/super_textfield/infrastructure/text_scrollview.dart similarity index 51% rename from super_editor/lib/src/infrastructure/super_textfield/infrastructure/text_scrollview.dart rename to super_editor/lib/src/super_textfield/infrastructure/text_scrollview.dart index e6e1a89b80..f2313e00e4 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/text_scrollview.dart +++ b/super_editor/lib/src/super_textfield/infrastructure/text_scrollview.dart @@ -1,17 +1,23 @@ +import 'dart:math'; +import 'dart:ui'; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/geometry.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; final _log = scrollingTextFieldLog; /// A scrollable that positions its [child] based on text metrics. /// -/// The [child] must contain a [SuperSelectableText] in its tree, -/// [textKey] must refer to that [SuperSelectableText], and the +/// The [child] must contain a [SuperText] in its tree, +/// [textKey] must refer to that [SuperText], and the /// dimensions of the [child] subtree should match the dimensions -/// of the [SuperSelectableText] so that there are no surprises +/// of the [SuperText] so that there are no surprises /// when the scroll offset is configured based on where a given /// character appears in the [child] layout. /// @@ -115,9 +121,6 @@ class _TextScrollViewState extends State final _scrollController = ScrollController(); - bool _needViewportHeight = true; - double? _viewportHeight; - @override void initState() { super.initState(); @@ -126,7 +129,7 @@ class _TextScrollViewState extends State ..delegate = this ..addListener(_onTextScrollChange); - widget.textEditingController.addListener(_scheduleViewportHeightUpdate); + widget.textEditingController.addListener(_onTextOrSelectionChanged); } @override @@ -144,16 +147,8 @@ class _TextScrollViewState extends State } if (widget.textEditingController != oldWidget.textEditingController) { - oldWidget.textEditingController.removeListener(_scheduleViewportHeightUpdate); - widget.textEditingController.addListener(_scheduleViewportHeightUpdate); - - _scheduleViewportHeightUpdate(); - } - - if (widget.minLines != oldWidget.minLines || - widget.maxLines != oldWidget.maxLines || - widget.lineHeight != oldWidget.lineHeight) { - _scheduleViewportHeightUpdate(); + oldWidget.textEditingController.removeListener(_onTextOrSelectionChanged); + widget.textEditingController.addListener(_onTextOrSelectionChanged); } } @@ -163,7 +158,7 @@ class _TextScrollViewState extends State ..delegate = null ..removeListener(_onTextScrollChange); - widget.textEditingController.removeListener(_scheduleViewportHeightUpdate); + widget.textEditingController.removeListener(_onTextOrSelectionChanged); super.dispose(); } @@ -188,6 +183,19 @@ class _TextScrollViewState extends State return renderBox.size.height; } + @override + Offset get textLayoutOffsetInViewport { + final viewportBox = context.findRenderObject() as RenderBox; + final textContentBox = widget.textKey.currentContext!.findRenderObject() as RenderBox; + final textOffsetInViewport = textContentBox.localToGlobal(Offset.zero, ancestor: viewportBox); + + if (isMultiline) { + return textOffsetInViewport.translate(0, _scrollController.offset); + } else { + return textOffsetInViewport.translate(_scrollController.offset, 0); + } + } + @override bool get isMultiline => widget.maxLines == null || widget.maxLines! > 1; @@ -198,14 +206,18 @@ class _TextScrollViewState extends State @override double get endScrollOffset { + final viewportWidth = this.viewportWidth; final viewportHeight = this.viewportHeight; - if (viewportHeight == null) { + if (viewportWidth == null || viewportHeight == null) { return 0; } - final lastCharacterPosition = TextPosition(offset: widget.textEditingController.text.text.length - 1); - return (_textLayout.getCharacterBox(lastCharacterPosition)?.bottom ?? _textLayout.estimatedLineHeight) - - viewportHeight; + final lastCharacterPosition = TextPosition(offset: widget.textEditingController.text.length - 1); + return isMultiline + ? (_textLayout.getCharacterBox(lastCharacterPosition)?.bottom ?? _textLayout.estimatedLineHeight) - + viewportHeight + + (widget.padding?.vertical ?? 0.0) + : _scrollController.position.maxScrollExtent; } @override @@ -228,7 +240,15 @@ class _TextScrollViewState extends State return false; } - final offsetInViewport = _textLayout.getOffsetAtPosition(position) - Offset(_scrollController.offset, 0); + // Find where the text sits from the edges of the viewport. This calculation implicitly + // includes any padding around the content, as well as the current scroll offset. + final viewportBox = context.findRenderObject() as RenderBox; + final textContentBox = widget.textKey.currentContext!.findRenderObject() as RenderBox; + final textOffsetInViewport = textContentBox.localToGlobal(Offset.zero, ancestor: viewportBox); + + // Find the offset of the text position within the viewport. + final offsetInViewport = textOffsetInViewport + _textLayout.getOffsetAtPosition(position); + // Round the top/bottom values to avoid false negatives due to floating point accuracy. return offsetInViewport.dx.round() >= 0 && offsetInViewport.dx.round() <= viewportWidth; } @@ -236,35 +256,54 @@ class _TextScrollViewState extends State @override bool isInAutoScrollToStartRegion(Offset offsetInViewport) { + return calculateDistanceBeyondStartingAutoScrollBoundary(offsetInViewport) > 0; + } + + @override + double calculateDistanceBeyondStartingAutoScrollBoundary(Offset offsetInViewport) { if (isMultiline) { - return offsetInViewport.dy <= _mulitlineFieldAutoScrollGap; + return max(_mulitlineFieldAutoScrollGap - offsetInViewport.dy, 0).abs().toDouble(); } else { - return offsetInViewport.dx <= _singleLineFieldAutoScrollGap; + return max(_singleLineFieldAutoScrollGap - offsetInViewport.dx, 0).abs().toDouble(); } } @override bool isInAutoScrollToEndRegion(Offset offsetInViewport) { + return calculateDistanceBeyondEndingAutoScrollBoundary(offsetInViewport) > 0; + } + + @override + double calculateDistanceBeyondEndingAutoScrollBoundary(Offset offsetInViewport) { if (isMultiline) { final viewportHeight = this.viewportHeight; if (viewportHeight == null) { - return false; + return 0; } - return offsetInViewport.dy >= viewportHeight - _mulitlineFieldAutoScrollGap; + return max(offsetInViewport.dy - (viewportHeight - _mulitlineFieldAutoScrollGap), 0); } else { final viewportWidth = this.viewportWidth; if (viewportWidth == null) { - return false; + return 0; } - return offsetInViewport.dx >= viewportWidth - _singleLineFieldAutoScrollGap; + return max(offsetInViewport.dx - (viewportWidth - _singleLineFieldAutoScrollGap), 0); } } @override - Rect getCharacterRectAtPosition(TextPosition position) { - return _textLayout.getCharacterBox(position)?.toRect() ?? Rect.fromLTRB(0, 0, 0, _textLayout.estimatedLineHeight); + Rect getViewportCharacterRectAtPosition(TextPosition position) { + final viewportBox = context.findRenderObject() as RenderBox; + final textBox = widget.textKey.currentContext!.findRenderObject() as RenderBox; + final textOffsetInViewport = textBox.localToGlobal(Offset.zero, ancestor: viewportBox); + + final characterBoxInTextLayout = + _textLayout.getCharacterBox(position)?.toRect() ?? Rect.fromLTRB(0, 0, 0, _textLayout.estimatedLineHeight); + + // The padding is applied inside of the scrollable area, + // so we need to adjust the rect to account for it. + return characterBoxInTextLayout.translate(textOffsetInViewport.dx, textOffsetInViewport.dy); } @override @@ -283,12 +322,14 @@ class _TextScrollViewState extends State final viewportWidth = (context.findRenderObject() as RenderBox).size.width; // Note: we look for an offset that is slightly further down than zero // to avoid any issues with the layout system differentiating between lines. - final textPositionAtRightEnd = - _textLayout.getPositionNearestToOffset(Offset(viewportWidth + _scrollController.offset, 5)); - final nextPosition = textPositionAtRightEnd.offset >= widget.textEditingController.text.text.length - 1 + final textOffsetInViewport = textLayoutOffsetInViewport; + final textPositionAtRightEnd = _textLayout.getPositionNearestToOffset( + Offset(viewportWidth + _scrollController.offset + textOffsetInViewport.dx, 5), + ); + final nextPosition = textPositionAtRightEnd.offset >= widget.textEditingController.text.length - 1 ? textPositionAtRightEnd : TextPosition(offset: textPositionAtRightEnd.offset + 1); - return _textLayout.getOffsetAtPosition(nextPosition).dx; + return _textLayout.getOffsetAtPosition(nextPosition).dx + textOffsetInViewport.dx; } @override @@ -328,168 +369,32 @@ class _TextScrollViewState extends State } } - /// Returns true if the viewport height changed, false otherwise. - bool _updateViewportHeight() { - _log.finer('Updating viewport height...'); - - final linesOfText = _getLineCount(); - _log.finer(' - lines of text: $linesOfText'); - - late double estimatedLineHeight; - if (widget.lineHeight != null) { - _log.finer(' - explicit line height provided: ${widget.lineHeight}'); - // Use the line height that was explicitly provided by the widget. - estimatedLineHeight = widget.lineHeight!; - } else { - _log.finer(' - calculating an estimated line height for the field'); - // No line height was provided. Calculate a best-guess. - final textLayout = widget.textKey.currentState!.textLayout; - estimatedLineHeight = textLayout.getLineHeightAtPosition(const TextPosition(offset: 0)); - _log.finer(' - line height at position 0: $estimatedLineHeight'); - - // We got 0.0 for the line height at the beginning of text. Maybe the - // text is empty. Ask the TextLayout to estimate a height for us that's - // based on the text style. - if (estimatedLineHeight == 0) { - estimatedLineHeight = widget.textKey.currentState!.textLayout.estimatedLineHeight; - _log.finer(' - estimated line height based on text styles: $estimatedLineHeight'); - } - } - - final totalVerticalPadding = widget.padding?.vertical ?? 0.0; - - final estimatedContentHeight = (linesOfText * estimatedLineHeight) + totalVerticalPadding; - _log.finer(' - estimated content height: $estimatedContentHeight'); - - final minContentHeight = widget.minLines != null // - ? widget.minLines! * estimatedLineHeight - : estimatedLineHeight; // Can't be shorter than 1 line. - - final minHeight = minContentHeight + totalVerticalPadding; - - final maxContentHeight = widget.maxLines != null // - ? (widget.maxLines! * estimatedLineHeight) // - : null; - - final maxHeight = maxContentHeight != null // - ? maxContentHeight + totalVerticalPadding - : null; - - _log.finer(' - minHeight: $minHeight, maxHeight: $maxHeight'); - - double? viewportHeight; - if (maxHeight != null && estimatedContentHeight >= maxHeight) { - _log.finer(' - setting viewport height to maxHeight'); - viewportHeight = maxHeight; - } else if (minHeight != null && estimatedContentHeight <= minHeight) { - _log.finer(' - setting viewport height to minHeight'); - viewportHeight = minHeight; - } - - if (!_needViewportHeight && viewportHeight == _viewportHeight) { - // The height of the viewport hasn't changed. Return. - _log.finer(' - viewport height hasn\'t changed'); - return false; - } - - final wantsUnboundedIntrinsicHeight = viewportHeight == null && isMultiline && !isBounded; - final multilineContentFitsMaxHeight = - viewportHeight == null && isMultiline && isBounded && estimatedContentHeight <= maxHeight!; - - if (wantsUnboundedIntrinsicHeight || multilineContentFitsMaxHeight) { - // We have either an unbounded height or our estimated content height fits inside our max height. - // The viewport should expand to fit its content. - _log.finer( - ' - viewport height is null, but TextScrollView is unbounded or the content fits max height, so that is OK'); - final didChange = viewportHeight != _viewportHeight; - if (mounted) { - setState(() { - _needViewportHeight = false; - _viewportHeight = null; - }); - } - return didChange; - } - - if (viewportHeight != null) { - setState(() { - _log.finer(' - new viewport height: $viewportHeight'); - _needViewportHeight = false; - _viewportHeight = viewportHeight; - }); - - return true; - } else { - _log.finer(' - could not calculate a viewport height. Rescheduling calculation.'); - - // We still don't have a resolved viewport height. Run again next frame. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - setState(() { - _updateViewportHeight(); - }); - } - }); - - return false; - } - } - - int _getLineCount() { - if (widget.textEditingController.text.text.isEmpty) { - return 0; - } - - if (widget.textKey.currentState == null) { - return 0; - } - - return _textLayout.getLineCount(); - } - - void _scheduleViewportHeightUpdate() { - // The viewport height is calculated using the number of the lines of text. - // Therefore, when text changes we need to recalculate the viewport height - // to accommodate the new text. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - _updateViewportHeight(); - } - }); - } - /// Returns the [ProseTextLayout] that lays out and renders the /// text in this text field. ProseTextLayout get _textLayout => widget.textKey.currentState!.textLayout; + void _onTextOrSelectionChanged() { + // After the text changes, the user might have entered new lines. + // Schedule a rebuild so our size is updated. + scheduleBuildAfterBuild(); + } + @override Widget build(BuildContext context) { - if (widget.textKey.currentContext == null || _needViewportHeight) { - // The text hasn't been laid out yet, which means our calculations - // for text height is probably wrong. Schedule a post frame callback - // to re-calculate the height after initial layout. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - _updateViewportHeight(); - } - }); - } - - return Opacity( - opacity: (widget.maxLines != null && widget.maxLines! > 1 && _viewportHeight == null && _needViewportHeight) - ? 0.0 - : 1.0, - child: SizedBox( - width: double.infinity, - height: _viewportHeight, - child: Stack( - children: [ - _buildScrollView( - child: widget.child, - ), - if (widget.showDebugPaint) ..._buildDebugScrollRegions(), - ], - ), + return _TextLinesLimiter( + textKey: widget.textKey, + scrollController: _scrollController, + minLines: widget.minLines, + maxLines: widget.maxLines, + lineHeight: widget.lineHeight, + padding: widget.padding, + child: Stack( + children: [ + _buildScrollView( + child: widget.child, + ), + if (widget.showDebugPaint) ..._buildDebugScrollRegions(), + ], ), ); } @@ -518,7 +423,11 @@ class _TextScrollViewState extends State child: SingleChildScrollView( key: _textFieldViewportKey, controller: _scrollController, - physics: const NeverScrollableScrollPhysics(), + // For single-line text fields, we do not allow horizontal scrolling, + // therefor we apply NeverScrollableScrollPhysics. For multi-line text + // fields, we pass null to allow the SingleChildScrollView to default + // to the appropriate scroll physics based on the host platform. + physics: isMultiline ? null : const NeverScrollableScrollPhysics(), scrollDirection: isMultiline ? Axis.vertical : Axis.horizontal, child: Padding( padding: widget.padding ?? EdgeInsets.zero, @@ -539,7 +448,7 @@ class _TextScrollViewState extends State child: IgnorePointer( child: Container( height: _mulitlineFieldAutoScrollGap, - color: Colors.purpleAccent.withOpacity(0.5), + color: Colors.purpleAccent.withValues(alpha: 0.5), ), ), ), @@ -550,7 +459,7 @@ class _TextScrollViewState extends State child: IgnorePointer( child: Container( height: _mulitlineFieldAutoScrollGap, - color: Colors.purpleAccent.withOpacity(0.5), + color: Colors.purpleAccent.withValues(alpha: 0.5), ), ), ), @@ -563,7 +472,7 @@ class _TextScrollViewState extends State bottom: 0, child: Container( width: _singleLineFieldAutoScrollGap, - color: Colors.purpleAccent.withOpacity(0.5), + color: Colors.purpleAccent.withValues(alpha: 0.5), ), ), Positioned( @@ -572,7 +481,7 @@ class _TextScrollViewState extends State bottom: 0, child: Container( width: _singleLineFieldAutoScrollGap, - color: Colors.purpleAccent.withOpacity(0.5), + color: Colors.purpleAccent.withValues(alpha: 0.5), ), ), ]; @@ -615,13 +524,32 @@ class TextScrollController with ChangeNotifier { late Ticker _ticker; + // FIXME: This scroll offset creates an ambiguous source of truth as compared to + // the actual scroll offset, which is held within a ScrollController. Either + // this controller should treat the ScrollController offset like it's own, or + // a careful two-way sync'ing should be setup between this controller and it's + // associated ScrollController. double _scrollOffset = 0.0; double get scrollOffset => _scrollOffset; + void _setScrollOffset(double newValue) { + if (newValue == _scrollOffset) { + return; + } + + _scrollOffset = newValue; + notifyListeners(); + } _AutoScrollDirection? _autoScrollDirection; + Offset? _userInteractionOffsetInViewport; + Duration _timeOfPreviousAutoScroll = Duration.zero; Duration _timeOfNextAutoScroll = Duration.zero; + double get startScrollOffset => _delegate!.startScrollOffset; + + double get endScrollOffset => _delegate!.endScrollOffset; + bool isTextPositionVisible(TextPosition position) => _delegate!.isTextPositionVisible(position); void jumpToStart() { @@ -635,8 +563,7 @@ class TextScrollController with ChangeNotifier { final startScrollOffset = _delegate!.startScrollOffset; if (_scrollOffset != startScrollOffset) { _log.finer(' - updated _scrollOffset to $_scrollOffset'); - _scrollOffset = startScrollOffset; - notifyListeners(); + _setScrollOffset(startScrollOffset); } } @@ -651,8 +578,7 @@ class TextScrollController with ChangeNotifier { final endScrollOffset = _delegate!.endScrollOffset; if (_scrollOffset != endScrollOffset) { _log.finer(' - updated _scrollOffset to $_scrollOffset'); - _scrollOffset = endScrollOffset; - notifyListeners(); + _setScrollOffset(endScrollOffset); } } @@ -664,6 +590,7 @@ class TextScrollController with ChangeNotifier { return; } + _userInteractionOffsetInViewport = userInteractionOffsetInViewport; if (_delegate!.isInAutoScrollToStartRegion(userInteractionOffsetInViewport)) { startScrollingToStart(); } else if (_delegate!.isInAutoScrollToEndRegion(userInteractionOffsetInViewport)) { @@ -689,6 +616,7 @@ class TextScrollController with ChangeNotifier { _log.fine('Auto-scrolling to start'); _autoScrollDirection = _AutoScrollDirection.start; + _timeOfPreviousAutoScroll = Duration.zero; _autoScrollTick(Duration.zero); _ticker.start(); } @@ -701,7 +629,7 @@ class TextScrollController with ChangeNotifier { /// cancelled and replaced by auto-scrolling to the end. void startScrollingToEnd() { if (_autoScrollDirection == _AutoScrollDirection.end) { - // Already scrolling to start. Return. + // Already scrolling to end. Return. return; } @@ -709,6 +637,7 @@ class TextScrollController with ChangeNotifier { _log.fine('Auto-scrolling to end'); _autoScrollDirection = _AutoScrollDirection.end; + _timeOfPreviousAutoScroll = Duration.zero; _autoScrollTick(Duration.zero); _ticker.start(); } @@ -743,11 +672,14 @@ class TextScrollController with ChangeNotifier { } if (elapsedTime < _timeOfNextAutoScroll) { + _log.finest("Not enough time passed to do auto-scroll."); // Not enough time has passed to jump further in the scroll direction. return; } _log.finer('auto-scroll tick, is multiline: ${_delegate!.isMultiline}, direction: $_autoScrollDirection'); + final dt = elapsedTime - _timeOfPreviousAutoScroll; + _timeOfPreviousAutoScroll = elapsedTime; final offsetBeforeScroll = _scrollOffset; if (_delegate!.isMultiline) { @@ -757,11 +689,16 @@ class TextScrollController with ChangeNotifier { _autoScrollOneLineDown(); } } else { - // TODO: implement RTL support if (_autoScrollDirection == _AutoScrollDirection.start) { - _autoScrollOneCharacterLeft(); + _autoScrollToTheLeft( + dt, + _delegate!.calculateDistanceBeyondStartingAutoScrollBoundary(_userInteractionOffsetInViewport!), + ); } else { - _autoScrollOneCharacterRight(); + _autoScrollToTheRight( + dt, + _delegate!.calculateDistanceBeyondEndingAutoScrollBoundary(_userInteractionOffsetInViewport!), + ); } } @@ -772,29 +709,28 @@ class TextScrollController with ChangeNotifier { } } - void _autoScrollOneCharacterLeft() { + void _autoScrollToTheLeft(Duration dt, double distanceFromAutoScrollBoundary) { if (_delegate == null) { _log.warning("Can't auto-scroll left. The scroll delegate is null."); return; } - final horizontalOffsetForStartOfCharacterLeftOfViewport = - _delegate!.getHorizontalOffsetForStartOfCharacterLeftOfViewport(); - if (horizontalOffsetForStartOfCharacterLeftOfViewport == null) { - _log.warning( - "Can't auto-scroll left. Couldn't calculate the horizontal offset for the first character beyond the viewport"); + if (_scrollOffset <= 0) { + // There's nowhere left to scroll. + _log.fine("Can't auto-scroll left because we're already at the end."); return; } _log.finer('_autoScrollOneCharacterLeft. Scroll offset before: $scrollOffset'); - _scrollOffset = horizontalOffsetForStartOfCharacterLeftOfViewport; - _log.finer(' - _scrollOffset after: $_scrollOffset'); _timeOfNextAutoScroll += _autoScrollTimePerCharacter; - - notifyListeners(); + _setScrollOffset(max( + _scrollOffset - _calculateAutoScrollDistance(dt, distanceFromAutoScrollBoundary), + 0, + )); + _log.finer(' - _scrollOffset after: $_scrollOffset'); } - void _autoScrollOneCharacterRight() { + void _autoScrollToTheRight(Duration dt, double distanceFromAutoScrollBoundary) { if (_delegate == null) { _log.warning("Can't auto-scroll right. The scroll delegate is null."); return; @@ -806,22 +742,45 @@ class TextScrollController with ChangeNotifier { return; } - final horizontalOffsetForEndOfCharacterRightOfViewport = - _delegate!.getHorizontalOffsetForEndOfCharacterRightOfViewport(); - if (horizontalOffsetForEndOfCharacterRightOfViewport == null) { - _log.warning( - "Can't auto-scroll right. Couldn't calculate the horizontal offset for the first character beyond the viewport"); + if (_scrollOffset >= _delegate!.endScrollOffset) { + // There's nowhere left to scroll. + _log.fine("Can't auto-scroll right because we're already at the end."); return; } - _log.finer('Scrolling right'); - _scrollOffset = horizontalOffsetForEndOfCharacterRightOfViewport - viewportWidth; - _log.finer(' - _scrollOffset after: $_scrollOffset'); + _log.finer('Scrolling right - current scroll offset: $_scrollOffset, max scroll: ${_delegate?.endScrollOffset}'); _timeOfNextAutoScroll += _autoScrollTimePerCharacter; + final scrollDistance = _calculateAutoScrollDistance(dt, distanceFromAutoScrollBoundary); + _setScrollOffset(min( + _scrollOffset + scrollDistance, + _delegate!.endScrollOffset, + )); + _log.finer(' - _scrollOffset after: $_scrollOffset'); notifyListeners(); } + /// Calculates the distance that the field should auto-scroll in a single frame, + /// based on the amount of time that has passed since the last auto-scroll, and + /// given how far beyond the auto-scroll boundary the user has dragged. + /// + /// The larger [dt], the larger the auto-scroll. + /// + /// The larger [distanceFromAutoScrollBound], the larger the auto-scroll, capped at + /// a sane value. + double _calculateAutoScrollDistance(Duration dt, double distanceFromAutoScrollBound) { + const minPixelsPerSecond = 50; + const maxPixelsPerSecond = 1500; + const maxDistanceFromScrollBound = 75; + + final speedPercent = min(distanceFromAutoScrollBound, maxDistanceFromScrollBound) / maxDistanceFromScrollBound; + // Apply an exponential curve to the percent so that the user has more control over + // slower speeds, but is also able to drag far away and achieve high speeds. + final exponentialPercent = Curves.easeIn.transform(speedPercent); + final speed = lerpDouble(minPixelsPerSecond, maxPixelsPerSecond, exponentialPercent)!; + return speed * (dt.inMilliseconds / 1000); + } + /// Updates the scroll offset so that a new line of text is /// visible at the top of the viewport, if a line is available. void _autoScrollOneLineUp() { @@ -847,11 +806,9 @@ class TextScrollController with ChangeNotifier { _log.finer('Old offset: $_scrollOffset.'); _log.finer('Viewport height: $viewportHeight'); _log.finer('Vertical offset for top of line above viewport: $verticalOffsetForTopOfLineAboveViewport'); - _scrollOffset = verticalOffsetForTopOfLineAboveViewport; _timeOfNextAutoScroll += _autoScrollTimePerLine; + _setScrollOffset(verticalOffsetForTopOfLineAboveViewport); _log.fine('New scroll offset: $_scrollOffset, time of next scroll: $_timeOfNextAutoScroll'); - - notifyListeners(); } /// Updates the scroll offset so that a new line of text is @@ -879,11 +836,9 @@ class TextScrollController with ChangeNotifier { _log.finer('Old offset: $_scrollOffset.'); _log.finer('Viewport height: ${_delegate!.viewportHeight}'); _log.finer('Vertical offset for bottom of line below viewport: $verticalOffsetForBottomOfLineBelowViewport'); - _scrollOffset = verticalOffsetForBottomOfLineBelowViewport - viewportHeight; _timeOfNextAutoScroll += _autoScrollTimePerLine; + _setScrollOffset(verticalOffsetForBottomOfLineBelowViewport - viewportHeight); _log.fine('New scroll offset: $_scrollOffset, time of next scroll: $_timeOfNextAutoScroll'); - - notifyListeners(); } /// Updates the scroll offset so that the current selection base @@ -901,7 +856,12 @@ class TextScrollController with ChangeNotifier { return; } - final baseCharacterRect = _delegate!.getCharacterRectAtPosition(_textController.selection.base); + if (_textController.text.isEmpty) { + // There is no text to make visible. + return; + } + + final baseCharacterRect = _delegate!.getViewportCharacterRectAtPosition(_textController.selection.base); _ensureRectIsVisible(baseCharacterRect); } @@ -920,44 +880,75 @@ class TextScrollController with ChangeNotifier { return; } - final characterIndex = _textController.selection.extentOffset >= _textController.text.text.length - ? _textController.text.text.length - 1 + if (_textController.text.isEmpty) { + // There is no text to make visible. + return; + } + + final characterIndex = _textController.selection.extentOffset >= _textController.text.length + ? _textController.text.length - 1 : _textController.selection.extentOffset; - final extentCharacterRect = _delegate!.getCharacterRectAtPosition(TextPosition(offset: characterIndex)); - _ensureRectIsVisible(extentCharacterRect); + + final extentCharacterRectInViewportSpace = + _delegate!.getViewportCharacterRectAtPosition(TextPosition(offset: characterIndex)); + + final extentCharacterRectInContentSpace = _delegate!.isMultiline + ? extentCharacterRectInViewportSpace.translate(0, _scrollOffset) + : extentCharacterRectInViewportSpace.translate(-_scrollOffset, 0); + + // Inflate the rectangle by 2px to the right to add visual space for a caret. + // FIXME: This is a hack to achieve the desired result. Implement a general policy, + // e.g., add a little padding to the left and right in all cases, account for + // actual caret dimensions, and make that happen before getting to this point. + final extentCharacterPlusCaretRectInContentSpace = extentCharacterRectInContentSpace.inflateRight(2); + + _ensureRectIsVisible(extentCharacterPlusCaretRectInContentSpace); } - void _ensureRectIsVisible(Rect rect) { + void _ensureRectIsVisible(Rect rectInContentSpace) { assert(_delegate != null); - _log.finer('Ensuring rect is visible: $rect'); + _log.finer('Ensuring rect is visible: $rectInContentSpace'); if (_delegate!.isMultiline) { - if (rect.top < 0) { + final firstCharRect = _delegate!.getViewportCharacterRectAtPosition(const TextPosition(offset: 0)); + final isAtFirstLine = rectInContentSpace.top == firstCharRect.top; + final extraSpacingAboveTop = (isAtFirstLine ? rectInContentSpace.height / 2 : 0); + + final lastCharRect = + _delegate!.getViewportCharacterRectAtPosition(TextPosition(offset: _textController.text.length - 1)); + final isAtLastLine = rectInContentSpace.top == lastCharRect.top; + final extraSpacingBelowBottom = (isAtLastLine ? rectInContentSpace.height / 2 : 0); + if (rectInContentSpace.top - extraSpacingAboveTop - _scrollOffset < 0) { // The character is entirely or partially above the top of the viewport. // Scroll the content down. - _scrollOffset = rect.top; + _setScrollOffset(max(rectInContentSpace.top - extraSpacingAboveTop, 0)); _log.finer(' - updated _scrollOffset to $_scrollOffset'); - } else if (rect.bottom > _delegate!.viewportHeight!) { + return; + } else if (rectInContentSpace.bottom - _scrollOffset + extraSpacingBelowBottom > _delegate!.viewportHeight!) { // The character is entirely or partially below the bottom of the viewport. // Scroll the content up. - _scrollOffset = rect.bottom - _delegate!.viewportHeight!; + _setScrollOffset(min(rectInContentSpace.bottom - _delegate!.viewportHeight! + extraSpacingBelowBottom, + _delegate!.endScrollOffset)); _log.finer(' - updated _scrollOffset to $_scrollOffset'); + return; } } else { - if (rect.left < 0) { + final rectInViewportSpace = rectInContentSpace.translate(_scrollOffset, 0); + + if (rectInViewportSpace.left < 0) { // The character is entirely or partially before the start of the viewport. // Scroll the content right. - _scrollOffset = rect.left; - _log.finer(' - updated _scrollOffset to $_scrollOffset'); - } else if (rect.right > _delegate!.viewportWidth!) { + _log.finer('Auto-scrolling to the left by ${rectInViewportSpace.left} to show a desired rectangle'); + _setScrollOffset((_scrollOffset + rectInViewportSpace.left).clamp(0, _delegate!.endScrollOffset)); + return; + } else if (rectInViewportSpace.right > _delegate!.viewportWidth!) { // The character is entirely or partially after the end of the viewport. // Scroll the content left. - _scrollOffset = rect.right - _delegate!.viewportWidth!; - _log.finer(' - updated _scrollOffset to $_scrollOffset'); + final scrollAmount = rectInViewportSpace.right - _delegate!.viewportWidth!; + _log.finer('Auto-scrolling to the right by $scrollAmount to show a desired rectangle'); + _setScrollOffset((_scrollOffset + scrollAmount).clamp(0, _delegate!.endScrollOffset)); } } - - notifyListeners(); } } @@ -968,6 +959,10 @@ abstract class TextScrollControllerDelegate { /// The height of the scrollable viewport. double? get viewportHeight; + /// The offset between the top-left of the viewport and the top-left of the + /// text layout. + Offset get textLayoutOffsetInViewport; + /// Whether the text in the scrollable area is displayed /// in a multi-line format (as opposed to single-line format). bool get isMultiline; @@ -987,11 +982,25 @@ abstract class TextScrollControllerDelegate { /// towards the start of the text. bool isInAutoScrollToStartRegion(Offset offsetInViewport); + /// Calculates the distance between the auto-scroll boundary + /// at the start of the text field and the given [offsetInViewport]. + /// + /// This value might be used, for example, to increase/decrease + /// the auto-scroll velocity. + double calculateDistanceBeyondStartingAutoScrollBoundary(Offset offsetInViewport); + /// Whether the given [offsetInViewport] is sitting in the /// area where the user expects an auto-scroll to happen /// towards the end of the text. bool isInAutoScrollToEndRegion(Offset offsetInViewport); + /// Calculates the distance between the auto-scroll boundary + /// at the end of the text field and the given [offsetInViewport]. + /// + /// This value might be used, for example, to increase/decrease + /// the auto-scroll velocity. + double calculateDistanceBeyondEndingAutoScrollBoundary(Offset offsetInViewport); + double? getHorizontalOffsetForStartOfCharacterLeftOfViewport(); double? getHorizontalOffsetForEndOfCharacterRightOfViewport(); @@ -1000,10 +1009,273 @@ abstract class TextScrollControllerDelegate { double? getVerticalOffsetForBottomOfLineBelowViewport(); - Rect getCharacterRectAtPosition(TextPosition position); + /// Calculates and returns the bounding `Rect` for the character at the + /// given [position] within the viewport's coordinates. + /// + /// The viewport coordinates implicitly include any padding between the + /// viewport edges and the text, as well as the current scroll offset. + Rect getViewportCharacterRectAtPosition(TextPosition position); } enum _AutoScrollDirection { start, end, } + +/// Sizes the [child] so its height falls within [minLines] and [maxLines], multiplied by the +/// given [lineHeight]. +/// +/// The [child] must contain a [SuperText] in its tree, +/// and the dimensions of the [child] subtree should match the dimensions +/// of the [SuperText]. The given [textKey] must be bound to the [SuperText] +/// within the [child]'s subtree. +class _TextLinesLimiter extends SingleChildRenderObjectWidget { + const _TextLinesLimiter({ + required this.textKey, + required this.scrollController, + this.minLines, + this.maxLines, + this.lineHeight, + this.padding, + required super.child, + }); + + /// [GlobalKey] that references the [SuperText] within the [child]'s subtree. + final GlobalKey textKey; + + /// The [ScrollController] associated with the multi-line text field that this + /// widget is limiting - this controller must be provided so that during layout, + /// while multiple layout passes are being run on the subtree, the initial scroll + /// offset can be forcibly set after those intermediate layout passes. + final ScrollController scrollController; + + /// The minimum height of this text scroll view, represented as a + /// line count. + final int? minLines; + + /// The maximum height of this text scroll view, represented as a + /// line count. + final int? maxLines; + + /// The height of a single line of text, used + /// with [minLines] and [maxLines] to size the viewport. + /// + /// If a [lineHeight] is provided, the [_TextLinesLimiter] is sized as a + /// multiple of that [lineHeight]. If no [lineHeight] is provided, the + /// [_TextLinesLimiter] is sized as a multiple of the line-height of the + /// first line of text. + final double? lineHeight; + + /// Padding around the text. + final EdgeInsets? padding; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderTextViewport( + textKey: textKey, + scrollController: scrollController, + minLines: minLines, + maxLines: maxLines, + lineHeight: lineHeight, + padding: padding, + ); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderTextViewport renderObject) { + renderObject + ..textKey = textKey + ..scrollController = scrollController + ..minLines = minLines + ..maxLines = maxLines + ..lineHeight = lineHeight + ..padding = padding; + } +} + +class _RenderTextViewport extends RenderProxyBox { + _RenderTextViewport({ + required GlobalKey textKey, + required ScrollController scrollController, + int? minLines, + int? maxLines, + double? lineHeight, + EdgeInsets? padding, + }) : _textKey = textKey, + _scrollController = scrollController, + _minLines = minLines, + _maxLines = maxLines, + _lineHeight = lineHeight, + _padding = padding; + + GlobalKey _textKey; + set textKey(GlobalKey value) { + if (value == _textKey) { + return; + } + + _textKey = value; + markNeedsLayout(); + } + + late ScrollController _scrollController; + set scrollController(ScrollController value) { + _scrollController = value; + } + + int? _maxLines; + set maxLines(int? value) { + if (value == _maxLines) { + return; + } + + _maxLines = value; + markNeedsLayout(); + } + + int? _minLines; + set minLines(int? value) { + if (value == _minLines) { + return; + } + + _minLines = value; + markNeedsLayout(); + } + + double? _lineHeight; + set lineHeight(double? value) { + if (value == _lineHeight) { + return; + } + + _lineHeight = value; + markNeedsLayout(); + } + + EdgeInsets? _padding; + set padding(EdgeInsets? value) { + if (value == _padding) { + return; + } + + _padding = value; + markNeedsLayout(); + } + + @override + void performLayout() { + // Note: Originally we had the following commented line setting the constraints + // for the child subtree. Due to some combination of changes for issue + // #1776, using these tightened constraints fails a test that checks for + // viewport re-sizing when the text gets longer. + // + // Through experimentation, I found that in the test when we use tightened + // constraints, this text viewport doesn't even re-run its layout after the + // text changes. Maybe it's an issue with how we're reporting changes within + // SuperText, but I couldn't figure it out. For now, I'm leaving the original + // line commented, and using the version that seems to work for all tests and + // demo inspections. However, we should eventually figure out what's going + // wrong here with tightened constraints and then bring them back. + // final childConstraints = constraints.tighten(width: constraints.maxWidth); + final childConstraints = constraints; + + if (_minLines == null && _maxLines == null) { + // We don't have restrictions about the number of visible lines. + // Let the child size itself. + child!.layout(childConstraints, parentUsesSize: true); + size = child!.size; + return; + } + + // Log the current scroll offset because our multiple layout passes will + // almost certainly cause the Scrollable to adjust the scroll offset as + // the viewport thinks its size changes. We'll forcibly restore this + // scroll offset at the end of layout. + final scrollOffsetBeforeLayout = _scrollController.offset; + + // Layout the subtree with the text widget so we can query the text layout. + child!.layout(childConstraints, parentUsesSize: true); + + final lineHeight = _computeLineHeight(); + final minHeight = _computeMinHeight(lineHeight); + final maxHeight = _computeMaxHeight(lineHeight); + + // The height we need to enforce if the child doesn't already respects the line restrictions. + double? adjustedChildHeight; + + // Compute the height the child wants to be. + // + // We layout instead of computing the child's intrinsic height, because RenderFlex doesn't + // support calling getMinIntrinsicHeight if it has baseline cross-axis alignment. + child!.layout(childConstraints.copyWith(maxHeight: double.infinity), parentUsesSize: true); + final childIntrinsicHeight = child!.size.height; + + if (childIntrinsicHeight < minHeight) { + adjustedChildHeight = minHeight; + } else if (maxHeight != null && childIntrinsicHeight > maxHeight) { + adjustedChildHeight = maxHeight; + } + + if (adjustedChildHeight == null) { + // The child's intrinsic height already respects the line restrictions. + // Layout the text subtree again, this time forcing the child to be exactly its instrinsic height tall. + child!.layout(childConstraints.tighten(height: childIntrinsicHeight), parentUsesSize: true); + + // Forcibly restore the scroll offset to the original pre-layout value. + _scrollController.position.correctPixels(scrollOffsetBeforeLayout); + + size = child!.size; + return; + } + + // Layout the text subtree again, this time with forced height constraints. + child!.layout(childConstraints.tighten(height: adjustedChildHeight), parentUsesSize: true); + + // Forcibly restore the scroll offset to the original pre-layout value. + _scrollController.position.correctPixels(scrollOffsetBeforeLayout); + + size = child!.size; + } + + double _computeMinHeight(double lineHeight) { + final minContentHeight = _minLines != null // + ? _minLines! * lineHeight + : lineHeight; // Can't be shorter than 1 line. + + return minContentHeight + (_padding?.vertical ?? 0.0); + } + + double? _computeMaxHeight(double lineHeight) { + if (_maxLines == null) { + return null; + } + + return (_maxLines! * lineHeight) + (_padding?.vertical ?? 0.0); + } + + double _computeLineHeight() { + if (_lineHeight != null) { + return _lineHeight!; + } + + final textLayout = _textKey.currentState!.textLayout; + + // We don't expect getHeightForCaret to ever return null, but since its return type is nullable, + // we use getLineHeightAtPosition as a backup. + // More information in https://github.com/flutter/flutter/issues/145507. + double lineHeight = textLayout.getHeightForCaret(const TextPosition(offset: 0)) ?? + textLayout.getLineHeightAtPosition(const TextPosition(offset: 0)); + _log.finer(' - line height at position 0: $lineHeight'); + + // We got 0.0 for the line height at the beginning of text. Maybe the + // text is empty. Ask the TextLayout to estimate a height for us that's + // based on the text style. + if (lineHeight == 0) { + lineHeight = _textKey.currentState!.textLayout.estimatedLineHeight; + _log.finer(' - estimated line height based on text styles: $lineHeight'); + } + + return lineHeight; + } +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/input_method_engine/_ime_text_editing_controller.dart b/super_editor/lib/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart similarity index 60% rename from super_editor/lib/src/infrastructure/super_textfield/input_method_engine/_ime_text_editing_controller.dart rename to super_editor/lib/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart index a7a720ff79..d0bec78b90 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/input_method_engine/_ime_text_editing_controller.dart +++ b/super_editor/lib/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart @@ -1,10 +1,15 @@ +import 'dart:async'; + import 'package:attributed_text/attributed_text.dart'; -import 'package:flutter/painting.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; +import 'package:super_text_layout/super_text_layout.dart'; -import '../../_logging.dart'; +import '../../infrastructure/_logging.dart'; final _log = imeTextFieldLog; @@ -32,14 +37,21 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController AttributedTextEditingController? controller, bool disposeClientController = true, void Function(RawFloatingCursorPoint)? onIOSFloatingCursorChange, + Brightness keyboardAppearance = Brightness.light, + TextInputConnectionFactory? inputConnectionFactory, + this.onPerformSelector, }) : _realController = controller ?? AttributedTextEditingController(), _disposeClientController = disposeClientController, - _onIOSFloatingCursorChange = onIOSFloatingCursorChange { - _realController.addListener(_onTextChange); + _inputConnectionFactory = inputConnectionFactory, + _onIOSFloatingCursorChange = onIOSFloatingCursorChange, + _keyboardAppearance = keyboardAppearance { + _realController.addListener(_onInnerControllerChange); } @override void dispose() { + _realController.removeListener(_onInnerControllerChange); + if (_disposeClientController) { _realController.dispose(); } @@ -47,6 +59,17 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController super.dispose(); } + /// The appearance of the software keyboard. + /// + /// Only used for iOS devices. + Brightness get keyboardAppearance => _keyboardAppearance; + Brightness _keyboardAppearance; + + /// Handles a selector generated by the IME. + /// + /// For the list of selectors, see [MacOsSelectors]. + void Function(String selectorName)? onPerformSelector; + final AttributedTextEditingController _realController; @Deprecated("this property is exposed temporarily as super_editor evaluates what to do with controllers") @@ -54,6 +77,9 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController final bool _disposeClientController; + // Only for testing purposes. + final TextInputConnectionFactory? _inputConnectionFactory; + void Function(RawFloatingCursorPoint)? _onIOSFloatingCursorChange; /// Sets the callback that's invoked whenever the floating cursor changes @@ -73,12 +99,24 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController _onIOSFloatingCursorChange = callback; } - TextInputConnection? _inputConnection; + /// Notifies whenever the current [TextInputConnection] changes. + ValueListenable get inputConnectionNotifier => _inputConnectionNotifier; + final ValueNotifier _inputConnectionNotifier = ValueNotifier(null); + bool _isKeyboardDisplayDesired = false; - bool get isAttachedToIme => _inputConnection != null && _inputConnection!.attached; + bool get isAttachedToIme => _inputConnectionNotifier.value != null && _inputConnectionNotifier.value!.attached; + + /// Holds the current editing value in the IME. + /// + /// Used to determine whether or not we need to send our editing value to the IME. + TextEditingValue _osCurrentTextEditingValue = const TextEditingValue(); + + /// Whether or not a `TextInputAction` differente from `TextInputAction.newLine` was performed on the current frame. + bool _hasPerformedNonNewLineTextInputActionThisFrame = false; void attachToIme({ + required int viewId, bool autocorrect = true, bool enableSuggestions = true, TextInputAction textInputAction = TextInputAction.done, @@ -89,72 +127,107 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController return; } - _inputConnection = TextInput.attach( - this, - TextInputConfiguration( - autocorrect: autocorrect, - enableDeltaModel: true, - enableSuggestions: enableSuggestions, - inputAction: textInputAction, - inputType: textInputType, - )); - _inputConnection! - ..show() - ..setEditingState(currentTextEditingValue!); - _log.fine('Is attached to input client? ${_inputConnection!.attached}'); + final config = TextInputConfiguration( + viewId: viewId, + enableDeltaModel: true, + autocorrect: autocorrect, + enableSuggestions: enableSuggestions, + inputType: textInputType, + inputAction: textInputAction, + keyboardAppearance: _keyboardAppearance, + ); + + attachToImeWithConfig(config); + } + + void attachToImeWithConfig(TextInputConfiguration configuration) { + if (isAttachedToIme) { + // We're already connected to the IME. + return; + } + + // Delta model is required for SuperTextField to work. + final imeConfig = configuration.copyWith(enableDeltaModel: true); + final inputConnection = _inputConnectionFactory?.call(this, imeConfig) ?? TextInput.attach(this, imeConfig); + inputConnection.show(); + + _inputConnectionNotifier.value = inputConnection; + _sendEditingValueToPlatform(); + + _osCurrentTextEditingValue = _latestTextEditingValueSentToPlatform!; + _log.fine('Is attached to input client? ${inputConnection.attached}'); } void updateTextInputConfiguration({ + required int viewId, bool autocorrect = true, bool enableSuggestions = true, TextInputAction textInputAction = TextInputAction.done, TextInputType textInputType = TextInputType.text, + Brightness keyboardAppearance = Brightness.light, + TextCapitalization textCapitalization = TextCapitalization.none, }) { + // Change the keyboard appearance even if we are detached from the IME. + // In the next time we attach to the IME, the keyboard appearance is used. + _keyboardAppearance = keyboardAppearance; + if (!isAttachedToIme) { // We're not attached to the IME, so there is nothing to update. return; } // Close the current connection. - _inputConnection?.close(); + _inputConnectionNotifier.value?.close(); // Open a new connection with the new configuration. - _inputConnection = TextInput.attach( - this, - TextInputConfiguration( - autocorrect: autocorrect, - enableDeltaModel: true, - enableSuggestions: enableSuggestions, - inputAction: textInputAction, - inputType: textInputType, - )); - _inputConnection! - ..show() - ..setEditingState(currentTextEditingValue!); + final imeConfig = TextInputConfiguration( + viewId: viewId, + autocorrect: autocorrect, + enableDeltaModel: true, + enableSuggestions: enableSuggestions, + inputAction: textInputAction, + inputType: textInputType, + keyboardAppearance: keyboardAppearance, + textCapitalization: textCapitalization, + ); + final inputConnection = _inputConnectionFactory?.call(this, imeConfig) ?? TextInput.attach(this, imeConfig); + inputConnection.show(); + + _inputConnectionNotifier.value = inputConnection; + _sendEditingValueToPlatform(); + + _osCurrentTextEditingValue = _latestTextEditingValueSentToPlatform!; } void detachFromIme() { _log.fine('Closing input connection'); - _inputConnection?.close(); + _inputConnectionNotifier.value?.close(); + + _osCurrentTextEditingValue = const TextEditingValue(); + _inputConnectionNotifier.value = null; } void showKeyboard() { _isKeyboardDisplayDesired = true; - _inputConnection?.show(); + if (!(_inputConnectionNotifier.value?.attached ?? false)) { + // We aren't connected to the IME. Therefore, we can't show the keyboard. + return; + } + _inputConnectionNotifier.value?.show(); } void toggleKeyboard() { _isKeyboardDisplayDesired = !_isKeyboardDisplayDesired; if (_isKeyboardDisplayDesired) { - _inputConnection?.show(); + _inputConnectionNotifier.value?.show(); } else { - _inputConnection?.close(); + _inputConnectionNotifier.value?.close(); } } void hideKeyboard() { _isKeyboardDisplayDesired = false; - _inputConnection?.close(); + _inputConnectionNotifier.value?.close(); } //------ Start TextInputClient ---- @@ -165,32 +238,45 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController // When changes come from the app, we want to forward those to platform. But, // when changes originate from the platform, we don't want to send those back // to the platform as changes. This flag differentiates between the two situations. + TextEditingValue? _latestTextEditingValueSentToPlatform; bool _sendTextChangesToPlatform = true; - void _onTextChange() { + void _onInnerControllerChange() { if (_sendTextChangesToPlatform) { _sendEditingValueToPlatform(); } - // Forward the change notification to our listeners (because we wrap - // _realController as a proxy). + // This method was called in response to our inner controller sending a + // change notification. Forward that change notification to our listeners, + // because we wrap _realController as a proxy. notifyListeners(); } - TextEditingValue? _latestPlatformTextEditingValue; - void _onReceivedTextEditingValueFromPlatform(TextEditingValue newValue) { - _latestPlatformTextEditingValue = newValue; + if (newValue == _latestTextEditingValueSentToPlatform) { + // The value didn't change. Don't let us get into an infinite loop + // with the IME where it keeps sending us the same value over and over. + return; + } + + if (currentTextEditingValue == _osCurrentTextEditingValue) { + // We applied the deltas and our editing value ended up the same as the IME thinks it is. + // We don't need to update the value. + return; + } // We have to send the value back to the platform to acknowledge receipt. _sendEditingValueToPlatform(); } void _sendEditingValueToPlatform() { - if (isAttachedToIme) { - _log.fine('Sending TextEditingValue to platform: $currentTextEditingValue'); - _inputConnection!.setEditingState(currentTextEditingValue!); + if (!isAttachedToIme) { + return; } + + _log.fine('Sending TextEditingValue to platform: $currentTextEditingValue'); + _latestTextEditingValueSentToPlatform = currentTextEditingValue; + _inputConnectionNotifier.value!.setEditingState(currentTextEditingValue!); } void Function(TextInputAction)? _onPerformActionPressed; @@ -199,7 +285,7 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController @override TextEditingValue? get currentTextEditingValue => TextEditingValue( - text: text.text, + text: text.toPlainText(), selection: selection, composing: composingRegion, ); @@ -207,11 +293,13 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController @override void updateEditingValue(TextEditingValue value) { _log.fine('New platform TextEditingValue: $value'); + + _osCurrentTextEditingValue = value; _onReceivedTextEditingValueFromPlatform(value); - if (_latestPlatformTextEditingValue != currentTextEditingValue) { + if (_latestTextEditingValueSentToPlatform != currentTextEditingValue) { _sendTextChangesToPlatform = false; - text = AttributedText(text: value.text); + text = AttributedText(value.text); selection = value.selection; composingRegion = value.composing; _sendTextChangesToPlatform = true; @@ -225,6 +313,21 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController return; } + if (_hasPerformedNonNewLineTextInputActionThisFrame && + defaultTargetPlatform == TargetPlatform.iOS && + deltas.every((e) => e is TextEditingDeltaReplacement)) { + // On iOS, pressing the action button can trigger the IME to try to apply auto-corrections + // after we have already processed the input action. Ignore replacement deltas on the same frame + // and forcefully update the IME with our current state. + _sendEditingValueToPlatform(); + return; + } + + // Update our view from the OS editing value. + for (final delta in deltas) { + _osCurrentTextEditingValue = delta.apply(_osCurrentTextEditingValue); + } + // Prevent us from sending these changes back to the platform as we alter // the _realController. Turn this flag back to `true` after the changes. _sendTextChangesToPlatform = false; @@ -236,6 +339,7 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController // This action appears to be user input at the caret. insertAtCaret( text: delta.textInserted, + newComposingRegion: delta.composing, ); } else { // We're not sure what this action represents. Either the current selection @@ -244,9 +348,11 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController // and then push/expand the current selection as needed around the new content. insert( newText: AttributedText( - text: delta.textInserted, + delta.textInserted, ), insertIndex: delta.insertionOffset, + newSelection: delta.selection, + newComposingRegion: delta.composing, ); } } else if (delta is TextEditingDeltaDeletion) { @@ -260,10 +366,11 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController } else if (delta is TextEditingDeltaReplacement) { _log.fine('Processing replacement: $delta'); replace( - newText: AttributedText(text: delta.replacementText), + newText: AttributedText(delta.replacementText), from: delta.replacedRange.start, to: delta.replacedRange.end, newSelection: delta.selection, + newComposingRegion: delta.composing, ); } else if (delta is TextEditingDeltaNonTextUpdate) { _log.fine('Processing selection/composing change: $delta'); @@ -290,8 +397,22 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController AutofillScope? get currentAutofillScope => null; @override + @mustCallSuper void performAction(TextInputAction action) { _onPerformActionPressed?.call(action); + + // Keep track that we have performed a text input action on this frame so we can ignore auto-corrections + // reported after we handled the text input action. + // + // We don't ignore TextInputAction.newline because the insertion of the new line happens after the action + // is reported, and we need to handle the new line insertion to let users replace the selected content + // with a new line. + // + // See https://github.com/superlistapp/super_editor/issues/2004 for more information. + _hasPerformedNonNewLineTextInputActionThisFrame = action != TextInputAction.newline; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _hasPerformedNonNewLineTextInputActionThisFrame = false; + }); } @override @@ -310,13 +431,14 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController @override void connectionClosed() { _log.info('TextInputClient: connectionClosed()'); - _inputConnection = null; - _latestPlatformTextEditingValue = null; + _inputConnectionNotifier.value = null; + _latestTextEditingValueSentToPlatform = null; } @override void performSelector(String selectorName) { - // TODO: implement this method starting with Flutter 3.3.4 + editorImeLog.fine("IME says to perform selector: $selectorName"); + onPerformSelector?.call(selectorName); } //------ End TextInputClient ----- @@ -518,7 +640,85 @@ class ImeAttributedTextEditingController extends AttributedTextEditingController } @override + void clearText() { + _realController.clearText(); + } + + @override + void clearTextAndSelection() { + _realController.clearTextAndSelection(); + } + + @override + @Deprecated('This will be removed in a future release. Use clearText or clearTextAndSelection instead') void clear() { + // ignore: deprecated_member_use_from_same_package _realController.clear(); } + + @override + void deleteCharacter(TextAffinity direction) { + _realController.deleteCharacter(direction); + } + + @override + void copySelectedTextToClipboard() { + _realController.copySelectedTextToClipboard(); + } + + @override + void deleteSelectedText() { + _realController.deleteSelectedText(); + } + + @override + void deleteTextOnLineBeforeCaret({required ProseTextLayout textLayout}) { + _realController.deleteTextOnLineBeforeCaret(textLayout: textLayout); + } + + @override + void insertCharacter(String character) { + _realController.insertCharacter(character); + } + + @override + void moveCaretHorizontally({ + required ProseTextLayout textLayout, + required bool expandSelection, + required bool moveLeft, + required MovementModifier? movementModifier, + }) { + _realController.moveCaretHorizontally( + textLayout: textLayout, + expandSelection: expandSelection, + moveLeft: moveLeft, + movementModifier: movementModifier, + ); + } + + @override + void moveCaretVertically({ + required ProseTextLayout textLayout, + required bool expandSelection, + required bool moveUp, + }) { + _realController.moveCaretVertically( + textLayout: textLayout, + expandSelection: expandSelection, + moveUp: moveUp, + ); + } + + @override + Future pasteClipboard() { + return _realController.pasteClipboard(); + } + + @override + void selectAll() { + _realController.selectAll(); + } } + +typedef TextInputConnectionFactory = TextInputConnection Function( + TextInputClient client, TextInputConfiguration configuration); diff --git a/super_editor/lib/src/infrastructure/super_textfield/ios/_caret.dart b/super_editor/lib/src/super_textfield/ios/caret.dart similarity index 97% rename from super_editor/lib/src/infrastructure/super_textfield/ios/_caret.dart rename to super_editor/lib/src/super_textfield/ios/caret.dart index 5ce1efbfd9..975b649eb0 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/ios/_caret.dart +++ b/super_editor/lib/src/super_textfield/ios/caret.dart @@ -27,7 +27,7 @@ class IOSTextFieldCaret extends StatefulWidget { final BorderRadius caretBorderRadius; @override - _IOSTextFieldCaretState createState() => _IOSTextFieldCaretState(); + State createState() => _IOSTextFieldCaretState(); } class _IOSTextFieldCaretState extends State with SingleTickerProviderStateMixin { @@ -119,7 +119,7 @@ class _IOSCursorPainter extends CustomPainter { } void _drawCaret(Canvas canvas) { - caretPaint.color = caretColor.withOpacity(blinkController.opacity); + caretPaint.color = caretColor.withValues(alpha: blinkController.opacity); final textPosition = selection.extent; double caretHeight = textLayout.getCharacterBox(textPosition)?.toRect().height ?? 0.0; diff --git a/super_editor/lib/src/infrastructure/super_textfield/ios/_editing_controls.dart b/super_editor/lib/src/super_textfield/ios/editing_controls.dart similarity index 69% rename from super_editor/lib/src/infrastructure/super_textfield/ios/_editing_controls.dart rename to super_editor/lib/src/super_textfield/ios/editing_controls.dart index a7c55806cb..28841f3332 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/ios/_editing_controls.dart +++ b/super_editor/lib/src/super_textfield/ios/editing_controls.dart @@ -1,13 +1,21 @@ import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; +import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; import 'package:super_editor/src/infrastructure/toolbar_position_delegate.dart'; import 'package:super_editor/src/infrastructure/platforms/ios/magnifier.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; +import '../metrics.dart'; + final _log = iosTextFieldLog; /// Overlay editing controls for an iOS-style text field. @@ -28,6 +36,7 @@ class IOSEditingControls extends StatefulWidget { required this.textContentKey, required this.textFieldLayerLink, required this.textContentLayerLink, + this.tapRegionGroupId, required this.handleColor, required this.popoverToolbarBuilder, this.showDebugPaint = false, @@ -58,6 +67,10 @@ class IOSEditingControls extends StatefulWidget { /// text. final GlobalKey textContentKey; + /// A group ID for [TapRegion]s that surround each overlay widget, e.g., + /// drag handles. + final String? tapRegionGroupId; + /// The color of the selection handles. final Color handleColor; @@ -71,10 +84,11 @@ class IOSEditingControls extends StatefulWidget { final Widget Function(BuildContext, IOSEditingOverlayController) popoverToolbarBuilder; @override - _IOSEditingControlsState createState() => _IOSEditingControlsState(); + State createState() => _IOSEditingControlsState(); } -class _IOSEditingControlsState extends State with WidgetsBindingObserver { +class _IOSEditingControlsState extends State + with WidgetsBindingObserver, SingleTickerProviderStateMixin { // These global keys are assigned to each draggable handle to // prevent a strange dragging issue. // @@ -95,7 +109,6 @@ class _IOSEditingControlsState extends State with WidgetsBin bool _isDraggingExtent = false; Offset? _globalDragOffset; Offset? _localDragOffset; - @override void initState() { super.initState(); @@ -129,13 +142,7 @@ class _IOSEditingControlsState extends State with WidgetsBin // The available screen dimensions may have changed, e.g., due to keyboard // appearance/disappearance. Reflow the layout. Use a post-frame callback // to give the rest of the UI a chance to reflow, first. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - setState(() { - // no-op - }); - } - }); + scheduleBuildAfterBuild(); } ProseTextLayout get _textLayout => widget.textContentKey.currentState!.textLayout; @@ -144,23 +151,13 @@ class _IOSEditingControlsState extends State with WidgetsBin // We request a rebuild at the end of this frame so that the editing // controls update their position to reflect changes to text styling, // e.g., text that gets wider because it was bolded. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - setState(() {}); - } - }); + scheduleBuildAfterBuild(); } void _onBasePanStart(DragStartDetails details) { _log.fine('_onBasePanStart'); - widget.editingController.hideToolbar(); - - widget.textScrollController.updateAutoScrollingForTouchOffset( - userInteractionOffsetInViewport: - (widget.textFieldKey.currentContext!.findRenderObject() as RenderBox).globalToLocal(details.globalPosition), - ); - widget.textScrollController.addListener(_updateSelectionForNewDragHandleLocation); + _onHandleDragStart(details); setState(() { _isDraggingBase = true; @@ -175,6 +172,18 @@ class _IOSEditingControlsState extends State with WidgetsBin void _onExtentPanStart(DragStartDetails details) { _log.fine('_onExtentPanStart'); + _onHandleDragStart(details); + + setState(() { + _isDraggingBase = false; + _isDraggingExtent = true; + _localDragOffset = (context.findRenderObject() as RenderBox).globalToLocal(details.globalPosition); + }); + } + + void _onHandleDragStart(DragStartDetails details) { + _log.fine('_onHandleDragStart()'); + widget.editingController.hideToolbar(); widget.textScrollController.updateAutoScrollingForTouchOffset( @@ -183,11 +192,10 @@ class _IOSEditingControlsState extends State with WidgetsBin ); widget.textScrollController.addListener(_updateSelectionForNewDragHandleLocation); - setState(() { - _isDraggingBase = false; - _isDraggingExtent = true; - _localDragOffset = (context.findRenderObject() as RenderBox).globalToLocal(details.globalPosition); - }); + if (widget.editingController.textController.selection.isCollapsed) { + // The user is dragging the handle. Stop the caret from blinking while dragging. + widget.editingController.stopCaretBlinking(); + } } void _onPanUpdate(DragUpdateDetails details) { @@ -245,6 +253,10 @@ class _IOSEditingControlsState extends State with WidgetsBin if (!widget.editingController.textController.selection.isCollapsed) { widget.editingController.showToolbar(); + } else { + // The user stopped dragging a handle and the selection is collapsed. + // Start the caret blinking again. + widget.editingController.startCaretBlinking(); } }); } @@ -270,9 +282,7 @@ class _IOSEditingControlsState extends State with WidgetsBin Widget build(BuildContext context) { final textFieldRenderObject = context.findRenderObject(); if (textFieldRenderObject == null) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - setState(() {}); - }); + scheduleBuildAfterBuild(); return const SizedBox(); } @@ -287,10 +297,8 @@ class _IOSEditingControlsState extends State with WidgetsBin ..._buildDraggableOverlayHandles(), // Build the editing toolbar _buildToolbar(), - // Build the focal point for the magnifier - if (_isDraggingBase || _isDraggingExtent) _buildMagnifierFocalPoint(), // Build the magnifier - if (widget.editingController.isMagnifierVisible) _buildMagnifier(), + _buildMagnifier(), ], ); }); @@ -305,7 +313,6 @@ class _IOSEditingControlsState extends State with WidgetsBin return const SizedBox(); } - const toolbarGap = 24.0; Offset toolbarTopAnchor; Offset toolbarBottomAnchor; @@ -314,8 +321,9 @@ class _IOSEditingControlsState extends State with WidgetsBin _textPositionToViewportOffset(widget.editingController.textController.selection.extent); final lineHeight = _textLayout.getLineHeightAtPosition(widget.editingController.textController.selection.extent); - toolbarTopAnchor = extentOffsetInViewport - const Offset(0, toolbarGap); - toolbarBottomAnchor = extentOffsetInViewport + Offset(0, lineHeight) + const Offset(0, toolbarGap); + toolbarTopAnchor = extentOffsetInViewport - const Offset(0, gapBetweenToolbarAndContent); + toolbarBottomAnchor = + extentOffsetInViewport + Offset(0, lineHeight) + const Offset(0, gapBetweenToolbarAndContent); } else { final selectionBoxes = _textLayout.getBoxesForSelection(widget.editingController.textController.selection); Rect selectionBounds = selectionBoxes.first.toRect(); @@ -324,38 +332,44 @@ class _IOSEditingControlsState extends State with WidgetsBin } final selectionTopInText = selectionBounds.topCenter; final selectionTopInViewport = _textOffsetToViewportOffset(selectionTopInText); - toolbarTopAnchor = selectionTopInViewport - const Offset(0, toolbarGap); + toolbarTopAnchor = selectionTopInViewport - const Offset(0, gapBetweenToolbarAndContent); final selectionBottomInText = selectionBounds.bottomCenter; final selectionBottomInViewport = _textOffsetToViewportOffset(selectionBottomInText); - toolbarBottomAnchor = selectionBottomInViewport + const Offset(0, toolbarGap); + toolbarBottomAnchor = selectionBottomInViewport + const Offset(0, gapBetweenToolbarAndContent); } // The selection might start above the visible area in a scrollable // text field. In that case, we don't want the toolbar to sit more - // than [toolbarGap] above the text field. + // than [gapBetweenToolbarAndContent] above the text field. toolbarTopAnchor = Offset( toolbarTopAnchor.dx, max( toolbarTopAnchor.dy, - -toolbarGap, + -gapBetweenToolbarAndContent, ), ); // The selection might end below the visible area in a scrollable // text field. In that case, we don't want the toolbar to sit more - // than [toolbarGap] below the text field. + // than [gapBetweenToolbarAndContent] below the text field. final viewportHeight = (widget.textFieldKey.currentContext!.findRenderObject() as RenderBox).size.height; toolbarTopAnchor = Offset( toolbarTopAnchor.dx, min( toolbarTopAnchor.dy, - viewportHeight + toolbarGap, + viewportHeight + gapBetweenToolbarAndContent, ), ); - final textFieldGlobalOffset = - (widget.textFieldKey.currentContext!.findRenderObject() as RenderBox).localToGlobal(Offset.zero); + final textFieldRenderBox = (widget.textFieldKey.currentContext!.findRenderObject() as RenderBox); + + final textFieldGlobalOffset = textFieldRenderBox.localToGlobal(Offset.zero); + + widget.editingController.overlayController.positionToolbar( + topAnchor: textFieldRenderBox.localToGlobal(toolbarTopAnchor), + bottomAnchor: textFieldRenderBox.localToGlobal(toolbarBottomAnchor), + ); // TODO: figure out why this approach works. Why isn't the text field's // RenderBox offset stale when the keyboard opens or closes? Shouldn't @@ -382,9 +396,12 @@ class _IOSEditingControlsState extends State with WidgetsBin child: AnimatedOpacity( opacity: widget.editingController.isToolbarVisible ? 1.0 : 0.0, duration: const Duration(milliseconds: 150), - child: Builder(builder: (context) { - return widget.popoverToolbarBuilder(context, widget.editingController); - }), + child: TapRegion( + groupId: widget.tapRegionGroupId, + child: Builder(builder: (context) { + return widget.popoverToolbarBuilder(context, widget.editingController); + }), + ), ), ), ); @@ -413,16 +430,38 @@ class _IOSEditingControlsState extends State with WidgetsBin final upstreamTextPosition = selectionDirection == TextAffinity.downstream ? widget.editingController.textController.selection.base : widget.editingController.textController.selection.extent; - final upstreamHandleOffsetInText = _textPositionToTextOffset(upstreamTextPosition); - final upstreamLineHeight = - _textLayout.getCharacterBox(upstreamTextPosition)?.toRect().height ?? _textLayout.estimatedLineHeight; final downstreamTextPosition = selectionDirection == TextAffinity.downstream ? widget.editingController.textController.selection.extent : widget.editingController.textController.selection.base; - final downstreamHandleOffsetInText = _textPositionToTextOffset(downstreamTextPosition); - final downstreamLineHeight = - _textLayout.getCharacterBox(downstreamTextPosition)?.toRect().height ?? _textLayout.estimatedLineHeight; + + late final Offset upstreamHandleOffsetInText; + late final double upstreamLineHeight; + + late final Offset downstreamHandleOffsetInText; + late final double downstreamLineHeight; + + final selectionBoxes = _textLayout.getBoxesForSelection(widget.editingController.textController.selection); + if (selectionBoxes.isEmpty) { + // It's not documented if getBoxesForSelection is guaranteed to return a non-empty list. Therefore, + // fallback to using character box to get the handle's offset and height. + upstreamHandleOffsetInText = _textPositionToTextOffset(upstreamTextPosition); + upstreamLineHeight = + _textLayout.getCharacterBox(upstreamTextPosition)?.toRect().height ?? _textLayout.estimatedLineHeight; + + downstreamHandleOffsetInText = _textPositionToTextOffset(downstreamTextPosition); + downstreamLineHeight = + _textLayout.getCharacterBox(downstreamTextPosition)?.toRect().height ?? _textLayout.estimatedLineHeight; + } else { + final upstreamSelectionBox = selectionBoxes.first; + final downstreamSelectionBox = selectionBoxes.last; + + upstreamHandleOffsetInText = Offset(upstreamSelectionBox.left, upstreamSelectionBox.top); + upstreamLineHeight = upstreamSelectionBox.bottom - upstreamSelectionBox.top; + + downstreamHandleOffsetInText = Offset(downstreamSelectionBox.right, downstreamSelectionBox.top); + downstreamLineHeight = downstreamSelectionBox.bottom - downstreamSelectionBox.top; + } if (upstreamLineHeight == 0 || downstreamLineHeight == 0) { _log.finer('Not building expanded handles because the text layout reported a zero line-height'); @@ -468,52 +507,54 @@ class _IOSEditingControlsState extends State with WidgetsBin required Color debugColor, required void Function(DragStartDetails) onPanStart, }) { + const ballRadius = defaultIosHandleBallDiameter / 2; + return CompositedTransformFollower( key: handleKey, link: widget.textContentLayerLink, offset: followerOffset, child: Transform.translate( - offset: const Offset(-12, -5), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onPanStart: onPanStart, - onPanUpdate: _onPanUpdate, - onPanEnd: _onPanEnd, - onPanCancel: _onPanCancel, - child: Container( - width: 24, - color: widget.showDebugPaint ? Colors.green : Colors.transparent, - child: showHandle - ? isUpstreamHandle - ? IOSSelectionHandle.upstream( - color: widget.handleColor, - caretHeight: lineHeight, - ) - : IOSSelectionHandle.downstream( - color: widget.handleColor, - caretHeight: lineHeight, - ) - : const SizedBox(), + offset: Offset( + -24, + -selectionHighlightBoxVerticalExpansion + + (isUpstreamHandle + // For the upstream handle, the ball is displayed above the text, partially + // overlapping the selected area. Move the ball up so it's positioned above the selected area, + // and add half of the radius to make the ball overlap with the selected area. + ? -defaultIosHandleBallDiameter + (ballRadius / 2) + : 0), + ), + child: TapRegion( + groupId: widget.tapRegionGroupId, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onPanCancel: _onPanCancel, + child: Container( + width: 48, + color: widget.showDebugPaint ? Colors.green : Colors.transparent, + child: showHandle + ? isUpstreamHandle + ? IOSSelectionHandle.upstream( + ballRadius: ballRadius, + color: widget.handleColor, + caretHeight: lineHeight, + ) + : IOSSelectionHandle.downstream( + ballRadius: ballRadius, + color: widget.handleColor, + caretHeight: lineHeight, + ) + : const SizedBox(), + ), ), ), ), ); } - Widget _buildMagnifierFocalPoint() { - // When the user is dragging a handle in this overlay, we - // are responsible for positioning the focal point for the - // magnifier to follow. We do that here. - return Positioned( - left: _localDragOffset!.dx, - top: _localDragOffset!.dy, - child: CompositedTransformTarget( - link: widget.editingController.magnifierFocalPoint, - child: const SizedBox(width: 1, height: 1), - ), - ); - } - Widget _buildMagnifier() { // Display a magnifier that tracks a focal point. // @@ -523,34 +564,45 @@ class _IOSEditingControlsState extends State with WidgetsBin // When some other interaction wants to show the magnifier, then // that other area of the widget tree is responsible for // positioning the LayerLink target. - return Center( - child: IOSFollowingMagnifier.roundedRectangle( - layerLink: widget.editingController.magnifierFocalPoint, - offsetFromFocalPoint: const Offset(0, -72), - ), + return ValueListenableBuilder( + valueListenable: widget.editingController.shouldShowMagnifier, + builder: (context, showMagnifier, child) { + return IOSFollowingMagnifier.roundedRectangle( + leaderLink: widget.editingController.magnifierFocalPoint, + show: showMagnifier, + // The magnifier is centered with the focal point. Translate it so that it sits + // above the focal point and leave a few pixels between the bottom of the magnifier + // and the focal point. This value was chosen empirically. + offsetFromFocalPoint: Offset(0, (-defaultIosMagnifierSize.height / 2) - 20), + ); + }, ); } void _scheduleRebuildBecauseTextIsNotLaidOutYet() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - setState(() { - // no-op. Rebuild this widget in the hopes that the selectable - // text has gone through a layout pass. - }); - } - }); + scheduleBuildAfterBuild(); } } class IOSEditingOverlayController with ChangeNotifier { IOSEditingOverlayController({ required this.textController, - required LayerLink magnifierFocalPoint, - }) : _magnifierFocalPoint = magnifierFocalPoint; + required this.caretBlinkController, + required LeaderLink toolbarFocalPoint, + required LeaderLink magnifierFocalPoint, + required this.overlayController, + }) : _toolbarFocalPoint = toolbarFocalPoint, + _magnifierFocalPoint = magnifierFocalPoint { + overlayController.addListener(_overlayControllerChanged); + } + + @override + void dispose() { + overlayController.removeListener(_overlayControllerChanged); + super.dispose(); + } - bool _isToolbarVisible = false; - bool get isToolbarVisible => _isToolbarVisible; + bool get isToolbarVisible => overlayController.shouldDisplayToolbar; /// The [AttributedTextEditingController] controlling the text /// and selection within the text field with which this @@ -563,44 +615,49 @@ class IOSEditingOverlayController with ChangeNotifier { /// this [textController]. final AttributedTextEditingController textController; - void toggleToolbar() { - if (isToolbarVisible) { - hideToolbar(); - } else { - showToolbar(); - } + final BlinkController caretBlinkController; + + /// Starts the text field caret blinking. + void startCaretBlinking() { + caretBlinkController.startBlinking(); } - void showToolbar() { - hideMagnifier(); + /// Stops the text field caret blinking. + void stopCaretBlinking() { + caretBlinkController.stopBlinking(); + } - _isToolbarVisible = true; + /// Shows, hides, and positions a floating toolbar and magnifier. + final MagnifierAndToolbarController overlayController; - notifyListeners(); + LeaderLink get toolbarFocalPoint => _toolbarFocalPoint; + final LeaderLink _toolbarFocalPoint; + + void toggleToolbar() { + overlayController.toggleToolbar(); + } + + void showToolbar() { + overlayController.showToolbar(); } void hideToolbar() { - _isToolbarVisible = false; - notifyListeners(); + overlayController.hideToolbar(); } - final LayerLink _magnifierFocalPoint; - LayerLink get magnifierFocalPoint => _magnifierFocalPoint; + LeaderLink get magnifierFocalPoint => _magnifierFocalPoint; + final LeaderLink _magnifierFocalPoint; - bool _isMagnifierVisible = false; - bool get isMagnifierVisible => _isMagnifierVisible; + bool get isMagnifierVisible => overlayController.shouldDisplayMagnifier; + final ValueNotifier _shouldShowMagnifier = ValueNotifier(false); + ValueListenable get shouldShowMagnifier => _shouldShowMagnifier; void showMagnifier(Offset globalOffset) { - hideToolbar(); - - _isMagnifierVisible = true; - - notifyListeners(); + overlayController.showMagnifier(); } void hideMagnifier() { - _isMagnifierVisible = false; - notifyListeners(); + overlayController.hideMagnifier(); } bool _areSelectionHandlesVisible = false; @@ -615,4 +672,9 @@ class IOSEditingOverlayController with ChangeNotifier { _areSelectionHandlesVisible = false; notifyListeners(); } + + void _overlayControllerChanged() { + _shouldShowMagnifier.value = overlayController.shouldDisplayMagnifier; + notifyListeners(); + } } diff --git a/super_editor/lib/src/infrastructure/super_textfield/ios/_floating_cursor.dart b/super_editor/lib/src/super_textfield/ios/floating_cursor.dart similarity index 94% rename from super_editor/lib/src/infrastructure/super_textfield/ios/_floating_cursor.dart rename to super_editor/lib/src/super_textfield/ios/floating_cursor.dart index 312bcc1770..2417f87e2c 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/ios/_floating_cursor.dart +++ b/super_editor/lib/src/super_textfield/ios/floating_cursor.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; /// An iOS floating cursor. @@ -24,8 +23,9 @@ class IOSFloatingCursor extends StatelessWidget { Widget build(BuildContext context) { return ListenableBuilder( listenable: controller, - builder: (context) { + builder: (context, _) { return Stack( + clipBehavior: Clip.none, children: [ if (controller.isShowingFloatingCursor) Positioned( @@ -34,7 +34,7 @@ class IOSFloatingCursor extends StatelessWidget { child: Container( width: 2, height: controller.floatingCursorHeight, - color: Colors.red.withOpacity(0.75), + color: Colors.red.withValues(alpha: 0.75), ), ), ], diff --git a/super_editor/lib/src/super_textfield/ios/ios_textfield.dart b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart new file mode 100644 index 0000000000..ef8886f1e7 --- /dev/null +++ b/super_editor/lib/src/super_textfield/ios/ios_textfield.dart @@ -0,0 +1,863 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/default_editor/text_tools.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/flutter/build_context.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/text_input_configuration.dart'; +import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; +import 'package:super_editor/src/infrastructure/platforms/mobile_documents.dart'; +import 'package:super_editor/src/infrastructure/signal_notifier.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/fill_width_if_constrained.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/hint_text.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_scrollview.dart'; +import 'package:super_editor/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; +import 'package:super_editor/src/super_textfield/ios/editing_controls.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +import '../metrics.dart'; +import '../styles.dart'; +import 'floating_cursor.dart'; +import '../../infrastructure/platforms/ios/ios_system_context_menu.dart'; +import 'user_interaction.dart'; + +export '../infrastructure/magnifier.dart'; +export 'caret.dart'; +export 'editing_controls.dart'; +export '../../infrastructure/platforms/ios/ios_system_context_menu.dart'; +export 'user_interaction.dart'; + +final _log = iosTextFieldLog; + +class SuperIOSTextField extends StatefulWidget { + const SuperIOSTextField({ + Key? key, + this.focusNode, + this.tapRegionGroupId, + this.tapHandlers = const [], + this.textController, + this.textStyleBuilder = defaultTextFieldStyleBuilder, + this.inlineWidgetBuilders = const [], + this.textAlign = TextAlign.left, + this.padding, + this.hintBehavior = HintBehavior.displayHintUntilFocus, + this.hintBuilder, + this.minLines, + this.maxLines = 1, + this.lineHeight, + required this.caretStyle, + this.blinkTimingMode = BlinkTimingMode.ticker, + required this.selectionColor, + required this.handlesColor, + this.textInputAction, + this.imeConfiguration, + this.showComposingUnderline = true, + this.popoverToolbarBuilder = defaultIosPopoverToolbarBuilder, + this.showDebugPaint = false, + }) : super(key: key); + + /// [FocusNode] attached to this text field. + final FocusNode? focusNode; + + /// {@macro super_text_field_tap_region_group_id} + final String? tapRegionGroupId; + + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + + /// Controller that owns the text content and text selection for + /// this text field. + final ImeAttributedTextEditingController? textController; + + /// The alignment to use for text in this text field. + /// + /// If `null`, the text alignment is determined by the text direction + /// of the content. + final TextAlign? textAlign; + + /// Text style factory that creates styles for the content in + /// [textController] based on the attributions in that content. + final AttributionStyleBuilder textStyleBuilder; + + /// {@macro super_text_field_inline_widget_builders} + final InlineWidgetBuilderChain inlineWidgetBuilders; + + /// Padding placed around the text content of this text field, but within the + /// scrollable viewport. + final EdgeInsets? padding; + + /// Policy for when the hint should be displayed. + final HintBehavior hintBehavior; + + /// Builder that creates the hint widget, when a hint is displayed. + /// + /// To easily build a hint with styled text, see [StyledHintBuilder]. + final WidgetBuilder? hintBuilder; + + /// The visual representation of the caret. + final CaretStyle caretStyle; + + /// The timing mechanism used to blink, e.g., `Ticker` or `Timer`. + /// + /// `Timer`s are not expected to work in tests. + final BlinkTimingMode blinkTimingMode; + + /// Color of the selection rectangle for selected text. + final Color selectionColor; + + /// Color of the selection handles. + final Color handlesColor; + + /// The minimum height of this text field, represented as a + /// line count. + /// + /// If [minLines] is non-null and greater than `1`, [lineHeight] + /// must also be provided because there is no guarantee that all + /// lines of text have the same height. + /// + /// See also: + /// + /// * [maxLines] + /// * [lineHeight] + final int? minLines; + + /// The maximum height of this text field, represented as a + /// line count. + /// + /// If text exceeds the maximum line height, scrolling dynamics + /// are added to accommodate the overflowing text. + /// + /// If [maxLines] is non-null and greater than `1`, [lineHeight] + /// must also be provided because there is no guarantee that all + /// lines of text have the same height. + /// + /// See also: + /// + /// * [minLines] + /// * [lineHeight] + final int? maxLines; + + /// The height of a single line of text in this text scroll view, used + /// with [minLines] and [maxLines] to size the text field. + /// + /// If a [lineHeight] is provided, the text field viewport is sized as a + /// multiple of that [lineHeight]. If no [lineHeight] is provided, the + /// text field viewport is sized as a multiple of the line-height of the + /// first line of text. + final double? lineHeight; + + /// The type of action associated with the action button on the mobile + /// keyboard. + /// + /// This property is ignored when an [imeConfiguration] is provided. + @Deprecated('This will be removed in a future release. Use imeConfiguration instead') + final TextInputAction? textInputAction; + + /// Preferences for how the platform IME should look and behave during editing. + final TextInputConfiguration? imeConfiguration; + + /// Whether to show an underline beneath the text in the composing region. + final bool showComposingUnderline; + + /// Builder that creates the popover toolbar widget that appears when text is selected. + final IOSPopoverToolbarBuilder popoverToolbarBuilder; + + /// Whether to paint debug guides. + final bool showDebugPaint; + + @override + State createState() => SuperIOSTextFieldState(); +} + +class SuperIOSTextFieldState extends State + with TickerProviderStateMixin, WidgetsBindingObserver + implements ProseTextBlock, ImeInputOwner { + static const Duration _autoScrollAnimationDuration = Duration(milliseconds: 100); + static const Curve _autoScrollAnimationCurve = Curves.fastOutSlowIn; + + final _textFieldKey = GlobalKey(); + final _textFieldLayerLink = LayerLink(); + final _textContentLayerLink = LayerLink(); + final _scrollKey = GlobalKey(); + final _textContentKey = GlobalKey(); + + late FocusNode _focusNode; + + late ImeAttributedTextEditingController _textEditingController; + + /// The text direction of the first character in the text. + /// + /// Used to align and position the caret depending on whether + /// the text is RTL or LTR. + TextDirection? _contentTextDirection; + + /// The text direction applied to the inner text. + TextDirection get _textDirection => _contentTextDirection ?? TextDirection.ltr; + + TextAlign get _textAlign => + widget.textAlign ?? + ((_textDirection == TextDirection.ltr) // + ? TextAlign.left + : TextAlign.right); + + late FloatingCursorController _floatingCursorController; + + final _toolbarLeaderLink = LeaderLink(); + final _magnifierLeaderLink = LeaderLink(); + late IOSEditingOverlayController _editingOverlayController; + + late TextScrollController _textScrollController; + + late MagnifierAndToolbarController _overlayController; + + /// Opens/closes the popover that displays the toolbar and magnifier, and + // positions the invisible touch targets for base/extent dragging. + final _popoverController = OverlayPortalController(); + + late final BlinkController _caretBlinkController; + + /// Notifies the popover toolbar to rebuild itself. + final _overlayControlsRebuildSignal = SignalNotifier(); + + @override + void initState() { + super.initState(); + + switch (widget.blinkTimingMode) { + case BlinkTimingMode.ticker: + _caretBlinkController = BlinkController(tickerProvider: this); + case BlinkTimingMode.timer: + _caretBlinkController = BlinkController.withTimer(); + } + + _focusNode = (widget.focusNode ?? FocusNode())..addListener(_updateSelectionAndImeConnectionOnFocusChange); + + _textEditingController = (widget.textController ?? ImeAttributedTextEditingController()) + ..addListener(_onTextOrSelectionChange) + ..onIOSFloatingCursorChange = _onFloatingCursorChange + ..onPerformActionPressed ??= _onPerformActionPressed; + + _textScrollController = TextScrollController( + textController: _textEditingController, + tickerProvider: this, + )..addListener(_onTextScrollChange); + + _floatingCursorController = FloatingCursorController( + textController: _textEditingController, + ); + + _overlayController = MagnifierAndToolbarController(); + + _editingOverlayController = IOSEditingOverlayController( + textController: _textEditingController, + caretBlinkController: _caretBlinkController, + toolbarFocalPoint: _toolbarLeaderLink, + magnifierFocalPoint: _magnifierLeaderLink, + overlayController: _overlayController, + ); + + _contentTextDirection = getParagraphDirection(_textEditingController.text.toPlainText()); + + WidgetsBinding.instance.addObserver(this); + + if (_focusNode.hasFocus) { + // The given FocusNode already has focus, we need to update selection and attach to IME. + onNextFrame((_) => _updateSelectionAndImeConnectionOnFocusChange()); + } + + if (_textEditingController.selection.isValid) { + // The text field was initialized with a selection - immediately ensure that the + // extent is visible. + onNextFrame((_) => _textScrollController.ensureExtentIsVisible()); + } + } + + @override + void didUpdateWidget(SuperIOSTextField oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.focusNode != oldWidget.focusNode) { + _focusNode.removeListener(_updateSelectionAndImeConnectionOnFocusChange); + if (widget.focusNode != null) { + _focusNode = widget.focusNode!; + } else { + _focusNode = FocusNode(); + } + _focusNode.addListener(_updateSelectionAndImeConnectionOnFocusChange); + } + + if (widget.textController != oldWidget.textController) { + _textEditingController + ..removeListener(_onTextOrSelectionChange) + ..onIOSFloatingCursorChange = null; + if (_textEditingController.onPerformActionPressed == _onPerformActionPressed) { + _textEditingController.onPerformActionPressed = null; + } + + if (widget.textController != null) { + _textEditingController = widget.textController!; + } else { + _textEditingController = ImeAttributedTextEditingController(); + } + + _textEditingController + ..addListener(_onTextOrSelectionChange) + ..onIOSFloatingCursorChange = _onFloatingCursorChange + ..onPerformActionPressed ??= _onPerformActionPressed; + } + + if (widget.imeConfiguration != oldWidget.imeConfiguration && + widget.imeConfiguration != null && + (oldWidget.imeConfiguration == null || !widget.imeConfiguration!.isEquivalentTo(oldWidget.imeConfiguration!)) && + _textEditingController.isAttachedToIme) { + _textEditingController.updateTextInputConfiguration( + viewId: View.of(context).viewId, + textInputAction: widget.imeConfiguration!.inputAction, + textInputType: widget.imeConfiguration!.inputType, + autocorrect: widget.imeConfiguration!.autocorrect, + enableSuggestions: widget.imeConfiguration!.enableSuggestions, + keyboardAppearance: widget.imeConfiguration!.keyboardAppearance, + textCapitalization: widget.imeConfiguration!.textCapitalization, + ); + } + + if (widget.showDebugPaint != oldWidget.showDebugPaint) { + onNextFrame((_) => _rebuildHandles()); + } + } + + @override + void reassemble() { + super.reassemble(); + + // On Hot Reload we need to remove any visible overlay controls and then + // bring them back a frame later to avoid having the controls attempt + // to access the layout of the text. The text layout is not immediately + // available upon Hot Reload. Accessing it results in an exception. + _removeEditingOverlayControls(); + + onNextFrame((_) => _showHandles()); + } + + @override + void dispose() { + _removeEditingOverlayControls(); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // Dispose after the current frame so that other widgets have + // time to remove their listeners. + _editingOverlayController.dispose(); + _overlayController.dispose(); + }); + + _textEditingController + ..removeListener(_onTextOrSelectionChange) + ..onIOSFloatingCursorChange = null + ..detachFromIme(); + if (widget.textController == null) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // Dispose after the current frame so that other widgets have + // time to remove their listeners. + _textEditingController.dispose(); + }); + } + + _focusNode.removeListener(_updateSelectionAndImeConnectionOnFocusChange); + if (widget.focusNode == null) { + _focusNode.dispose(); + } + + _textScrollController + ..removeListener(_onTextScrollChange) + ..dispose(); + + _caretBlinkController.dispose(); + + WidgetsBinding.instance.removeObserver(this); + + _overlayControlsRebuildSignal.dispose(); + + super.dispose(); + } + + @override + void didChangeMetrics() { + // The available screen dimensions may have changed, e.g., due to keyboard + // appearance/disappearance. + onNextFrame((_) { + if (!_focusNode.hasFocus) { + return; + } + + _autoScrollToKeepTextFieldVisible(); + }); + } + + @visibleForTesting + TextScrollController get scrollController => _textScrollController; + + @override + ProseTextLayout get textLayout => _textContentKey.currentState!.textLayout; + + /// Calculates and returns the `Offset` from the top-left corner of this text field + /// to the top-left corner of the [textLayout] within this text field. + Offset get textLayoutOffsetInField { + final fieldBox = context.findRenderObject() as RenderBox; + final textLayoutBox = _textContentKey.currentContext!.findRenderObject() as RenderBox; + return textLayoutBox.localToGlobal(Offset.zero, ancestor: fieldBox); + } + + Rect? _getGlobalCaretRect() { + if (!_textEditingController.selection.isValid || !_textEditingController.selection.isCollapsed) { + // Either there's no selection, or the selection is expanded. In either case, there's no caret. + return null; + } + + final globalTextOffset = + (_textContentKey.currentContext!.findRenderObject() as RenderBox).localToGlobal(Offset.zero); + + final caretPosition = _textEditingController.selection.extent; + final caretOffset = textLayout.getOffsetForCaret(caretPosition) + globalTextOffset; + final caretHeight = textLayout.getHeightForCaret(caretPosition)!; + + return Rect.fromLTWH(caretOffset.dx, caretOffset.dy, 1, caretHeight); + } + + bool get _isMultiline => (widget.minLines ?? 1) != 1 || widget.maxLines != 1; + + @override + DeltaTextInputClient get imeClient => _textEditingController; + + void _updateSelectionAndImeConnectionOnFocusChange() { + // The focus change callback might be invoked in the build phase, usually when used inside + // an OverlayPortal. If that's the case, defer the setState call until the end of the frame. + WidgetsBinding.instance.runAsSoonAsPossible(() { + if (!mounted) { + return; + } + + if (_focusNode.hasFocus) { + if (!_textEditingController.isAttachedToIme) { + _log.info('Attaching TextInputClient to TextInput'); + setState(() { + if (!_textEditingController.selection.isValid) { + _textEditingController.selection = TextSelection.collapsed(offset: _textEditingController.text.length); + } + + if (widget.imeConfiguration != null) { + _textEditingController.attachToImeWithConfig(widget.imeConfiguration!); + } else { + _textEditingController.attachToIme( + viewId: View.of(context).viewId, + textInputAction: widget.textInputAction ?? TextInputAction.done, + textInputType: _isMultiline ? TextInputType.multiline : TextInputType.text, + ); + } + + _autoScrollToKeepTextFieldVisible(); + _showHandles(); + }); + } + } else { + _log.info('Lost focus. Detaching TextInputClient from TextInput.'); + setState(() { + _textEditingController.detachFromIme(); + _textEditingController.selection = const TextSelection.collapsed(offset: -1); + _textEditingController.composingRegion = TextRange.empty; + _removeEditingOverlayControls(); + }); + } + }); + } + + void _onTextOrSelectionChange() { + if (_textEditingController.selection.isCollapsed) { + _editingOverlayController.hideToolbar(); + } + + setState(() { + _contentTextDirection = getParagraphDirection(_textEditingController.text.toPlainText()); + }); + } + + void _onTextScrollChange() { + if (_popoverController.isShowing) { + _rebuildHandles(); + } + } + + /// Displays [IOSEditingControls] in the [OverlayPortal], if not already + /// displayed. + void _showHandles() { + if (!_popoverController.isShowing) { + _popoverController.show(); + } + } + + /// Rebuilds the [IOSEditingControls] in the [OverlayPortal], if + /// they're currently displayed. + void _rebuildHandles() { + if (!_popoverController.isShowing) { + _overlayControlsRebuildSignal.notifyListeners(); + } + } + + /// Hides the [IOSEditingControls] in the [OverlayPortal], if they're + /// currently displayed. + void _removeEditingOverlayControls() { + if (_popoverController.isShowing) { + _popoverController.hide(); + } + } + + void _onFloatingCursorChange(RawFloatingCursorPoint point) { + _floatingCursorController.updateFloatingCursor(_textContentKey.currentState!.textLayout, point); + } + + /// Handles actions from the IME + void _onPerformActionPressed(TextInputAction action) { + switch (action) { + case TextInputAction.done: + _focusNode.unfocus(); + break; + case TextInputAction.next: + _focusNode.nextFocus(); + break; + case TextInputAction.previous: + _focusNode.previousFocus(); + break; + default: + _log.warning("User pressed unhandled action button: $action"); + } + } + + /// Scrolls the ancestor [Scrollable], if any, so [SuperTextField] + /// is visible on the viewport when it's focused + void _autoScrollToKeepTextFieldVisible() { + // If we are not inside a [Scrollable] we don't autoscroll + final ancestorScrollable = context.findAncestorScrollableWithVerticalScroll; + if (ancestorScrollable == null) { + return; + } + + // Compute the text field offset that should be visible to the user + final textFieldFocalPoint = widget.maxLines == null && _textEditingController.selection.isValid + ? _textContentKey.currentState!.textLayout.getOffsetAtPosition( + TextPosition(offset: _textEditingController.selection.extentOffset), + ) + : Offset.zero; + + final lineHeight = _textContentKey.currentState!.textLayout.getLineHeightAtPosition( + TextPosition(offset: _textEditingController.selection.extentOffset), + ); + final fieldBox = context.findRenderObject() as RenderBox; + + // The area of the text field that should be revealed. + // We add a small margin to leave some space between the text field and the keyboard. + final textFieldFocalRect = Rect.fromLTWH( + textFieldFocalPoint.dx, + textFieldFocalPoint.dy, + fieldBox.size.width, + lineHeight + gapBetweenCaretAndKeyboard, + ); + + fieldBox.showOnScreen( + rect: textFieldFocalRect, + duration: _autoScrollAnimationDuration, + curve: _autoScrollAnimationCurve, + ); + } + + @override + Widget build(BuildContext context) { + return TapRegion( + groupId: widget.tapRegionGroupId, + child: Focus( + key: _textFieldKey, + focusNode: _focusNode, + child: CompositedTransformTarget( + link: _textFieldLayerLink, + child: IOSTextFieldTouchInteractor( + focusNode: _focusNode, + tapHandlers: widget.tapHandlers, + selectableTextKey: _textContentKey, + getGlobalCaretRect: _getGlobalCaretRect, + textFieldLayerLink: _textFieldLayerLink, + textController: _textEditingController, + editingOverlayController: _editingOverlayController, + textScrollController: _textScrollController, + isMultiline: _isMultiline, + handleColor: widget.handlesColor, + showDebugPaint: widget.showDebugPaint, + child: TextScrollView( + key: _scrollKey, + textScrollController: _textScrollController, + textKey: _textContentKey, + textEditingController: _textEditingController, + textAlign: _textAlign, + minLines: widget.minLines, + maxLines: widget.maxLines, + lineHeight: widget.lineHeight, + padding: EdgeInsets.only(top: widget.padding?.top ?? 0, bottom: widget.padding?.bottom ?? 0), + perLineAutoScrollDuration: const Duration(milliseconds: 100), + showDebugPaint: widget.showDebugPaint, + child: FillWidthIfConstrained( + child: Padding( + padding: EdgeInsets.only(left: widget.padding?.left ?? 0, right: widget.padding?.right ?? 0), + child: CompositedTransformTarget( + link: _textContentLayerLink, + child: ListenableBuilder( + listenable: _textEditingController, + builder: (context, _) { + return _buildSelectableText(); + }, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildSelectableText() { + final textSpan = _textEditingController.text // + .computeInlineSpan(context, widget.textStyleBuilder, widget.inlineWidgetBuilders); + + CaretStyle caretStyle = widget.caretStyle; + + final caretColorOverride = _floatingCursorController.isShowingFloatingCursor ? Colors.grey : null; + if (caretColorOverride != null) { + caretStyle = caretStyle.copyWith(color: caretColorOverride); + } + + return Directionality( + textDirection: _textDirection, + child: SuperText( + key: _textContentKey, + richText: textSpan, + textAlign: _textAlign, + textDirection: _textDirection, + textScaler: MediaQuery.textScalerOf(context), + layerBeneathBuilder: (context, textLayout) { + final isTextEmpty = _textEditingController.text.isEmpty; + final showHint = widget.hintBuilder != null && + ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || + (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); + + return Stack( + clipBehavior: Clip.none, + children: [ + if (_textEditingController.selection.isValid == true) + // Selection highlight beneath the text. + TextLayoutSelectionHighlight( + textLayout: textLayout, + style: SelectionHighlightStyle( + color: widget.selectionColor, + ), + selection: _textEditingController.selection, + ), + // Underline beneath the composing region. + if (_textEditingController.composingRegion.isValid == true && widget.showComposingUnderline) + TextUnderlineLayer( + textLayout: textLayout, + style: StraightUnderlineStyle( + color: widget.textStyleBuilder({}).color ?? // + (Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white), + ), + underlines: [ + TextLayoutUnderline( + range: _textEditingController.composingRegion, + ), + ], + ), + if (showHint) // + widget.hintBuilder!(context), + ], + ); + }, + layerAboveBuilder: (context, textLayout) { + if (!_focusNode.hasFocus) { + return const SizedBox(); + } + + return OverlayPortal( + controller: _popoverController, + overlayChildBuilder: _buildOverlayIosControls, + child: Stack( + clipBehavior: Clip.none, + children: [ + TextLayoutCaret( + textLayout: textLayout, + style: widget.caretStyle, + position: _textEditingController.selection.isCollapsed // + ? _textEditingController.selection.extent + : null, + blinkController: _caretBlinkController, + ), + IOSFloatingCursor( + controller: _floatingCursorController, + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildOverlayIosControls(BuildContext context) { + return ListenableBuilder( + listenable: _overlayControlsRebuildSignal, + builder: (context, _) { + return IOSEditingControls( + editingController: _editingOverlayController, + textScrollController: _textScrollController, + textFieldLayerLink: _textFieldLayerLink, + textFieldKey: _textFieldKey, + textContentLayerLink: _textContentLayerLink, + textContentKey: _textContentKey, + tapRegionGroupId: widget.tapRegionGroupId, + handleColor: widget.handlesColor, + popoverToolbarBuilder: widget.popoverToolbarBuilder, + showDebugPaint: widget.showDebugPaint, + ); + }, + ); + } +} + +/// Builder that returns a widget for an iOS-style popover editing toolbar. +typedef IOSPopoverToolbarBuilder = Widget Function(BuildContext, IOSEditingOverlayController); + +/// An [IOSPopoverToolbarBuilder] that displays the iOS system popover toolbar, if the version of +/// iOS is recent enough, otherwise builds [defaultIosPopoverToolbarBuilder]. +Widget iOSSystemPopoverTextFieldToolbarWithFallback(BuildContext context, IOSEditingOverlayController controller) { + if (IOSSystemContextMenu.isSupported(context)) { + return IOSSuperTextFieldSystemContextMenu( + controller: controller, + ); + } + + return defaultIosPopoverToolbarBuilder(context, controller); +} + +/// Returns a widget for the default/standard iOS-style popover provided by Super Text Field. +Widget defaultIosPopoverToolbarBuilder(BuildContext context, IOSEditingOverlayController controller) { + return IOSTextEditingFloatingToolbar( + focalPoint: controller.toolbarFocalPoint, + onCutPressed: () { + final textController = controller.textController; + final selection = textController.selection; + if (selection.isCollapsed) { + return; + } + + final selectedText = selection.textInside(textController.text.toPlainText()); + + textController.deleteSelectedText(); + + Clipboard.setData(ClipboardData(text: selectedText)); + }, + onCopyPressed: () { + final textController = controller.textController; + final selection = textController.selection; + final selectedText = selection.textInside(textController.text.toPlainText()); + + Clipboard.setData(ClipboardData(text: selectedText)); + }, + onPastePressed: () async { + final clipboardContent = await Clipboard.getData('text/plain'); + if (clipboardContent == null || clipboardContent.text == null) { + return; + } + + final textController = controller.textController; + final selection = textController.selection; + if (selection.isCollapsed) { + textController.insertAtCaret(text: clipboardContent.text!); + } else { + textController.replaceSelectionWithUnstyledText(replacementText: clipboardContent.text!); + } + }, + ); +} + +class IOSSuperTextFieldSystemContextMenu extends StatefulWidget { + const IOSSuperTextFieldSystemContextMenu({ + super.key, + required this.controller, + }); + + final IOSEditingOverlayController controller; + + @override + State createState() => _IOSSuperTextFieldSystemContextMenuState(); +} + +class _IOSSuperTextFieldSystemContextMenuState extends State { + late final SystemContextMenuController _systemContextMenuController; + + @override + void initState() { + super.initState(); + _systemContextMenuController = SystemContextMenuController(); + widget.controller.addListener(_onControllerChanged); + onNextFrame((_) { + _positionSystemMenu(); + }); + } + + @override + void didUpdateWidget(covariant IOSSuperTextFieldSystemContextMenu oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_onControllerChanged); + widget.controller.addListener(_onControllerChanged); + } + onNextFrame((_) { + _positionSystemMenu(); + }); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChanged); + _systemContextMenuController.dispose(); + super.dispose(); + } + + void _onControllerChanged() { + onNextFrame((_) { + _positionSystemMenu(); + }); + } + + void _positionSystemMenu() { + // The size reported by the controller's toolbarFocalPoint is one frame behind. Query the information + // overlayController instead. + final topAnchor = widget.controller.overlayController.toolbarTopAnchor; + final bottomAnchor = widget.controller.overlayController.toolbarTopAnchor; + + if (topAnchor == null || bottomAnchor == null) { + // We don't expect the toolbar builder to be called without having the anchors + // defined. But, since these properties are nullable, we account for that. + return; + } + + _systemContextMenuController.show(Rect.fromLTRB(topAnchor.dx, topAnchor.dy, bottomAnchor.dx, bottomAnchor.dy)); + } + + @override + Widget build(BuildContext context) { + assert(IOSSystemContextMenu.isSupported(context)); + return const SizedBox.shrink(); + } +} diff --git a/super_editor/lib/src/super_textfield/ios/user_interaction.dart b/super_editor/lib/src/super_textfield/ios/user_interaction.dart new file mode 100644 index 0000000000..bd98f9423c --- /dev/null +++ b/super_editor/lib/src/super_textfield/ios/user_interaction.dart @@ -0,0 +1,759 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/text_selection.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/selection_heuristics.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; +import 'package:super_editor/src/test/test_globals.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +import 'editing_controls.dart'; + +final _log = iosTextFieldLog; + +/// iOS text field touch interaction surface. +/// +/// This widget is intended to be displayed in the foreground of a [SuperText] widget. +/// +/// This widget recognizes and acts upon various user interactions: +/// +/// * Tap: Place a collapsed text selection at the text location that's +/// nearest to the tap offset. +/// * Tap (in a location that doesn't move the caret): Toggle the toolbar. +/// * Double-Tap: Select the word surrounding the tapped location. +/// * Triple-Tap: Select the paragraph surrounding the tapped location +/// * Drag: Move a collapsed selection wherever the user drags, while +/// displaying a magnifying glass. +/// +/// Drag handles, a magnifying glass, and an editing toolbar are displayed +/// based on how the user interacts with this widget. Those UI elements +/// are controller via the given [editingOverlayController]. +/// +/// The text is auto-scrolled when the user drags a collapsed caret in +/// this widget. The auto-scrolling is handled by the given [textScrollController]. +/// +/// Selection changes are made via the given [textController]. +class IOSTextFieldTouchInteractor extends StatefulWidget { + /// {@macro ios_use_selection_heuristics} + @visibleForTesting + static bool useIosSelectionHeuristics = true; + + const IOSTextFieldTouchInteractor({ + Key? key, + required this.focusNode, + this.tapHandlers = const [], + required this.textFieldLayerLink, + required this.textController, + required this.editingOverlayController, + required this.textScrollController, + required this.selectableTextKey, + required this.getGlobalCaretRect, + required this.isMultiline, + required this.handleColor, + this.showDebugPaint = false, + required this.child, + }) : super(key: key); + + /// [FocusNode] for the text field that contains this [IOSTextFieldInteractor]. + /// + /// [IOSTextFieldInteractor] only shows editing controls, and listens for drag + /// events when [focusNode] has focus. + /// + /// [IOSTextFieldInteractor] requests focus when the user taps on it. + final FocusNode focusNode; + + /// {@macro super_text_field_tap_handlers} + final List tapHandlers; + + /// [LayerLink] that follows the text field that contains this + /// [IOSExtFieldInteractor]. + /// + /// [textFieldLayerLink] is used to anchor the editing controls. + final LayerLink textFieldLayerLink; + + /// [TextController] used to read the current selection to display + /// editing controls, and used to update the selection based on + /// user interactions. + final ImeAttributedTextEditingController textController; + + final IOSEditingOverlayController editingOverlayController; + + final TextScrollController textScrollController; + + /// [GlobalKey] that references the widget that contains the field's + /// text. + final GlobalKey selectableTextKey; + + /// A function that returns the current caret global rect, or `null` if no + /// caret exists. + final Rect? Function() getGlobalCaretRect; + + /// Whether the text field that owns this [IOSTextFieldInteractor] is + /// a multiline text field. + final bool isMultiline; + + /// The color of expanded selection drag handles. + final Color handleColor; + + /// Whether to paint debugging guides and regions. + final bool showDebugPaint; + + /// The child widget. + final Widget child; + + @override + IOSTextFieldTouchInteractorState createState() => IOSTextFieldTouchInteractorState(); +} + +class IOSTextFieldTouchInteractorState extends State with TickerProviderStateMixin { + /// The maximum horizontal distance that a user can press near the caret to enable + /// a caret drag. + static const _closeEnoughToDragCaret = 48.0; + + final _textViewportOffsetLink = LayerLink(); + + // Whether the user is dragging a collapsed selection. + bool _isDraggingCaret = false; + + // The latest offset during a user's drag gesture. + Offset? _globalDragOffset; + Offset? _dragOffset; + + TextSelection? _selectionBeforeTap; + + TextSelection? _previousToolbarFocusSelection; + final _toolbarFocusSelectionRect = ValueNotifier(null); + + @override + void initState() { + super.initState(); + + widget.textController.addListener(_onTextOrSelectionChange); + widget.textScrollController.addListener(_onScrollChange); + } + + @override + void didUpdateWidget(IOSTextFieldTouchInteractor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.textController != oldWidget.textController) { + oldWidget.textController.removeListener(_onTextOrSelectionChange); + widget.textController.addListener(_onTextOrSelectionChange); + } + if (widget.textScrollController != oldWidget.textScrollController) { + oldWidget.textScrollController.removeListener(_onScrollChange); + widget.textScrollController.addListener(_onScrollChange); + } + } + + @override + void dispose() { + _toolbarFocusSelectionRect.dispose(); + widget.textController.removeListener(_onTextOrSelectionChange); + widget.textScrollController.removeListener(_onScrollChange); + super.dispose(); + } + + ProseTextLayout get _textLayout => widget.selectableTextKey.currentState!.textLayout; + + void _onTextOrSelectionChange() { + if (widget.textController.selection != _previousToolbarFocusSelection) { + // Update the selection bounds focal point + WidgetsBinding.instance.runAsSoonAsPossible(_computeSelectionRect); + } + + if (!_isDraggingCaret) { + // The user isn't dragging the caret. Ensure the current selection is visible. The + // user may have typed beyond the viewport, or something may have changed the controller's + // selection to sit beyond the viewport. + // + // We don't do this when the user is dragging the caret because the user's finger position + // and the auto-scrolling system should control the scroll offset in that case. + onNextFrame((timeStamp) { + // We adjust for the extent offset in the next frame because we need the + // underlying RenderParagraph to update first, so that we can inspect the + // text layout for the most recent text and selection. + widget.textScrollController.ensureExtentIsVisible(); + }); + } + } + + void _onTapDown(TapDownDetails details) { + _log.fine("User tapped down"); + + final textOffset = _globalOffsetToTextOffset(details.globalPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onTapDown( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onTapUp(TapUpDetails details) { + _log.fine('User released a tap'); + + final textOffset = _globalOffsetToTextOffset(details.globalPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onTapUp( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + + _selectionBeforeTap = widget.textController.selection; + + if (widget.focusNode.hasFocus && widget.textController.isAttachedToIme) { + widget.textController.showKeyboard(); + } else if (widget.focusNode.hasFocus) { + // This situation can happen on iOS web when the user taps outside the field + // or clicks on the OK button of the software keyboard. + // In this situation, the IME connection is closed but the field remains focused. + // We need to attach to IME so the keyboard is displayed again. + widget.textController.attachToIme(viewId: View.of(context).viewId); + } else { + widget.focusNode.requestFocus(); + } + + final exactTapTextPosition = _getTextPositionNearestToOffset(details.localPosition); + final adjustedTapTextPosition = + exactTapTextPosition != null ? _moveTapPositionToWordBoundary(exactTapTextPosition) : null; + final didTapOnExistingSelection = exactTapTextPosition != null && + _selectionBeforeTap != null && + (_selectionBeforeTap!.isCollapsed + ? exactTapTextPosition.offset == _selectionBeforeTap!.extent.offset + : exactTapTextPosition.offset >= _selectionBeforeTap!.start && + exactTapTextPosition.offset <= _selectionBeforeTap!.end); + + if (!didTapOnExistingSelection) { + // Select the text that's nearest to where the user tapped. + _selectPosition(adjustedTapTextPosition); + } + + final didCaretStayInSamePlace = _selectionBeforeTap != null && + _selectionBeforeTap?.hasSameBoundsAs(widget.textController.selection) == true && + _selectionBeforeTap!.isCollapsed; + if ((didCaretStayInSamePlace || didTapOnExistingSelection) && widget.focusNode.hasFocus) { + // The user either tapped directly on the caret, or on an expanded selection, + // or the user tapped in empty space but didn't move the caret, for example + // the user tapped in empty space after the text and the caret was already + // at the end of the text. + // + // Toggle the toolbar. + widget.editingOverlayController.toggleToolbar(); + } else if (!didCaretStayInSamePlace && !didTapOnExistingSelection) { + // The user tapped somewhere in the text outside any existing selection. + // Hide the toolbar. + widget.editingOverlayController.hideToolbar(); + } + + _selectionBeforeTap = null; + } + + void _onTapCancel() { + for (final handler in widget.tapHandlers) { + final result = handler.onTapCancel(); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + TextPosition _moveTapPositionToWordBoundary(TextPosition textPosition) { + if (!IOSTextFieldTouchInteractor.useIosSelectionHeuristics) { + // Don't adjust the tap location in tests because we want tests to be + // able to precisely position the caret at a given offset. + // TODO: Make this decision configurable, similar to SuperEditor, so that + // we can add tests for this behavior. + return textPosition; + } + + if (textPosition.offset < 0) { + return textPosition; + } + + final text = widget.textController.text.toPlainText(); + final tapOffset = textPosition.offset; + if (tapOffset == text.length) { + return textPosition; + } + final adjustedSelectionOffset = IosHeuristics.adjustTapOffset(text, tapOffset); + + return TextPosition(offset: adjustedSelectionOffset); + } + + void _selectPosition(TextPosition? textPosition) { + if (textPosition == null || textPosition.offset < 0) { + // This situation indicates the user tapped in empty space + widget.textController.selection = TextSelection.collapsed(offset: widget.textController.text.length); + return; + } + + // Update the text selection to a collapsed selection where the user tapped. + widget.textController.selection = TextSelection.collapsed(offset: textPosition.offset); + widget.textController.composingRegion = TextRange.empty; + } + + void _onDoubleTapDown(TapDownDetails details) { + _log.fine('Double tap'); + + final textOffset = _globalOffsetToTextOffset(details.globalPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onDoubleTapDown( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + + widget.focusNode.requestFocus(); + + // When the user released the first tap, the toolbar was set + // to visible. At the beginning of a double-tap, make it invisible + // again. + widget.editingOverlayController.hideToolbar(); + + final tapTextPosition = _getTextPositionNearestToOffset(details.localPosition); + if (tapTextPosition != null) { + setState(() { + final wordSelection = _getWordSelectionAt(tapTextPosition); + + widget.textController.selection = wordSelection; + + if (!wordSelection.isCollapsed) { + widget.editingOverlayController.showToolbar(); + } + }); + } + } + + void _onDoubleTapUp(TapUpDetails details) { + final textOffset = _globalOffsetToTextOffset(details.globalPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onDoubleTapUp( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onDoubleTapCancel() { + for (final handler in widget.tapHandlers) { + final result = handler.onDoubleTapCancel(); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onTripleTapDown(TapDownDetails details) { + final textOffset = _globalOffsetToTextOffset(details.globalPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onTripleTapDown( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + + final textLayout = _textLayout; + final tapTextPosition = textLayout.getPositionAtOffset(details.localPosition)!; + + widget.textController.selection = + textLayout.expandSelection(tapTextPosition, paragraphExpansionFilter, TextAffinity.downstream); + } + + void _onTripleTapUp(TapUpDetails details) { + final textOffset = _globalOffsetToTextOffset(details.globalPosition); + + for (final handler in widget.tapHandlers) { + final result = handler.onTripleTapUp( + SuperTextFieldGestureDetails( + textLayout: _textLayout, + textController: widget.textController, + globalOffset: details.globalPosition, + layoutOffset: details.localPosition, + textOffset: textOffset, + ), + ); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onTripleTapCancel() { + for (final handler in widget.tapHandlers) { + final result = handler.onTripleTapCancel(); + + if (result == TapHandlingInstruction.halt) { + return; + } + } + } + + void _onPanStart(DragStartDetails details) { + _log.fine('_onPanStart()'); + + final globalCaretRect = widget.getGlobalCaretRect(); + if (globalCaretRect == null) { + // There's no caret, therefore the user shouldn't be able to drag the caret. Fizzle. + return; + } + if ((globalCaretRect.center - details.globalPosition).dx.abs() > _closeEnoughToDragCaret) { + // There's a caret, but the user's drag offset is far away. Fizzle. + return; + } + + // Let the user drag the caret around. + setState(() { + _isDraggingCaret = true; + _globalDragOffset = details.globalPosition; + _dragOffset = details.localPosition; + + // When the user drags, the toolbar should not be visible. + widget.editingOverlayController.hideToolbar(); + + if (widget.textController.selection.isCollapsed) { + // The user is dragging the caret. Stop the caret from blinking while dragging. + widget.editingOverlayController.stopCaretBlinking(); + } + }); + } + + void _onPanUpdate(DragUpdateDetails details) { + _log.fine('_onPanUpdate handle mode'); + if (!_isDraggingCaret) { + return; + } + + setState(() { + widget.textController.selection = TextSelection.collapsed( + offset: _globalOffsetToTextPosition(details.globalPosition).offset, + ); + + _globalDragOffset = _globalDragOffset! + details.delta; + _dragOffset = _dragOffset! + details.delta; + + widget.textScrollController.updateAutoScrollingForTouchOffset( + userInteractionOffsetInViewport: _dragOffset!, + ); + + widget.editingOverlayController.showMagnifier(_globalDragOffset!); + }); + } + + void _onPanEnd(DragEndDetails details) { + _log.fine('_onPanEnd()'); + _onHandleDragEnd(); + } + + void _onPanCancel() { + _log.fine('_onPanCancel()'); + _onHandleDragEnd(); + } + + void _onHandleDragEnd() { + _log.fine('_onHandleDragEnd()'); + widget.textScrollController.stopScrolling(); + + if (_isDraggingCaret) { + widget.textScrollController.ensureExtentIsVisible(); + } + + setState(() { + _isDraggingCaret = false; + widget.editingOverlayController.hideMagnifier(); + + if (!widget.textController.selection.isCollapsed) { + widget.editingOverlayController.showToolbar(); + } else { + // The user stopped dragging the caret and the selection is collapsed. + // Start the caret blinking again. + widget.editingOverlayController.startCaretBlinking(); + } + }); + } + + void _onScrollChange() { + if (_isDraggingCaret) { + // This callback is invoked as soon as the logical scroll offset + // changes, but that scroll value won't be reflected in the text + // layout until the end of this frame. Therefore, we schedule a + // a post frame callback to lookup the new text selection location + // after the current layout pass. + onNextFrame((_) { + widget.textController.selection = TextSelection.collapsed( + offset: _globalOffsetToTextPosition(_globalDragOffset!).offset, + ); + }); + } + } + + /// Converts a screen-level offset to an offset relative to the top-left + /// corner of the text within this text field. + Offset _globalOffsetToTextOffset(Offset globalOffset) { + final textBox = widget.selectableTextKey.currentContext!.findRenderObject() as RenderBox; + return textBox.globalToLocal(globalOffset); + } + + /// Converts a screen-level offset to a [TextPosition] that sits at that + /// global offset. + TextPosition _globalOffsetToTextPosition(Offset globalOffset) { + return _textLayout.getPositionNearestToOffset( + _globalOffsetToTextOffset(globalOffset), + ); + } + + /// Returns the [TextPosition] that's nearest to the given [localOffset] within + /// this [IOSTextFieldInteractor]. + TextPosition? _getTextPositionNearestToOffset(Offset localOffset) { + // We show placeholder text when there is no text content. We don't want + // to place the caret in the placeholder text, so when _currentText is + // empty, explicitly set the text position to an offset of -1. + if (widget.textController.text.isEmpty) { + return const TextPosition(offset: -1); + } + + final globalOffset = (context.findRenderObject() as RenderBox).localToGlobal(localOffset); + final textOffset = + (widget.selectableTextKey.currentContext!.findRenderObject() as RenderBox).globalToLocal(globalOffset); + return _textLayout.getPositionNearestToOffset(textOffset); + } + + /// Returns the [TextPosition] that's at the given [localOffset] within + /// this [IOSTextFieldInteractor], or `null` if no text exists at the given + /// offset. + TextPosition? _getTextPositionAtOffset(Offset localOffset) { + // We show placeholder text when there is no text content. We don't want + // to place the caret in the placeholder text, so when _currentText is + // empty, explicitly set the text position to an offset of -1. + if (widget.textController.text.isEmpty) { + return const TextPosition(offset: -1); + } + + final globalOffset = (context.findRenderObject() as RenderBox).localToGlobal(localOffset); + final textOffset = + (widget.selectableTextKey.currentContext!.findRenderObject() as RenderBox).globalToLocal(globalOffset); + return _textLayout.getPositionAtOffset(textOffset); + } + + /// Returns a [TextSelection] that selects the word surrounding the given + /// [position]. + TextSelection _getWordSelectionAt(TextPosition position) { + return _textLayout.getWordSelectionAt(position); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _textViewportOffsetLink, + child: GestureDetector( + onTap: () { + _log.fine('Intercepting single tap'); + // This GestureDetector is here to prevent taps from going further + // up the tree. There must be an issue with the custom gesture detector + // used below that's allowing taps to bubble up even if handled. + // + // If this GestureDetector is placed any further down in this tree, + // it won't block the touch event. But it does from right here. + // + // TODO: fix the custom gesture detector in the RawGestureDetector. + }, + onDoubleTap: () { + _log.fine('Intercepting double tap'); + // no-op + }, + child: DecoratedBox( + decoration: BoxDecoration( + border: widget.showDebugPaint ? Border.all(color: Colors.purple) : const Border(), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + widget.child, + _buildExtentTrackerForMagnifier(), + _buildTrackerForToolbarFocus(), + _buildTapAndDragDetector(), + ], + ), + ), + ), + ); + } + + Widget _buildTapAndDragDetector() { + final gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; + return Positioned( + left: 0, + top: 0, + right: 0, + bottom: 0, + child: RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = _onTapDown + ..onTapUp = _onTapUp + ..onTapCancel = _onTapCancel + ..onDoubleTapDown = _onDoubleTapDown + ..onDoubleTapUp = _onDoubleTapUp + ..onDoubleTapCancel = _onDoubleTapCancel + ..onTripleTapDown = _onTripleTapDown + ..onTripleTapUp = _onTripleTapUp + ..onTripleTapCancel = _onTripleTapCancel + ..gestureSettings = gestureSettings; + }, + ), + PanGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (PanGestureRecognizer recognizer) { + recognizer + ..onStart = widget.focusNode.hasFocus ? _onPanStart : null + ..onUpdate = widget.focusNode.hasFocus ? _onPanUpdate : null + ..onEnd = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanEnd : null + ..onCancel = widget.focusNode.hasFocus || _isDraggingCaret ? _onPanCancel : null + ..gestureSettings = gestureSettings; + }, + ), + }, + ), + ); + } + + Widget _buildTrackerForToolbarFocus() { + return ValueListenableBuilder( + valueListenable: _toolbarFocusSelectionRect, + builder: (context, selectionRect, child) { + if (selectionRect == null) { + return const SizedBox(); + } + + return Positioned.fromRect( + rect: selectionRect, + child: Leader( + link: widget.editingOverlayController.toolbarFocalPoint, + child: widget.showDebugPaint ? ColoredBox(color: Colors.green.withValues(alpha: 0.2)) : const SizedBox(), + ), + ); + }, + ); + } + + void _computeSelectionRect() { + _previousToolbarFocusSelection = widget.textController.selection; + + if (!widget.textController.selection.isValid) { + _toolbarFocusSelectionRect.value = null; + return; + } + + if (widget.textController.selection.isCollapsed) { + // The selection is collapsed. + // Place the selection rect at the caret position. + final selectionExtent = widget.textController.selection.extent; + final caretOffset = _textLayout.getOffsetForCaret(selectionExtent); + final caretHeight = + _textLayout.getHeightForCaret(selectionExtent) ?? _textLayout.getLineHeightAtPosition(selectionExtent); + _toolbarFocusSelectionRect.value = Rect.fromLTWH(caretOffset.dx, caretOffset.dy, 0, caretHeight); + + return; + } + + // The selection is expanded. + // Make the selection rect include all selected characters. + final textBoxes = _textLayout.getBoxesForSelection(widget.textController.selection); + + Rect boundingBox = textBoxes.first.toRect(); + for (int i = 1; i < textBoxes.length; i += 1) { + boundingBox = boundingBox.expandToInclude(textBoxes[i].toRect()); + } + _toolbarFocusSelectionRect.value = boundingBox; + } + + /// Builds a tracking widget at the selection extent offset. + /// + /// The extent widget is tracked via [_draggingHandleLink] + Widget _buildExtentTrackerForMagnifier() { + if (!widget.textController.selection.isValid || _dragOffset == null) { + return const SizedBox(); + } + + return Positioned( + left: _dragOffset!.dx, + top: _dragOffset!.dy, + child: Leader( + link: widget.editingOverlayController.magnifierFocalPoint, + child: widget.showDebugPaint + ? FractionalTranslation( + translation: const Offset(-0.5, -0.5), + child: Container( + width: 20, + height: 20, + color: Colors.purpleAccent.withValues(alpha: 0.5), + ), + ) + : const SizedBox(width: 1, height: 1), + ), + ); + } +} diff --git a/super_editor/lib/src/super_textfield/metrics.dart b/super_editor/lib/src/super_textfield/metrics.dart new file mode 100644 index 0000000000..c1ccd8faaa --- /dev/null +++ b/super_editor/lib/src/super_textfield/metrics.dart @@ -0,0 +1,6 @@ +/// Minimum distance that should be maintained between the bottom of the caret +/// and the software keyboard when editing a `SuperTextField`. +const gapBetweenCaretAndKeyboard = 30; + +/// Gap between a floating toolbar and the content related to the toolbar. +const gapBetweenToolbarAndContent = 24.0; diff --git a/super_editor/lib/src/infrastructure/super_textfield/styles.dart b/super_editor/lib/src/super_textfield/styles.dart similarity index 87% rename from super_editor/lib/src/infrastructure/super_textfield/styles.dart rename to super_editor/lib/src/super_textfield/styles.dart index 24d2350158..f2704163bf 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/styles.dart +++ b/super_editor/lib/src/super_textfield/styles.dart @@ -1,7 +1,6 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/material.dart'; - -import '../../default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; const defaultSelectionColor = Color(0xFFACCEF7); const defaultDesktopCaretColor = Color(0xFF000000); @@ -15,6 +14,7 @@ TextStyle defaultTextFieldStyleBuilder(Set attributions) { TextStyle newStyle = const TextStyle( fontSize: 16, height: 1, + color: Colors.black, ); for (final attribution in attributions) { @@ -38,6 +38,10 @@ TextStyle defaultTextFieldStyleBuilder(Set attributions) { ? TextDecoration.lineThrough : TextDecoration.combine([TextDecoration.lineThrough, newStyle.decoration!]), ); + } else if (attribution is ColorAttribution) { + newStyle = newStyle.copyWith( + color: attribution.color, + ); } else if (attribution is LinkAttribution) { newStyle = newStyle.copyWith( color: Colors.lightBlue, diff --git a/super_editor/lib/src/super_textfield/super_text_field_keys.dart b/super_editor/lib/src/super_textfield/super_text_field_keys.dart new file mode 100644 index 0000000000..5978a9ec1e --- /dev/null +++ b/super_editor/lib/src/super_textfield/super_text_field_keys.dart @@ -0,0 +1,7 @@ +import 'package:flutter/foundation.dart'; + +class SuperTextFieldKeys { + static const caret = ValueKey("supertextfield_caret"); + + const SuperTextFieldKeys._(); +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/super_textfield.dart b/super_editor/lib/src/super_textfield/super_textfield.dart similarity index 51% rename from super_editor/lib/src/infrastructure/super_textfield/super_textfield.dart rename to super_editor/lib/src/super_textfield/super_textfield.dart index b6df92c261..887de51a8d 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/super_textfield.dart +++ b/super_editor/lib/src/super_textfield/super_textfield.dart @@ -3,12 +3,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/android/android_textfield.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/desktop/desktop_textfield.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_controller.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/hint_text.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/ios/ios_textfield.dart'; +import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; +import 'package:super_editor/src/super_textfield/android/android_textfield.dart'; +import 'package:super_editor/src/super_textfield/desktop/desktop_textfield.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/attributed_text_editing_controller.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/hint_text.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_gestures_interaction_overrides.dart'; +import 'package:super_editor/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; +import 'package:super_editor/src/super_textfield/ios/ios_textfield.dart'; +import 'package:super_editor/src/infrastructure/text_input.dart'; import 'package:super_text_layout/super_text_layout.dart'; import 'styles.dart'; @@ -19,55 +22,74 @@ export 'infrastructure/attributed_text_editing_controller.dart'; export 'infrastructure/hint_text.dart'; export 'infrastructure/magnifier.dart'; export 'infrastructure/text_scrollview.dart'; +export 'infrastructure/text_field_gestures_interaction_overrides.dart'; +export 'infrastructure/text_field_tap_handlers.dart'; export 'input_method_engine/_ime_text_editing_controller.dart'; export 'ios/ios_textfield.dart'; export 'styles.dart'; +export 'super_textfield_context.dart'; /// Custom text field implementations that offer greater control than traditional /// Flutter text fields. /// /// For example, the custom text fields in this package use [AttributedText] -/// instead of regular `String`s or `InlineSpan`s, which makes it easier style -/// text and add other text metadata. +/// instead of regular `String`s or `InlineSpan`s, which makes it easier to style +/// text and edit other text metadata. + +export "super_text_field_keys.dart"; /// Text field that supports styled text. /// /// [SuperTextField] adapts to the expectations of the current platform, or /// conforms to a specified [configuration]. /// -/// - desktop uses physical keyboard handlers with a blinking cursor and -/// mouse gestures -/// - Android uses IME text input with draggable handles in the Android style -/// - iOS uses IME text input with draggable handles in the iOS style +/// - desktop uses a blinking cursor and mouse gestures +/// - Android uses draggable handles in the Android style +/// - iOS uses draggable handles in the iOS style /// /// [SuperTextField] is built on top of platform-specific text field implementations, /// which may offer additional customization beyond that of [SuperTextField]: /// -/// - [SuperDesktopTextField], which uses physical keyboard handlers and mouse -/// gestures -/// - [SuperAndroidTextField], which uses IME text input with Android-style handles -/// - [SuperIOSTextField], which uses IME text input with iOS-style handles +/// - [SuperDesktopTextField], configured for a typical desktop experience. +/// - [SuperAndroidTextField], configured for a typical Android experience. +/// - [SuperIOSTextField], configured for a typical iOS experience. class SuperTextField extends StatefulWidget { const SuperTextField({ Key? key, this.focusNode, + this.tapRegionGroupId, this.configuration, this.textController, - this.textAlign = TextAlign.left, + this.textAlign, this.textStyleBuilder = defaultTextFieldStyleBuilder, + this.inlineWidgetBuilders = const [], this.hintBehavior = HintBehavior.displayHintUntilFocus, this.hintBuilder, this.controlsColor, + this.caretStyle, + this.blinkTimingMode = BlinkTimingMode.ticker, this.selectionColor, this.minLines, this.maxLines = 1, this.lineHeight, - this.keyboardHandlers = defaultTextFieldKeyboardHandlers, + this.inputSource, + this.keyboardHandlers, + this.selectorHandlers, + this.tapHandlers = const [], this.padding, + this.textInputAction, + this.imeConfiguration, + this.showComposingUnderline, }) : super(key: key); final FocusNode? focusNode; + /// {@template super_text_field_tap_region_group_id} + /// An optional group ID for a tap region that surrounds this text field + /// and also surrounds any related widgets, such as drag handles and a toolbar. + /// {@endtemplate} + final String? tapRegionGroupId; + /// The platform-style configuration for this text field, or `null` to /// automatically configure for the current platform. final SuperTextFieldPlatformConfiguration? configuration; @@ -77,12 +99,23 @@ class SuperTextField extends StatefulWidget { final AttributedTextEditingController? textController; /// The alignment of the text in this text field. - final TextAlign textAlign; + /// + /// If `null`, the text alignment is determined by the text direction + /// of the content. + final TextAlign? textAlign; /// Text style factory that creates styles for the content in /// [textController] based on the attributions in that content. final AttributionStyleBuilder textStyleBuilder; + /// {@template super_text_field_inline_widget_builders} + /// A Chain of Responsibility that's used to build inline widgets. + /// + /// The first builder in the chain to return a non-null `Widget` will be + /// used for a given inline placeholder. + /// {@endtemplate} + final InlineWidgetBuilderChain inlineWidgetBuilders; + /// Policy for when the hint should be displayed. final HintBehavior hintBehavior; @@ -92,8 +125,20 @@ class SuperTextField extends StatefulWidget { final WidgetBuilder? hintBuilder; /// The color of the caret, drag handles, and other controls. + /// + /// The color in [caretStyle] overrides the [controlsColor]. final Color? controlsColor; + /// The visual representation of the caret. + /// + /// The color in [caretStyle] overrides the [controlsColor]. + final CaretStyle? caretStyle; + + /// The timing mechanism used to blink, e.g., `Ticker` or `Timer`. + /// + /// `Timer`s are not expected to work in tests. + final BlinkTimingMode blinkTimingMode; + /// The color of selection rectangles that appear around selected text. final Color? selectionColor; @@ -136,28 +181,69 @@ class SuperTextField extends StatefulWidget { /// provided and used for all text field height calculations. final double? lineHeight; + /// The [SuperTextField] input source, e.g., keyboard or Input Method Engine. + /// + /// Only used on desktop. On mobile platforms, only [TextInputSource.ime] is available. + final TextInputSource? inputSource; + /// Priority list of handlers that process all physical keyboard /// key presses, for text input, deletion, caret movement, etc. /// /// Only used on desktop. - final List keyboardHandlers; + final List? keyboardHandlers; + + /// Handlers for all Mac OS "selectors" reported by the IME. + /// + /// The IME reports selectors as unique `String`s, therefore selector handlers are + /// defined as a mapping from selector names to handler functions. + final Map? selectorHandlers; + + /// {@template super_text_field_tap_handlers} + /// Optional list of handlers that respond to taps on content, e.g., opening + /// a link when the user taps on text with a link attribution. + /// + /// If a handler returns [TapHandlingInstruction.halt], no subsequent handlers + /// nor the default tap behavior will be executed. + /// {@endtemplate} + final List tapHandlers; /// Padding placed around the text content of this text field, but within the /// scrollable viewport. final EdgeInsets? padding; + /// The main action for the virtual keyboard, e.g. [TextInputAction.done]. + /// + /// This property is ignored when an [imeConfiguration] is provided. + /// + /// When `null`, and in single-line mode, the action will be [TextInputAction.done], + /// and when in multi-line mode, the action will be [TextInputAction.newline]. + /// + /// Only used on mobile. + @Deprecated('This will be removed in a future release. Use imeConfiguration instead') + final TextInputAction? textInputAction; + + /// Preferences for how the platform IME should look and behave during editing. + final TextInputConfiguration? imeConfiguration; + + /// Whether to show an underline beneath the text in the composing region, or `null` + /// to let [SuperTextField] decide when to show the underline. + final bool? showComposingUnderline; + @override State createState() => SuperTextFieldState(); } -class SuperTextFieldState extends State { +class SuperTextFieldState extends State implements ImeInputOwner { final _platformFieldKey = GlobalKey(); + late FocusNode _focusNode; late ImeAttributedTextEditingController _controller; @override void initState() { super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + _controller = widget.textController != null ? widget.textController is ImeAttributedTextEditingController ? (widget.textController as ImeAttributedTextEditingController) @@ -169,6 +255,13 @@ class SuperTextFieldState extends State { void didUpdateWidget(SuperTextField oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + if (oldWidget.focusNode == null) { + _focusNode.dispose(); + } + _focusNode = widget.focusNode ?? FocusNode(); + } + if (widget.textController != oldWidget.textController) { _controller = widget.textController != null ? widget.textController is ImeAttributedTextEditingController @@ -178,14 +271,43 @@ class SuperTextFieldState extends State { } } + @override + void dispose() { + if (widget.focusNode == null) { + _focusNode.dispose(); + } + + super.dispose(); + } + + @visibleForTesting + bool get hasFocus => _focusNode.hasFocus; + @visibleForTesting AttributedTextEditingController get controller => _controller; @visibleForTesting ProseTextLayout get textLayout => (_platformFieldKey.currentState as ProseTextBlock).textLayout; + @visibleForTesting + @override + DeltaTextInputClient get imeClient { + switch (_configuration) { + case SuperTextFieldPlatformConfiguration.desktop: + // ignore: invalid_use_of_visible_for_testing_member + return (_platformFieldKey.currentState as SuperDesktopTextFieldState).imeClient; + case SuperTextFieldPlatformConfiguration.android: + return (_platformFieldKey.currentState as SuperAndroidTextFieldState).imeClient; + case SuperTextFieldPlatformConfiguration.iOS: + return (_platformFieldKey.currentState as SuperIOSTextFieldState).imeClient; + } + } + bool get _isMultiline => (widget.minLines ?? 1) != 1 || widget.maxLines != 1; + TextInputAction get _textInputAction => + widget.textInputAction ?? (_isMultiline ? TextInputAction.newline : TextInputAction.done); + SuperTextFieldPlatformConfiguration get _configuration { if (widget.configuration != null) { return widget.configuration!; @@ -204,6 +326,26 @@ class SuperTextFieldState extends State { } } + /// Returns the desired [TextInputSource] for this text field. + /// + /// If the [widget.inputSource] is configured, it is used. Otherwise, + /// the [TextInputSource] is chosen based on the platform. + TextInputSource get _inputSource { + if (widget.inputSource != null) { + return widget.inputSource!; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return TextInputSource.ime; + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return TextInputSource.keyboard; + } + } + /// Shortcuts that should be ignored on web. /// /// Without this we can't handle space and arrow keys inside [SuperTextField]. @@ -212,11 +354,11 @@ class SuperTextFieldState extends State { /// pressing [LogicalKeyboardKey.space] scrolls the scrollview. final Map _scrollShortcutOverrides = kIsWeb ? { - LogicalKeySet(LogicalKeyboardKey.space): DoNothingAndStopPropagationIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowUp): DoNothingAndStopPropagationIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowDown): DoNothingAndStopPropagationIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowLeft): DoNothingAndStopPropagationIntent(), - LogicalKeySet(LogicalKeyboardKey.arrowRight): DoNothingAndStopPropagationIntent(), + LogicalKeySet(LogicalKeyboardKey.space): const DoNothingAndStopPropagationIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const DoNothingAndStopPropagationIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const DoNothingAndStopPropagationIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationIntent(), } : const {}; @@ -226,44 +368,63 @@ class SuperTextFieldState extends State { case SuperTextFieldPlatformConfiguration.desktop: return SuperDesktopTextField( key: _platformFieldKey, - focusNode: widget.focusNode, + focusNode: _focusNode, + tapRegionGroupId: widget.tapRegionGroupId, textController: _controller, textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, + inlineWidgetBuilders: widget.inlineWidgetBuilders, hintBehavior: widget.hintBehavior, hintBuilder: widget.hintBuilder, selectionHighlightStyle: SelectionHighlightStyle( color: widget.selectionColor ?? defaultSelectionColor, ), - caretStyle: CaretStyle( - color: widget.controlsColor ?? defaultDesktopCaretColor, - width: 1, - borderRadius: BorderRadius.zero, - ), + caretStyle: widget.caretStyle ?? + CaretStyle( + color: widget.controlsColor ?? defaultDesktopCaretColor, + width: 1, + borderRadius: BorderRadius.zero, + ), minLines: widget.minLines, maxLines: widget.maxLines, keyboardHandlers: widget.keyboardHandlers, + selectorHandlers: widget.selectorHandlers, + tapHandlers: widget.tapHandlers, padding: widget.padding ?? EdgeInsets.zero, + inputSource: _inputSource, + textInputAction: _textInputAction, + imeConfiguration: widget.imeConfiguration, + showComposingUnderline: widget.showComposingUnderline ?? defaultTargetPlatform == TargetPlatform.macOS, + blinkTimingMode: widget.blinkTimingMode, ); case SuperTextFieldPlatformConfiguration.android: return Shortcuts( shortcuts: _scrollShortcutOverrides, child: SuperAndroidTextField( key: _platformFieldKey, - focusNode: widget.focusNode, + focusNode: _focusNode, + tapRegionGroupId: widget.tapRegionGroupId, + tapHandlers: widget.tapHandlers, textController: _controller, textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, + inlineWidgetBuilders: widget.inlineWidgetBuilders, hintBehavior: widget.hintBehavior, hintBuilder: widget.hintBuilder, - caretColor: widget.controlsColor ?? defaultAndroidControlsColor, + caretStyle: widget.caretStyle ?? + CaretStyle( + color: widget.controlsColor ?? defaultAndroidControlsColor, + ), selectionColor: widget.selectionColor ?? defaultSelectionColor, handlesColor: widget.controlsColor ?? defaultAndroidControlsColor, minLines: widget.minLines, maxLines: widget.maxLines, lineHeight: widget.lineHeight, - textInputAction: _isMultiline ? TextInputAction.newline : TextInputAction.done, + textInputAction: _textInputAction, + imeConfiguration: widget.imeConfiguration, + showComposingUnderline: widget.showComposingUnderline ?? true, padding: widget.padding, + blinkTimingMode: widget.blinkTimingMode, ), ); case SuperTextFieldPlatformConfiguration.iOS: @@ -271,20 +432,29 @@ class SuperTextFieldState extends State { shortcuts: _scrollShortcutOverrides, child: SuperIOSTextField( key: _platformFieldKey, - focusNode: widget.focusNode, + focusNode: _focusNode, + tapRegionGroupId: widget.tapRegionGroupId, + tapHandlers: widget.tapHandlers, textController: _controller, textAlign: widget.textAlign, textStyleBuilder: widget.textStyleBuilder, + inlineWidgetBuilders: widget.inlineWidgetBuilders, + padding: widget.padding, hintBehavior: widget.hintBehavior, hintBuilder: widget.hintBuilder, - caretColor: widget.controlsColor ?? defaultIOSControlsColor, + caretStyle: widget.caretStyle ?? + CaretStyle( + color: widget.controlsColor ?? defaultIOSControlsColor, + ), selectionColor: widget.selectionColor ?? defaultSelectionColor, handlesColor: widget.controlsColor ?? defaultIOSControlsColor, minLines: widget.minLines, maxLines: widget.maxLines, lineHeight: widget.lineHeight, - textInputAction: _isMultiline ? TextInputAction.newline : TextInputAction.done, - padding: widget.padding, + textInputAction: _textInputAction, + imeConfiguration: widget.imeConfiguration, + showComposingUnderline: widget.showComposingUnderline ?? true, + blinkTimingMode: widget.blinkTimingMode, ), ); } diff --git a/super_editor/lib/src/super_textfield/super_textfield_context.dart b/super_editor/lib/src/super_textfield/super_textfield_context.dart new file mode 100644 index 0000000000..3e747961f9 --- /dev/null +++ b/super_editor/lib/src/super_textfield/super_textfield_context.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/attributed_text_editing_controller.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_scroller.dart'; +import 'package:super_editor/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +/// Collection of core artifacts used to interact with, and edit, a text field. +class SuperTextFieldContext { + SuperTextFieldContext({ + required this.textFieldBuildContext, + required this.focusNode, + required this.controller, + required this.getTextLayout, + required this.scroller, + }); + + /// A [BuildContext] that's bound to the text field. + /// + /// This [BuildContext] is provided so that behaviors, like key handlers, can + /// interact with ancestor widgets, such as ancestor scrollables. + /// + /// This context may or may not point to the root [Element] of the text field. That + /// said, it will point to an [Element] near the root of the text field, and the + /// associated render object will match the outer bounds of the text field. + final BuildContext textFieldBuildContext; + + /// The [FocusNode] associated with the text field. + final FocusNode focusNode; + + /// Controller that owns the text content, selection, composing region and any + /// other text-editing state for the associated text field. + final AttributedTextEditingController controller; + + /// The [controller], cast as an [ImeAttributedTextEditingController], or `null` + /// if [controller] is not an [ImeAttributedTextEditingController]. + ImeAttributedTextEditingController? get imeController => + controller is ImeAttributedTextEditingController ? controller as ImeAttributedTextEditingController : null; + + /// Returns a `Function`, which, when invoked, returns a reference to the + /// text field's [ProseTextLayout], which can be used to query the visual + /// bounds of text. + final SuperTextFieldLayoutResolver getTextLayout; + + /// Controller to query and change the scroll offset within the associated + /// text field. + final TextFieldScroller scroller; +} + +/// Function that returns the text layout for a text field. +typedef SuperTextFieldLayoutResolver = ProseTextLayout Function(); diff --git a/super_editor/lib/src/test/flutter_extensions/finders.dart b/super_editor/lib/src/test/flutter_extensions/finders.dart new file mode 100644 index 0000000000..994951dbf7 --- /dev/null +++ b/super_editor/lib/src/test/flutter_extensions/finders.dart @@ -0,0 +1,43 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension Finders on CommonFinders { + /// Finds [StatefulElement]s whose [State] is of type [StateType], optionally + /// scoped to the given [subtreeScope], and returns the element's associated + /// [StateType] object. + /// + /// This method expects to find, at most, one [StateType] object. + /// + /// Example - assume a widget MyWidget with a state MyWidgetState: + /// + /// final state = find.state(); + /// state?.myCustomStateMethod(); + /// + StateType? state([Finder? subtreeScope]) { + final elementFinder = + find.byElementPredicate((element) => element is StatefulElement && element.state is StateType); + final Finder stateFinder = + subtreeScope != null ? find.descendant(of: subtreeScope, matching: elementFinder) : elementFinder; + + final finderResult = stateFinder.evaluate(); + if (finderResult.length > 1) { + throw Exception("Expected to find no more than one $StateType, but found ${finderResult.length}"); + } + if (finderResult.isEmpty) { + return null; + } + + final foundElement = stateFinder.evaluate().single as StatefulElement; + return foundElement.state as StateType; + } +} + +class FindsNothing extends Finder { + @override + String get description => "Finder that matches nothing so that a Finder may be returned in defunct situations"; + + @override + Iterable apply(Iterable candidates) { + return List.empty(); + } +} diff --git a/super_editor/lib/src/test/flutter_extensions/flutter_extensions_stub.dart b/super_editor/lib/src/test/flutter_extensions/flutter_extensions_stub.dart new file mode 100644 index 0000000000..819b690252 --- /dev/null +++ b/super_editor/lib/src/test/flutter_extensions/flutter_extensions_stub.dart @@ -0,0 +1,4 @@ +// The purpose of this file is to act as a stub for web builds where +// we don't want to expose test-related extensions. +// +// See https://github.com/Flutter-Bounty-Hunters/super_editor/issues/2895 for details. diff --git a/super_editor/lib/src/test/flutter_extensions/test_documents.dart b/super_editor/lib/src/test/flutter_extensions/test_documents.dart new file mode 100644 index 0000000000..53b8661d9a --- /dev/null +++ b/super_editor/lib/src/test/flutter_extensions/test_documents.dart @@ -0,0 +1,279 @@ +import 'package:super_editor/super_editor.dart'; + +MutableDocument paragraphThenHrThenParagraphDoc() => MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("This is the first node in a document.")), + HorizontalRuleNode(id: "2"), + ParagraphNode(id: "3", text: AttributedText("This is the third node in a document.")), + ], + ); + +MutableDocument paragraphThenHrDoc() => MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("Paragraph 1")), + HorizontalRuleNode(id: "2"), + ], + ); + +MutableDocument hrThenParagraphDoc() => MutableDocument( + nodes: [ + HorizontalRuleNode(id: "1"), + ParagraphNode(id: "2", text: AttributedText("Paragraph 1")), + ], + ); + +MutableDocument singleParagraphEmptyDoc() => MutableDocument.empty("1"); + +MutableDocument twoParagraphEmptyDoc() => MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText()), + ParagraphNode(id: "2", text: AttributedText()), + ], + ); + +MutableDocument singleParagraphDoc() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + // String length is 445 + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + ), + ), + ], + ); + +MutableDocument singleParagraphDocShortText() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + // End position is 37. + "This is the first node in a document.", + ), + ), + ], + ); + +MutableDocument singleParagraphWithLinkDoc() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + // "link" is 26->30 + "This paragraph includes a link that the user can tap", + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: LinkAttribution("https://fake.url"), + offset: 26, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: LinkAttribution("https://fake.url"), + offset: 30, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ), + ], + ); + +MutableDocument singleParagraphShortDoc() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + // String length is 445 + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + ), + ), + ], + ); + +MutableDocument singleBlockDoc() => MutableDocument( + nodes: [ + HorizontalRuleNode(id: "1"), + ], + ); + +MutableDocument singleOrderedListItemDoc() => MutableDocument( + nodes: [ + ListItemNode( + id: "1", + itemType: ListItemType.ordered, + text: AttributedText( + "This is an ordered list item.", + ), + ), + ], + ); + +MutableDocument orderedListItemFollowedByEmptyParagraph() => MutableDocument( + nodes: [ + ListItemNode( + id: "1", + itemType: ListItemType.ordered, + text: AttributedText( + "This is an ordered list item.", + ), + ), + ParagraphNode(id: "2", text: AttributedText()), + ], + ); + +MutableDocument longTextDoc() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "3", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "4", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ], + ); + +MutableDocument longDoc() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "3", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "4", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ParagraphNode( + id: "5", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "6", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "7", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "8", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ParagraphNode( + id: "9", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "10", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "11", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "12", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ParagraphNode( + id: "13", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "14", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "15", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "16", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ParagraphNode( + id: "17", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "18", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "19", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "20", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ], + ); diff --git a/super_editor/lib/src/test/flutter_extensions/test_flutter_extensions.dart b/super_editor/lib/src/test/flutter_extensions/test_flutter_extensions.dart new file mode 100644 index 0000000000..ffb0a79b02 --- /dev/null +++ b/super_editor/lib/src/test/flutter_extensions/test_flutter_extensions.dart @@ -0,0 +1,13 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/infrastructure/flutter/render_box.dart'; + +/// Extension that accesses a [Finder]'s results assuming it finds a [RenderBox]. +extension RenderBoxAccess on Finder { + /// Assumes this [Finder] found a single [RenderBox] and returns that [RenderBox]'s + /// bounds in the global coordinate space. + Rect get globalRect => asRenderBox.globalRect; + + /// Assumes this [Finder] found a single [RenderBox] and returns that [RenderBox]. + RenderBox get asRenderBox => evaluate().first.renderObject as RenderBox; +} diff --git a/super_editor/lib/src/test/flutter_extensions/test_tools_goldens.dart b/super_editor/lib/src/test/flutter_extensions/test_tools_goldens.dart new file mode 100644 index 0000000000..240e466309 --- /dev/null +++ b/super_editor/lib/src/test/flutter_extensions/test_tools_goldens.dart @@ -0,0 +1,308 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; + +/// Runs one golden test for every platform, e.g., Android, iOS, Web, Mac, Windows, Linux. +/// +/// It's the job of the test implementation to include the platform name in the golden +/// file name - if this is not done, all tests will overwrite the same golden file(s). +@isTest +void testGoldensOnAllPlatforms( + String description, + WidgetTesterCallback test, { + bool skip = false, + Size? windowSize, +}) { + testGoldensOnAndroid(description, test, windowSize: windowSize, skip: skip); + testGoldensOniOS(description, test, windowSize: windowSize, skip: skip); + testGoldensOnMac(description, test, windowSize: windowSize, skip: skip); + testGoldensOnWindows(description, test, windowSize: windowSize, skip: skip); + testGoldensOnLinux(description, test, windowSize: windowSize, skip: skip); +} + +/// Runs one golden test for Android and iOS. +/// +/// It's the job of the test implementation to include the platform name in the golden +/// file name - if this is not done, all tests will overwrite the same golden file(s). +@isTest +void testGoldensOnMobile( + String description, + WidgetTesterCallback test, { + bool skip = false, + Size? windowSize, +}) { + testGoldensOnAndroid(description, test, windowSize: windowSize, skip: skip); + testGoldensOniOS(description, test, windowSize: windowSize, skip: skip); +} + +/// A golden test that configures itself as a Android platform before executing the +/// given [test], and nullifies the Android configuration when the test is done. +@isTest +void testGoldensOnAndroid( + String description, + WidgetTesterCallback test, { + bool skip = false, + Size? windowSize, +}) { + testGoldens('$description (on Android)', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + // Adjust the size of the golden window/image as desired. + tester.viewSize = windowSize; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: skip); +} + +/// A golden test that configures itself as a iOS platform before executing the +/// given [test], and nullifies the iOS configuration when the test is done. +@isTest +void testGoldensOniOS( + String description, + WidgetTesterCallback test, { + bool skip = false, + Size? windowSize, +}) { + testGoldens('$description (on iOS)', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + // Adjust the size of the golden window/image as desired. + tester.viewSize = windowSize; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: skip); +} + +/// A golden test that configures itself as a Mac platform before executing the +/// given [test], and nullifies the Mac configuration when the test is done. +@isTest +void testGoldensOnMac( + String description, + WidgetTesterCallback test, { + bool skip = false, + Size? windowSize, +}) { + testGoldens(description, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + + // Adjust the size of the golden window/image as desired. + tester.viewSize = windowSize; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: skip); +} + +/// A golden test that configures itself as a Windows platform before executing the +/// given [test], and nullifies the Windows configuration when the test is done. +@isTest +void testGoldensOnWindows( + String description, + WidgetTesterCallback test, { + bool skip = false, + Size? windowSize, +}) { + testGoldens(description, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + + // Adjust the size of the golden window/image as desired. + tester.viewSize = windowSize; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: skip); +} + +/// A golden test that configures itself as a Linux platform before executing the +/// given [test], and nullifies the Linux configuration when the test is done. +@isTest +void testGoldensOnLinux( + String description, + WidgetTesterCallback test, { + bool skip = false, + Size? windowSize, +}) { + testGoldens(description, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + + // Adjust the size of the golden window/image as desired. + tester.viewSize = windowSize; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: skip); +} + +extension TesterWindowSize on WidgetTester { + set viewSize(Size? desiredSize) { + if (desiredSize == null) { + return; + } + + view.physicalSize = desiredSize; + addTearDown(() => view.resetPhysicalSize()); + } +} + +const goldenSizeXLarge = Size(2400, 1600); +const goldenSizeLarge = Size(1200, 800); +const goldenSizeLongStrip = Size(1000, 300); +const goldenSizeMedium = Size(800, 600); +const goldenSizeSmall = Size(600, 400); + +/// A matcher that expects given content to match the golden file referenced +/// by [key], allowing up to [maxPixelMismatchCount] different pixels before +/// considering the test to be a failure. +/// +/// Typically, the [key] is expected to be a relative file path from the given +/// test file, to the golden file, e.g., "goldens/my-golden-name.png". +/// +/// This matcher can be used by calling it in `expectLater()`, e.g., +/// +/// await expectLater( +/// find.byType(MaterialApp), +/// matchesGoldenFileWithPixelAllowance("goldens/my-golden-name.png", 20), +/// ); +/// +/// Typically, Flutter's golden system describes mismatches in terms of percentages. +/// But percentages are difficult to depend upon. Sometimes a relatively large percentage +/// doesn't matter, and sometimes a tiny percentage is critical. When it comes to ignoring +/// irrelevant mismatches, it's often more convenient to work in terms of pixels. This +/// matcher lets developers specify a maximum pixel mismatch count, instead of relying on +/// percentage differences across the entire golden image. +MatchesGoldenFile matchesGoldenFileWithPixelAllowance(Object key, int maxPixelMismatchCount, {int? version}) { + if (key is Uri) { + return MatchesGoldenFileWithPixelAllowance(key, maxPixelMismatchCount, version); + } else if (key is String) { + return MatchesGoldenFileWithPixelAllowance.forStringPath(key, maxPixelMismatchCount, version); + } + throw ArgumentError('Unexpected type for golden file: ${key.runtimeType}'); +} + +/// A special version of [MatchesGoldenFile] that allows a specified number of +/// pixels to be different between golden files before considering the test to +/// be a failure. +/// +/// Typically, this matcher is expected to be created by calling +/// [matchesGoldenFileWithPixelAllowance]. +class MatchesGoldenFileWithPixelAllowance extends MatchesGoldenFile { + /// Creates a [MatchesGoldenFileWithPixelAllowance] that looks for a golden + /// file at the relative path within the [key] URI. + /// + /// The [key] URI should be a relative path from the executing test's + /// directory to the golden file, e.g., "goldens/my-golden-name.png". + MatchesGoldenFileWithPixelAllowance(super.key, this._maxPixelMismatchCount, [super.version]); + + /// Creates a [MatchesGoldenFileWithPixelAllowance] that looks for a golden + /// file at the relative [path]. + /// + /// The [path] should be relative to the executing test's directory, e.g., + /// "goldens/my-golden-name.png". + MatchesGoldenFileWithPixelAllowance.forStringPath(String path, this._maxPixelMismatchCount, [int? version]) + : super.forStringPath(path, version); + + final int _maxPixelMismatchCount; + + @override + Future matchAsync(dynamic item) async { + // Cache the current goldenFileComparator so we can restore + // it after the test. + final originalComparator = goldenFileComparator; + + try { + goldenFileComparator = PixelDiffGoldenComparator( + (goldenFileComparator as LocalFileComparator).basedir.path, + pixelCount: _maxPixelMismatchCount, + ); + + return await super.matchAsync(item); + } finally { + goldenFileComparator = originalComparator; + } + } +} + +/// A golden file comparator that allows a specified number of pixels +/// to be different between the golden image file and the test image file, and +/// still pass. +class PixelDiffGoldenComparator extends LocalFileComparator { + PixelDiffGoldenComparator( + String testBaseDirectory, { + required int pixelCount, + }) : _testBaseDirectory = testBaseDirectory, + _maxPixelMismatchCount = pixelCount, + super(Uri.parse(testBaseDirectory)); + + @override + Uri get basedir => Uri.parse(_testBaseDirectory); + + /// The file system path to the directory that holds the currently executing + /// Dart test file. + final String _testBaseDirectory; + + /// The maximum number of mismatched pixels for which this pixel test + /// is considered a success/pass. + final int _maxPixelMismatchCount; + + @override + Future compare(Uint8List imageBytes, Uri golden) async { + // Note: the incoming `golden` Uri is a partial path from the currently + // executing test directory to the golden file, e.g., "goldens/my-test.png". + final result = await GoldenFileComparator.compareLists( + imageBytes, + await getGoldenBytes(golden), + ); + + if (result.passed) { + return true; + } + + final diffImage = result.diffs!.entries.first.value; + final pixelCount = diffImage.width * diffImage.height; + final pixelMismatchCount = pixelCount * result.diffPercent; + + if (pixelMismatchCount <= _maxPixelMismatchCount) { + return true; + } + + // Paint the golden diffs and images to failure files. + await generateFailureOutput(result, golden, basedir); + throw FlutterError( + "Pixel test failed. ${result.diffPercent.toStringAsFixed(2)}% diff, $pixelMismatchCount pixel count diff (max allowed pixel mismatch count is $_maxPixelMismatchCount)"); + } + + @override + @protected + Future> getGoldenBytes(Uri golden) async { + final File goldenFile = _getGoldenFile(golden); + if (!goldenFile.existsSync()) { + fail('Could not be compared against non-existent file: "$golden"'); + } + final List goldenBytes = await goldenFile.readAsBytes(); + return goldenBytes; + } + + File _getGoldenFile(Uri golden) => File(path.join(_testBaseDirectory, path.fromUri(golden.path))); +} diff --git a/super_editor/lib/src/test/flutter_extensions/test_tools_user_input.dart b/super_editor/lib/src/test/flutter_extensions/test_tools_user_input.dart new file mode 100644 index 0000000000..c416d01b5e --- /dev/null +++ b/super_editor/lib/src/test/flutter_extensions/test_tools_user_input.dart @@ -0,0 +1,262 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/super_editor.dart'; + +final inputSourceVariant = ValueVariant({ + TextInputSource.keyboard, + TextInputSource.ime, +}); + +final inputAndGestureVariants = ValueVariant( + { + const InputAndGestureTuple(TextInputSource.keyboard, DocumentGestureMode.mouse), + const InputAndGestureTuple(TextInputSource.keyboard, DocumentGestureMode.iOS), + const InputAndGestureTuple(TextInputSource.keyboard, DocumentGestureMode.android), + const InputAndGestureTuple(TextInputSource.ime, DocumentGestureMode.mouse), + const InputAndGestureTuple(TextInputSource.ime, DocumentGestureMode.iOS), + const InputAndGestureTuple(TextInputSource.ime, DocumentGestureMode.android), + }, +); + +/// A combination of an [inputSource] and a [gestureMode]. +class InputAndGestureTuple { + const InputAndGestureTuple(this.inputSource, this.gestureMode); + + final TextInputSource inputSource; + final DocumentGestureMode gestureMode; + + @override + String toString() { + return '${inputSource.name} Input Source & ${gestureMode.name} Gesture Mode'; + } +} + +/// A [TextInputConnection] that tracks the number of content updates, to verify +/// within tests. +class ImeConnectionWithUpdateCount extends TextInputConnectionDecorator { + ImeConnectionWithUpdateCount(TextInputConnection client) : super(client); + + int get contentUpdateCount => _contentUpdateCount; + int _contentUpdateCount = 0; + + @override + void setEditingState(TextEditingValue value) { + super.setEditingState(value); + _contentUpdateCount += 1; + } +} + +/// An [AutomatedTestWidgetsFlutterBinding] with a fake [HardwareKeyboard], which can be used +/// to simulate keys being pressed while new keys are pressed, e.g., `CMD` being pressed when +/// the user then presses `C` to copy or `V` to paste. +/// +/// When this binding is instantiated, it replaces the standard Flutter test binding. Once this +/// happens, this binding cannot be removed within the given test file. The binding won't be +/// reset until the next test file runs. Therefore, testers must ensure that the presence of this +/// binding throughout a test file won't impact other tests. +/// +/// To help prevent issues with this binding being used in unrelated tests in the same file, +/// a concept of "activation" is included. When this binding is `activate()`d, fakes are +/// be used. When this binding is `deactivate()`d, the regular superclass behaviors are +/// used. A test can use these behaviors as follows: +/// +/// ```dart +/// void main() { +/// final fakeServicesBinding = FakeServicesBinding(); +/// +/// testWidgets('my regular test', (tester) async { +/// // This test doesn't care about the binding. +/// }); +/// +/// testWidgets('my fake binding test', (tester) async { +/// // This test wants to use the fake binding. Activate it. +/// fakeServicesBinding.activate(); +/// +/// // Ensure we deactivate the fake services binding after this test. +/// addTearDown(() => fakeServicesBinding.deactivate()); +/// +/// // Use the binding +/// fakeServicesBinding.fakeKeyboard.isMetaPressed = true; +/// }); +/// } +/// ``` +class FakeServicesBinding extends AutomatedTestWidgetsFlutterBinding { + @override + void initInstances() { + fakeKeyboard = FakeHardwareKeyboard(); + super.initInstances(); + } + + late final FakeHardwareKeyboard fakeKeyboard; + + void activate() => _isActive = true; + bool _isActive = false; + void deactivate() => _isActive = false; + + @override + HardwareKeyboard get keyboard => _isActive ? fakeKeyboard : super.keyboard; +} + +/// A fake [HardwareKeyboard], which can be used to simulate keys being pressed while new +/// keys are pressed, e.g., `CMD` being pressed when the user then presses `C` to copy +/// or `V` to paste. +class FakeHardwareKeyboard extends HardwareKeyboard { + FakeHardwareKeyboard({ + this.isAltPressed = false, + this.isControlPressed = false, + this.isMetaPressed = false, + this.isShiftPressed = false, + }); + + @override + bool isMetaPressed; + @override + bool isControlPressed; + @override + bool isAltPressed; + @override + bool isShiftPressed; + + @override + bool isLogicalKeyPressed(LogicalKeyboardKey key) { + return switch (key) { + LogicalKeyboardKey.shift || LogicalKeyboardKey.shiftLeft || LogicalKeyboardKey.shiftRight => isShiftPressed, + LogicalKeyboardKey.alt || LogicalKeyboardKey.altLeft || LogicalKeyboardKey.altRight => isAltPressed, + LogicalKeyboardKey.control || + LogicalKeyboardKey.controlLeft || + LogicalKeyboardKey.controlRight => + isControlPressed, + LogicalKeyboardKey.meta || LogicalKeyboardKey.metaLeft || LogicalKeyboardKey.metaRight => isMetaPressed, + _ => super.isLogicalKeyPressed(key) + }; + } +} + +/// Generates the default shortcut key bindings based on the [defaultTargetPlatform]. +/// +/// Copied from [WidgetsApp.defaultShortcuts] to make it possible to force the usage of web shortcuts. +Map get defaultFlutterShortcuts { + if (CurrentPlatform.isWeb) { + return defaultWebShortcuts; + } + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return defaultNonAppleShortcuts; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return defaultAppleShortcuts; + } +} + +/// Default shortcuts for Windows, Linux, Android and Fuchsia. +/// +/// Copied from [WidgetsApp._defaultShortcuts] because Flutter doesn't expose it and +/// we need to provide these shortcuts during tests in order to be able to simulate +/// web platforms during tests. +/// +/// This map must be kept up to date with [WidgetsApp._defaultShortcuts]. +const Map defaultNonAppleShortcuts = { + // Activation + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.select): ActivateIntent(), + + // Dismissal + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + + // Keyboard traversal. + SingleActivator(LogicalKeyboardKey.tab): NextFocusIntent(), + SingleActivator(LogicalKeyboardKey.tab, shift: true): PreviousFocusIntent(), + SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down), + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), + + // Scrolling + SingleActivator(LogicalKeyboardKey.arrowUp, control: true): ScrollIntent(direction: AxisDirection.up), + SingleActivator(LogicalKeyboardKey.arrowDown, control: true): ScrollIntent(direction: AxisDirection.down), + SingleActivator(LogicalKeyboardKey.arrowLeft, control: true): ScrollIntent(direction: AxisDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight, control: true): ScrollIntent(direction: AxisDirection.right), + SingleActivator(LogicalKeyboardKey.pageUp): ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page), + SingleActivator(LogicalKeyboardKey.pageDown): + ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), +}; + +/// Default shortcuts for the Apple platforms. +/// +/// Copied from [WidgetsApp._defaultAppleOsShortcuts] because Flutter doesn't expose it and +/// we need to provide these shortcuts during tests in order to be able to simulate +/// web platforms during tests. +/// +/// This map must be kept up to date with [WidgetsApp._defaultAppleOsShortcuts]. +const Map defaultAppleShortcuts = { + // Activation + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + + // Dismissal + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + + // Keyboard traversal + SingleActivator(LogicalKeyboardKey.tab): NextFocusIntent(), + SingleActivator(LogicalKeyboardKey.tab, shift: true): PreviousFocusIntent(), + SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right), + SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down), + SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up), + + // Scrolling + SingleActivator(LogicalKeyboardKey.arrowUp, meta: true): ScrollIntent(direction: AxisDirection.up), + SingleActivator(LogicalKeyboardKey.arrowDown, meta: true): ScrollIntent(direction: AxisDirection.down), + SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): ScrollIntent(direction: AxisDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): ScrollIntent(direction: AxisDirection.right), + SingleActivator(LogicalKeyboardKey.pageUp): ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page), + SingleActivator(LogicalKeyboardKey.pageDown): + ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), +}; + +/// Default shortcuts for web. +/// +/// Copied from [WidgetsApp._defaultWebShortcuts] because Flutter doesn't expose it and +/// we need to provide these shortcuts during tests in order to be able to simulate +/// web platforms during tests. +/// +/// This map must be kept up to date with [WidgetsApp._defaultWebShortcuts]. +const Map defaultWebShortcuts = { + // Activation + SingleActivator(LogicalKeyboardKey.space): PrioritizedIntents( + orderedIntents: [ + ActivateIntent(), + ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), + ], + ), + // On the web, enter activates buttons, but not other controls. + SingleActivator(LogicalKeyboardKey.enter): ButtonActivateIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter): ButtonActivateIntent(), + + // Dismissal + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + + // Keyboard traversal. + SingleActivator(LogicalKeyboardKey.tab): NextFocusIntent(), + SingleActivator(LogicalKeyboardKey.tab, shift: true): PreviousFocusIntent(), + + // Scrolling + SingleActivator(LogicalKeyboardKey.arrowUp): ScrollIntent(direction: AxisDirection.up), + SingleActivator(LogicalKeyboardKey.arrowDown): ScrollIntent(direction: AxisDirection.down), + SingleActivator(LogicalKeyboardKey.arrowLeft): ScrollIntent(direction: AxisDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight): ScrollIntent(direction: AxisDirection.right), + SingleActivator(LogicalKeyboardKey.pageUp): ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page), + SingleActivator(LogicalKeyboardKey.pageDown): + ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), +}; diff --git a/super_editor/lib/src/test/ime.dart b/super_editor/lib/src/test/ime.dart index 9f4ddc5d5c..efbad70563 100644 --- a/super_editor/lib/src/test/ime.dart +++ b/super_editor/lib/src/test/ime.dart @@ -2,6 +2,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; /// Provides access to an IME client, to simulate IME input within a test. /// @@ -9,6 +10,17 @@ import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; /// IME input. If no [finder] is provided, then the tree is searched for an [ImeInputOwner]. In /// that case, there must only be a single [ImeInputOwner] in the tree. DeltaTextInputClient imeClientGetter([Finder? finder]) { + if (finder == null) { + // Check, specifically, for a SuperTextField because SuperTextField internally contains other + // widgets that implement ImeInputOwner, so we have to manually disambiguate which one we want. + final superTextFieldImeOwner = _getSuperTextFieldImeClient(); + if (superTextFieldImeOwner != null) { + return superTextFieldImeOwner.imeClient; + } + } + + // There should only be one ImeInputOwner in the tree, or within the `finder`. + // Find it and return its IME client. final element = (finder ?? find.byElementPredicate((element) => element is StatefulElement && element.state is ImeInputOwner)) .evaluate() @@ -16,3 +28,12 @@ DeltaTextInputClient imeClientGetter([Finder? finder]) { final owner = element.state as ImeInputOwner; return owner.imeClient; } + +ImeInputOwner? _getSuperTextFieldImeClient() { + final superTextFieldElements = find.byType(SuperTextField).evaluate(); + if (superTextFieldElements.length != 1) { + return null; + } + + return (superTextFieldElements.single as StatefulElement).state as ImeInputOwner; +} diff --git a/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart b/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart index ac6e94f3a1..08016fb613 100644 --- a/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart +++ b/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart @@ -1,8 +1,8 @@ import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/src/default_editor/document_gestures_touch_android.dart'; -import 'package:super_editor/src/default_editor/document_gestures_touch_ios.dart'; +import 'package:super_editor/src/test/flutter_extensions/finders.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -21,6 +21,34 @@ class SuperEditorInspector { return superEditor.focusNode.hasFocus; } + /// Returns `true` if the given [SuperEditor] widget currently has an open IME connection, + /// or `false` if no IME connection is open, or if [SuperEditor] is in keyboard mode. + /// + /// {@template supereditor_finder} + /// By default, this method expects a single [SuperEditor] in the widget tree and + /// finds it `byType`. To specify one [SuperEditor] among many, pass a [superEditorFinder]. + /// {@endtemplate} + static bool isImeConnectionOpen([Finder? finder]) { + final element = (finder ?? find.byType(SuperEditor)).evaluate().single as StatefulElement; + final superEditor = element.widget as SuperEditor; + + // Keyboard mode never has an IME connection. + if (superEditor.inputSource == TextInputSource.keyboard) { + return false; + } + + final imeInteractorElement = find + .descendant( + of: find.byWidget(superEditor), + matching: find.byType(SuperEditorImeInteractor), + ) + .evaluate() + .single as StatefulElement; + final imeInteractor = imeInteractorElement.state as SuperEditorImeInteractorState; + + return imeInteractor.isAttachedToIme; + } + /// Returns the [Document] within the [SuperEditor] matched by [finder], /// or the singular [SuperEditor] in the widget tree, if [finder] is `null`. /// @@ -28,7 +56,17 @@ class SuperEditorInspector { static Document? findDocument([Finder? finder]) { final element = (finder ?? find.byType(SuperEditor)).evaluate().single as StatefulElement; final superEditor = element.state as SuperEditorState; - return superEditor.editContext.editor.document; + return superEditor.editContext.document; + } + + /// Returns the [DocumentComposer] within the [SuperEditor] matched by [finder], + /// or the singular [SuperEditor] in the widget tree, if [finder] is `null`. + /// + /// {@macro supereditor_finder} + static DocumentComposer? findComposer([Finder? finder]) { + final element = (finder ?? find.byType(SuperEditor)).evaluate().single as StatefulElement; + final superEditor = element.state as SuperEditorState; + return superEditor.editContext.composer; } /// Returns the current [DocumentSelection] for the [SuperEditor] matched by @@ -42,26 +80,24 @@ class SuperEditorInspector { return superEditor.editContext.composer.selection; } + /// Returns the current composing region for the [SuperEditor] matched by + /// [finder], or the singular [SuperEditor] in the widget tree, if [finder] + /// is `null`. + /// + /// {@macro supereditor_finder} + static DocumentRange? findComposingRegion([Finder? finder]) { + final element = (finder ?? find.byType(SuperEditor)).evaluate().single as StatefulElement; + final superEditor = element.state as SuperEditorState; + return superEditor.editContext.composer.composingRegion.value; + } + /// Returns the (x,y) offset for the caret that's currently visible in the document. static Offset findCaretOffsetInDocument([Finder? finder]) { - final desktopCaretBox = find.byKey(primaryCaretKey).evaluate().singleOrNull?.renderObject as RenderBox?; - if (desktopCaretBox != null) { - final globalCaretOffset = desktopCaretBox.localToGlobal(Offset.zero); - final documentLayout = _findDocumentLayout(finder); - final globalToDocumentOffset = documentLayout.getGlobalOffsetFromDocumentOffset(Offset.zero); - return globalCaretOffset - globalToDocumentOffset; - } - - final androidControls = find.byType(AndroidDocumentTouchEditingControls).evaluate().lastOrNull?.widget - as AndroidDocumentTouchEditingControls?; - if (androidControls != null) { - return androidControls.editingController.caretTop!; - } - - final iOSControls = - find.byType(IosDocumentTouchEditingControls).evaluate().lastOrNull?.widget as IosDocumentTouchEditingControls?; - if (iOSControls != null) { - return iOSControls.editingController.caretTop!; + final caret = find.byKey(DocumentKeys.caret).evaluate().singleOrNull?.renderObject as RenderBox?; + if (caret != null) { + final globalCaretOffset = caret.localToGlobal(Offset.zero); + final documentLayout = findDocumentLayout(finder); + return documentLayout.getDocumentOffsetFromAncestorOffset(globalCaretOffset); } throw Exception('Could not locate caret in document'); @@ -71,7 +107,7 @@ class SuperEditorInspector { /// /// {@macro supereditor_finder} static Offset findComponentOffset(String nodeId, Alignment alignment, [Finder? finder]) { - final documentLayout = _findDocumentLayout(finder); + final documentLayout = findDocumentLayout(finder); final component = documentLayout.getComponentByNodeId(nodeId); assert(component != null); final componentBox = component!.context.findRenderObject() as RenderBox; @@ -79,11 +115,22 @@ class SuperEditorInspector { return alignment.withinRect(rect); } + /// Returns the size of the component which renders the node with the given [nodeId]. + /// + /// {@macro supereditor_finder} + static Size findComponentSize(String nodeId, [Finder? finder]) { + final documentLayout = findDocumentLayout(finder); + final component = documentLayout.getComponentByNodeId(nodeId); + assert(component != null); + final componentBox = component!.context.findRenderObject() as RenderBox; + return componentBox.size; + } + /// Returns the (x,y) offset for a caret, if that caret appeared at the given [position]. /// /// {@macro supereditor_finder} static Offset calculateOffsetForCaret(DocumentPosition position, [Finder? finder]) { - final documentLayout = _findDocumentLayout(finder); + final documentLayout = findDocumentLayout(finder); final positionRect = documentLayout.getRectForPosition(position); assert(positionRect != null); return positionRect!.topLeft; @@ -94,7 +141,7 @@ class SuperEditorInspector { /// /// {@macro supereditor_finder} static bool isPositionVisibleGlobally(DocumentPosition position, Size globalSize, [Finder? finder]) { - final documentLayout = _findDocumentLayout(finder); + final documentLayout = findDocumentLayout(finder); final positionRect = documentLayout.getRectForPosition(position)!; final globalDocumentOffset = documentLayout.getGlobalOffsetFromDocumentOffset(Offset.zero); final globalPositionRect = positionRect.translate(globalDocumentOffset.dx, globalDocumentOffset.dy); @@ -113,8 +160,8 @@ class SuperEditorInspector { /// /// {@macro supereditor_finder} static WidgetType findWidgetForComponent(String nodeId, [Finder? superEditorFinder]) { - final documentLayout = _findDocumentLayout(superEditorFinder); - final widget = (documentLayout.getComponentByNodeId(nodeId) as TextComponentState).widget; + final documentLayout = findDocumentLayout(superEditorFinder); + final widget = (documentLayout.getComponentByNodeId(nodeId) as State).widget; if (widget is! WidgetType) { throw Exception("Looking for a component's widget. Expected type $WidgetType, but found ${widget.runtimeType}"); } @@ -122,6 +169,18 @@ class SuperEditorInspector { return widget as WidgetType; } + /// Same as [findWidgetForComponent], except this method returns `null` when no such + /// component is found. + static WidgetType? maybeFindWidgetForComponent(String nodeId, [Finder? superEditorFinder]) { + final documentLayout = findDocumentLayout(superEditorFinder); + final widget = (documentLayout.getComponentByNodeId(nodeId) as State?)?.widget; + if (widget != null && widget is! WidgetType) { + throw Exception("Looking for a component's widget. Expected type $WidgetType, but found ${widget.runtimeType}"); + } + + return widget as WidgetType; + } + /// Returns the [AttributedText] within the [ParagraphNode] associated with the /// given [nodeId]. /// @@ -129,9 +188,37 @@ class SuperEditorInspector { /// [SuperEditor]. /// /// {@macro supereditor_finder} - static AttributedText findTextInParagraph(String nodeId, [Finder? superEditorFinder]) { - final documentLayout = _findDocumentLayout(superEditorFinder); - return (documentLayout.getComponentByNodeId(nodeId) as TextComponentState).widget.text; + static AttributedText findTextInComponent(String nodeId, [Finder? superEditorFinder]) { + final documentLayout = findDocumentLayout(superEditorFinder); + final component = documentLayout.getComponentByNodeId(nodeId); + + if (component is TextComponentState) { + return component.widget.text; + } + + if (component is ProxyDocumentComponent) { + return (component.childDocumentComponentKey.currentState as TextComponentState).widget.text; + } + + throw Exception('The component for node id $nodeId is not a TextComponent.'); + } + + /// Finds the paragraph with the given [nodeId] and returns the paragraph's content as a [TextSpan]. + /// + /// A [TextSpan] is the fundamental way that Flutter styles text. It's the lowest level reflection + /// of what the user will see, short of rendering the actual UI. + /// + /// {@macro supereditor_finder} + static TextSpan findRichTextInParagraph(String nodeId, [Finder? superEditorFinder]) { + final documentLayout = findDocumentLayout(superEditorFinder); + + final component = documentLayout.getComponentByNodeId(nodeId) as DocumentComponent; + final superText = find + .descendant(of: find.byWidget(component.widget), matching: find.byType(SuperText)) + .evaluate() + .single + .widget as SuperText; + return superText.richText as TextSpan; } /// Finds and returns the [TextStyle] that's applied to the top-level of the [TextSpan] @@ -139,15 +226,61 @@ class SuperEditorInspector { /// /// {@macro supereditor_finder} static TextStyle? findParagraphStyle(String nodeId, [Finder? superEditorFinder]) { - final documentLayout = _findDocumentLayout(superEditorFinder); + return findRichTextInParagraph(nodeId, superEditorFinder).style; + } - final textComponentState = documentLayout.getComponentByNodeId(nodeId) as TextComponentState; - final superTextWithSelection = find - .descendant(of: find.byWidget(textComponentState.widget), matching: find.byType(SuperTextWithSelection)) + /// Finds the paragraph with the given [nodeId] and returns its indent level. + /// + /// Indent levels start at zero and increment by `1` for each level. + /// + /// {@macro supereditor_finder} + static int findParagraphIndent(String nodeId, [Finder? superEditorFinder]) { + final component = SuperEditorInspector.findWidgetForComponent(nodeId) as ParagraphComponent; + return component.viewModel.indent; + } + + /// Finds the ordered list item with the given [nodeId] and returns its ordinal value. + /// + /// List items ordinals start from 1. + /// + /// {@macro supereditor_finder} + static int findListItemOrdinal(String nodeId, [Finder? superEditorFinder]) { + final listItem = find + .ancestor( + of: find.byWidget(SuperEditorInspector.findWidgetForComponent(nodeId)), + matching: find.byType(OrderedListItemComponent), + ) .evaluate() .single - .widget as SuperTextWithSelection; - return superTextWithSelection.richText.style; + .widget as OrderedListItemComponent; + + return listItem.listIndex; + } + + /// Finds the task with the given [nodeId] and returns its indent level. + /// + /// Indent levels start at zero and increment by `1` for each level. + /// + /// {@macro supereditor_finder} + static int findTaskIndent(String nodeId, [Finder? superEditorFinder]) { + final component = SuperEditorInspector.findWidgetForComponent(nodeId) as TaskComponent; + return component.viewModel.indent; + } + + /// Calculates the delta between the center of the character at [textOffset1] and and the + /// center of the character at [textOffset2] within the node with the given [nodeId]. + /// + /// {@macro supereditor_finder} + static Offset findDeltaBetweenCharactersInTextNode(String nodeId, int textOffset1, int textOffset2, + [Finder? superEditorFinder]) { + final docLayout = findDocumentLayout(superEditorFinder); + final characterBoxStart = docLayout.getRectForPosition( + DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: textOffset1)), + ); + final characterBoxEnd = docLayout.getRectForPosition( + DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: textOffset2)), + ); + return characterBoxEnd!.center - characterBoxStart!.center; } /// Returns the [DocumentNode] at given the [index]. @@ -163,11 +296,11 @@ class SuperEditorInspector { throw Exception('SuperEditor not found'); } - if (index >= doc.nodes.length) { - throw Exception('Tried to access index $index in a document where the max index is ${doc.nodes.length - 1}'); + if (index >= doc.nodeCount) { + throw Exception('Tried to access index $index in a document where the max index is ${doc.nodeCount - 1}'); } - final node = doc.nodes[index]; + final node = doc.getNodeAt(index); if (node is! NodeType) { throw Exception('Tried to access a ${node.runtimeType} as $NodeType'); } @@ -176,15 +309,8 @@ class SuperEditorInspector { } /// Locates the first line break in a text node, or throws an exception if it cannot find one. - static int findOffsetOfLineBreak(String nodeId, [Finder? finder]) { - late final Finder layoutFinder; - if (finder != null) { - layoutFinder = find.descendant(of: finder, matching: find.byType(SingleColumnDocumentLayout)); - } else { - layoutFinder = find.byType(SingleColumnDocumentLayout); - } - final documentLayoutElement = layoutFinder.evaluate().single as StatefulElement; - final documentLayout = documentLayoutElement.state as DocumentLayout; + static int findOffsetOfLineBreak(String nodeId, [Finder? superEditorFinder]) { + final documentLayout = findDocumentLayout(superEditorFinder); final componentState = documentLayout.getComponentByNodeId(nodeId) as State; late final GlobalKey textComponentKey; @@ -204,7 +330,7 @@ class SuperEditorInspector { /// Finds the [DocumentLayout] that backs a [SuperEditor] in the widget tree. /// /// {@macro supereditor_finder} - static DocumentLayout _findDocumentLayout([Finder? superEditorFinder]) { + static DocumentLayout findDocumentLayout([Finder? superEditorFinder]) { late final Finder layoutFinder; if (superEditorFinder != null) { layoutFinder = find.descendant(of: superEditorFinder, matching: find.byType(SingleColumnDocumentLayout)); @@ -215,5 +341,352 @@ class SuperEditorInspector { return documentLayoutElement.state as DocumentLayout; } + /// Returns `true` if [SuperEditor]'s policy believes that a mobile toolbar should + /// be visible right now, or `false` otherwise. + /// + /// This inspection is different from [isMobileToolbarVisible] in a couple ways: + /// * On mobile web, [SuperEditor] defers to the browser's built-in overlay + /// controls. Therefore, [wantsMobileToolbarToBeVisible] is `true` but + /// [isMobileToolbarVisible] is `false`. + /// * When an app customizes the toolbar, [SuperEditor] might want to build + /// and display a toolbar, but the app overrode the toolbar widget and chose + /// to build empty space instead of a toolbar. In this case + /// [wantsMobileToolbarToBeVisible] is `true`, but [isMobileToolbarVisible] + /// is `false`. + static bool wantsMobileToolbarToBeVisible([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + final toolbarManager = find.state(superEditorFinder); + if (toolbarManager == null) { + throw Exception( + "Tried to verify that SuperEditor wants mobile toolbar to be visible on Android, but couldn't find the toolbar manager widget."); + } + return toolbarManager.wantsToDisplayToolbar; + case TargetPlatform.iOS: + final toolbarManager = find.state(superEditorFinder); + if (toolbarManager == null) { + throw Exception( + "Tried to verify that SuperEditor wants mobile toolbar to be visible on iOS, but couldn't find the toolbar manager widget."); + } + return toolbarManager.wantsToDisplayToolbar; + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return false; + } + } + + /// Returns `true` if the mobile floating toolbar is currently visible, or `false` + /// if it's not. + /// + /// The mobile floating toolbar looks different for iOS and Android, but on both + /// platforms it appears on top of the editor, near selected content. + /// + /// This method doesn't take a `superEditorFinder` because the toolbar is displayed + /// in the application overlay, and is therefore completely independent from the + /// [SuperEditor] subtree. There's no obvious way to associate a toolbar with + /// a specific [SuperEditor]. + /// + /// See also: [wantsMobileToolbarToBeVisible]. + static bool isMobileToolbarVisible() { + return find.byKey(DocumentKeys.mobileToolbar).evaluate().isNotEmpty; + } + + /// Returns `true` if [SuperEditor]'s policy believes that a mobile magnifier + /// should be visible right now, or `false` otherwise. + /// + /// This inspection is different from [isMobileMagnifierVisible] in a couple ways: + /// * On mobile web, [SuperEditor] defers to the browser's built-in overlay + /// controls. Therefore, [wantsMobileMagnifierToBeVisible] is `true` but + /// [isMobileMagnifierVisible] is `false`. + /// * When an app customizes the magnifier, [SuperEditor] might want to build + /// and display a magnifier, but the app overrode the magnifier widget and chose + /// to build empty space instead of a magnifier. In this case + /// [wantsMobileMagnifierToBeVisible] is `true`, but [isMobileMagnifierVisible] + /// is `false`. + static bool wantsMobileMagnifierToBeVisible([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + final magnifierManager = find.state(superEditorFinder); + if (magnifierManager == null) { + throw Exception( + "Tried to verify that SuperEditor wants mobile magnifier to be visible on Android, but couldn't find the magnifier manager widget."); + } + + return magnifierManager.wantsToDisplayMagnifier; + case TargetPlatform.iOS: + final magnifierManager = find.state(superEditorFinder); + if (magnifierManager == null) { + throw Exception( + "Tried to verify that SuperEditor wants mobile magnifier to be visible on iOS, but couldn't find the magnifier manager widget."); + } + + return magnifierManager.wantsToDisplayMagnifier; + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return false; + } + } + + /// Returns `true` if a mobile magnifier is currently visible, or `false` if it's + /// not. + /// + /// The mobile magnifier looks different for iOS and Android. The magnifier also + /// follows different focal points depending on whether it's iOS or Android. + /// But in both cases, a magnifier is a small shape near the user's finger or + /// selection, which shows the editor content at an enlarged/magnified level. + /// + /// This method doesn't take a `superEditorFinder` because the magnifier is displayed + /// in the application overlay, and is therefore completely independent from the + /// [SuperEditor] subtree. There's no obvious way to associate a magnifier with + /// a specific [SuperEditor]. + /// + /// See also: [wantsMobileMagnifierToBeVisible] + static bool isMobileMagnifierVisible() { + return find.byKey(DocumentKeys.magnifier).evaluate().isNotEmpty; + } + + /// Returns `true` if any type of mobile drag handles are visible, or `false` + /// if not. + /// + /// On iOS, drag handles include the caret, as well as the upstream and downstream + /// handles. + /// + /// On Android, drag handles include the caret handle, as well as the upstream and + /// downstream drag handles. The caret drag handle on Android disappears after a brief + /// period of inactivity, and reappears upon another user interaction. + static Finder findAllMobileDragHandles([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return find.byWidgetPredicate( + (widget) => + widget.key == DocumentKeys.androidCaretHandle || + widget.key == DocumentKeys.upstreamHandle || + widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.iOS: + return find.byWidgetPredicate( + (widget) => + widget.key == DocumentKeys.caret || + widget.key == DocumentKeys.upstreamHandle || + widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileCaret([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.caret); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + /// Returns `true` if the caret is currently visible and 100% opaque, or `false` if it's + /// not. + /// + /// {@macro supereditor_finder} + static bool isCaretVisible([Finder? superEditorFinder]) { + if (defaultTargetPlatform == TargetPlatform.android) { + final androidCaretLayerState = _findAndroidControlsLayer(superEditorFinder); + return androidCaretLayerState.isCaretVisible; + } + + if (defaultTargetPlatform == TargetPlatform.iOS) { + final iOSCaretLayer = _findIosControlsLayer(superEditorFinder); + return iOSCaretLayer.isCaretVisible; + } + + final desktopCaretLayer = _findDesktopCaretOverlay(superEditorFinder); + return desktopCaretLayer.isCaretVisible; + } + + /// Returns the [Duration] to switch the caret between visible and invisible. + /// + /// {@macro supereditor_finder} + static Duration caretFlashPeriod([Finder? superEditorFinder]) { + if (defaultTargetPlatform == TargetPlatform.android) { + final androidCaretLayerState = _findAndroidControlsLayer(superEditorFinder); + return androidCaretLayerState.caretFlashPeriod; + } + + if (defaultTargetPlatform == TargetPlatform.iOS) { + final iOSCaretLayer = _findIosControlsLayer(superEditorFinder); + return iOSCaretLayer.caretFlashPeriod; + } + + final desktopCaretLayer = _findDesktopCaretOverlay(superEditorFinder); + return desktopCaretLayer.caretFlashPeriod; + } + + static Finder findMobileCaretDragHandle([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return find.byKey(DocumentKeys.androidCaretHandle); + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.caret); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileExpandedDragHandles([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byWidgetPredicate( + (widget) => widget.key == DocumentKeys.upstreamHandle || widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + /// Finds the upstream drag handle for a mobile `SuperEditor`. + /// + /// This handle might be the base handle or the extent handle, depending on selection direction. + static Finder findMobileUpstreamDragHandle([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.upstreamHandle); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + /// Finds the downstream drag handle for a mobile `SuperEditor`. + /// + /// This handle might be the base handle or the extent handle, depending on selection direction. + static Finder findMobileDownstreamDragHandle([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.downstreamHandle); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + /// Finds the base drag handle for a mobile `SuperEditor`. + /// + /// Keep in mind that the base handle is the handle at the beginning of a selection. + /// The beginning of a selection might appear on the left side or right side of a + /// selection, depending on the direction that the user dragged to select content. + static Finder findMobileBaseDragHandle([Finder? superEditorFinder]) { + final selection = findDocumentSelection(superEditorFinder); + expect(selection, isNotNull, + reason: "Tried to find the mobile handle for the selection base, but the selection is null."); + + if (selection!.isCollapsed) { + // When the selection is collapsed, the base and extent are the same. The choice is irrelevant. + return findMobileDownstreamDragHandle(superEditorFinder); + } + + if (selection.hasDownstreamAffinity(findDocument(superEditorFinder)!)) { + return findMobileUpstreamDragHandle(superEditorFinder); + } else { + return findMobileDownstreamDragHandle(superEditorFinder); + } + } + + /// Finds the extent drag handle for a mobile `SuperEditor`. + /// + /// Keep in mind that the extent handle is the handle at the end of a selection. + /// The end of a selection might appear on the left side or right side of a + /// selection, depending on the direction that the user dragged to select content. + static Finder findMobileExtentDragHandle([Finder? superEditorFinder]) { + final selection = findDocumentSelection(superEditorFinder); + expect(selection, isNotNull, + reason: "Tried to find the mobile handle for the selection extent, but the selection is null."); + + if (selection!.isCollapsed) { + // When the selection is collapsed, the base and extent are the same. The choice is irrelevant. + return findMobileDownstreamDragHandle(superEditorFinder); + } + + if (selection.hasDownstreamAffinity(findDocument(superEditorFinder)!)) { + return findMobileDownstreamDragHandle(superEditorFinder); + } else { + return findMobileUpstreamDragHandle(superEditorFinder); + } + } + + /// Finds the magnifier for a mobile `SuperEditor`. + static Finder findMobileMagnifier([Finder? superEditorFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.magnifier); + + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static AndroidControlsDocumentLayerState _findAndroidControlsLayer([Finder? superEditorFinder]) { + final element = find + .descendant( + of: (superEditorFinder ?? find.byType(SuperEditor)), + matching: find.byType(AndroidHandlesDocumentLayer), + ) + .evaluate() + .single as StatefulElement; + + return element.state as AndroidControlsDocumentLayerState; + } + + static IosControlsDocumentLayerState _findIosControlsLayer([Finder? superEditorFinder]) { + final element = find + .descendant( + of: (superEditorFinder ?? find.byType(SuperEditor)), + matching: find.byType(IosHandlesDocumentLayer), + ) + .evaluate() + .single as StatefulElement; + + return element.state as IosControlsDocumentLayerState; + } + + static CaretDocumentOverlayState _findDesktopCaretOverlay([Finder? superEditorFinder]) { + final element = find + .descendant( + of: (superEditorFinder ?? find.byType(SuperEditor)), + matching: find.byType(CaretDocumentOverlay), + ) + .evaluate() + .single as StatefulElement; + + return element.state as CaretDocumentOverlayState; + } + SuperEditorInspector._(); } diff --git a/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart b/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart index 1ddc5244ac..15123fa4e5 100644 --- a/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart +++ b/super_editor/lib/src/test/super_editor_test/supereditor_robot.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; @@ -13,6 +15,8 @@ extension SuperEditorRobot on WidgetTester { /// /// The simulated user gesture is probably a tap, but the only guarantee is that /// the caret is placed with a gesture. + /// + /// To explicitly simulate a tap within a paragraph, use [tapInParagraph]. Future placeCaretInParagraph( String nodeId, int offset, { @@ -22,6 +26,90 @@ extension SuperEditorRobot on WidgetTester { await _tapInParagraph(nodeId, offset, affinity, 1, superEditorFinder); } + /// Simulates a tap at the given [offset] within the paragraph with the + /// given [nodeId]. + /// + /// This simulated interaction is intended primarily for purposes other + /// than changing the document selection, such as tapping on a link to + /// launch a URL. + /// + /// To place the caret in a paragraph, consider using [placeCaretInParagraph], + /// which might choose a different execution path to simulate the selection + /// change. + Future tapInParagraph( + String nodeId, + int offset, { + TextAffinity affinity = TextAffinity.downstream, + Finder? superEditorFinder, + }) async { + await _tapInParagraph(nodeId, offset, affinity, 1, superEditorFinder); + } + + Future tapDownInParagraph( + String nodeId, + int offset, { + TextAffinity affinity = TextAffinity.downstream, + Finder? superEditorFinder, + }) async { + // Calculate the global tap position based on the TextLayout and desired + // TextPosition. + final globalTapOffset = _findGlobalOffsetForTextPosition(nodeId, offset, affinity, superEditorFinder); + + // TODO: check that the tap offset is visible within the viewport. Add option to + // auto-scroll, or throw exception when it's not tappable. + + return await startGesture(globalTapOffset); + } + + Future doubleTapDownInParagraph( + String nodeId, + int offset, { + TextAffinity affinity = TextAffinity.downstream, + Finder? superEditorFinder, + }) async { + // Calculate the global tap position based on the TextLayout and desired TextPosition. + final globalTapOffset = _findGlobalOffsetForTextPosition(nodeId, offset, affinity, superEditorFinder); + + final gesture = await startGesture(globalTapOffset); + await gesture.up(); + await pump(kTapMinTime + const Duration(milliseconds: 1)); + + await gesture.down(globalTapOffset); + await pump(kTapMinTime + const Duration(milliseconds: 1)); + await pump(); + + return gesture; + } + + /// Simulates a long-press at the given text [offset] within the paragraph + /// with the given [nodeId]. + Future longPressInParagraph( + String nodeId, + int offset, { + TextAffinity affinity = TextAffinity.downstream, + Finder? superEditorFinder, + }) async { + final gesture = await tapDownInParagraph(nodeId, offset, affinity: affinity, superEditorFinder: superEditorFinder); + await pump(kLongPressTimeout + kPressTimeout); + + await gesture.up(); + await pump(); + } + + /// Simulates a long-press down at the given text [offset] within the paragraph + /// with the given [nodeId], and returns the [TestGesture] so that a test can + /// decide to drag it, or release. + Future longPressDownInParagraph( + String nodeId, + int offset, { + TextAffinity affinity = TextAffinity.downstream, + Finder? superEditorFinder, + }) async { + final gesture = await tapDownInParagraph(nodeId, offset, affinity: affinity, superEditorFinder: superEditorFinder); + await pump(kLongPressTimeout + kPressTimeout); + return gesture; + } + /// Simulates a double tap at the given [offset] within the paragraph with the given /// [nodeId]. Future doubleTapInParagraph( @@ -44,6 +132,7 @@ extension SuperEditorRobot on WidgetTester { await _tapInParagraph(nodeId, offset, affinity, 3, superEditorFinder); } + // TODO: rename all of these related behaviors to "text" instead of "paragraph" Future _tapInParagraph( String nodeId, int offset, @@ -51,41 +140,9 @@ extension SuperEditorRobot on WidgetTester { int tapCount, [ Finder? superEditorFinder, ]) async { - late final Finder layoutFinder; - if (superEditorFinder != null) { - layoutFinder = find.descendant(of: superEditorFinder, matching: find.byType(SingleColumnDocumentLayout)); - } else { - layoutFinder = find.byType(SingleColumnDocumentLayout); - } - final documentLayoutElement = layoutFinder.evaluate().single as StatefulElement; - final documentLayout = documentLayoutElement.state as DocumentLayout; - - // Collect the various text UI artifacts needed to find the - // desired caret offset. - final componentState = documentLayout.getComponentByNodeId(nodeId) as State; - late final GlobalKey textComponentKey; - if (componentState is ProxyDocumentComponent) { - textComponentKey = componentState.childDocumentComponentKey; - } else { - textComponentKey = componentState.widget.key as GlobalKey; - } - - final textLayout = (textComponentKey.currentState as TextComponentState).textLayout; - final textRenderBox = textComponentKey.currentContext!.findRenderObject() as RenderBox; - // Calculate the global tap position based on the TextLayout and desired // TextPosition. - final position = TextPosition(offset: offset, affinity: affinity); - // For the local tap offset, we add a small vertical adjustment downward. This - // prevents flaky edge effects, which might occur if we try to tap exactly at the - // top of the line. In general, we could use the caret height to choose a vertical - // offset, but the caret height is null when the text is empty. So we use a - // hard-coded value, instead. We also adjust the horizontal offset by a pixel left - // or right depending on the requested affinity. Without this the resulting selection - // may contain an incorrect affinity if the gesture did not occur at a line break. - final localTapOffset = - textLayout.getOffsetForCaret(position) + Offset(affinity == TextAffinity.upstream ? -1 : 1, 5); - final globalTapOffset = localTapOffset + textRenderBox.localToGlobal(Offset.zero); + final globalTapOffset = _findGlobalOffsetForTextPosition(nodeId, offset, affinity, superEditorFinder); // TODO: check that the tap offset is visible within the viewport. Add option to // auto-scroll, or throw exception when it's not tappable. @@ -96,6 +153,9 @@ extension SuperEditorRobot on WidgetTester { await pump(kTapMinTime + const Duration(milliseconds: 1)); } + // Pump long enough to prevent the next tap from being seen as a sequence on top of these taps. + await pump(kTapTimeout); + await pumpAndSettle(); } @@ -103,11 +163,39 @@ extension SuperEditorRobot on WidgetTester { /// /// {@macro supereditor_finder} Future tapAtDocumentPosition(DocumentPosition position, [Finder? superEditorFinder]) async { + final documentLayout = _findDocumentLayout(superEditorFinder); + final positionRectInDoc = _getRectForDocumentPosition(position, documentLayout, superEditorFinder); + final globalTapOffset = documentLayout.getAncestorOffsetFromDocumentOffset(positionRectInDoc.center); + + await tapAt(globalTapOffset); + } + + /// Double-taps at the center of the content at the given [position] within a [SuperEditor]. + /// + /// {@macro supereditor_finder} + Future doubleTapAtDocumentPosition(DocumentPosition position, [Finder? superEditorFinder]) async { final documentLayout = _findDocumentLayout(superEditorFinder); final positionRectInDoc = documentLayout.getRectForPosition(position)!; final globalTapOffset = documentLayout.getAncestorOffsetFromDocumentOffset(positionRectInDoc.center); await tapAt(globalTapOffset); + await pump(kTapMinTime); + await tapAt(globalTapOffset); + } + + /// Triple-taps at the center of the content at the given [position] within a [SuperEditor]. + /// + /// {@macro supereditor_finder} + Future tripleTapAtDocumentPosition(DocumentPosition position, [Finder? superEditorFinder]) async { + final documentLayout = _findDocumentLayout(superEditorFinder); + final positionRectInDoc = documentLayout.getRectForPosition(position)!; + final globalTapOffset = documentLayout.getAncestorOffsetFromDocumentOffset(positionRectInDoc.center); + + await tapAt(globalTapOffset); + await pump(kTapMinTime); + await tapAt(globalTapOffset); + await pump(kTapMinTime); + await tapAt(globalTapOffset); } /// Simulates a user drag that begins at the [from] [DocumentPosition] @@ -117,9 +205,14 @@ extension SuperEditorRobot on WidgetTester { /// to ensure that the drag rectangle never has a zero-width or a /// zero-height, because such a drag rectangle wouldn't be seen as /// intersecting any content. + /// + /// Provide a [pointerDeviceKind] to override the device kind used in the gesture. + /// If [pointerDeviceKind] is `null`, it defaults to [PointerDeviceKind.touch] + /// on mobile, and [PointerDeviceKind.mouse] on other platforms. Future dragSelectDocumentFromPositionByOffset({ required DocumentPosition from, required Offset delta, + PointerDeviceKind? pointerDeviceKind, Finder? superEditorFinder, }) async { final documentLayout = _findDocumentLayout(superEditorFinder); @@ -157,8 +250,13 @@ extension SuperEditorRobot on WidgetTester { } } + final deviceKind = pointerDeviceKind ?? + (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.android + ? PointerDeviceKind.touch + : PointerDeviceKind.mouse); + // Simulate the drag. - final gesture = await startGesture(dragStartOffset, kind: PointerDeviceKind.mouse); + final gesture = await startGesture(dragStartOffset, kind: deviceKind); // Move slightly so that a "pan start" is reported. // @@ -216,20 +314,220 @@ extension SuperEditorRobot on WidgetTester { await pumpAndSettle(); } + Future pressDownOnCollapsedMobileHandle() async { + final handleElement = find.byKey(DocumentKeys.androidCaretHandle).evaluate().firstOrNull; + assert(handleElement != null, "Tried to press down on Android collapsed handle but no handle was found."); + final renderHandle = handleElement!.renderObject as RenderBox; + final handleCenter = renderHandle.localToGlobal(renderHandle.size.center(Offset.zero)); + + final gesture = await startGesture(handleCenter); + return gesture; + } + + Future tapOnCollapsedMobileHandle() async { + final handleElement = find.byKey(DocumentKeys.androidCaretHandle).evaluate().firstOrNull; + assert(handleElement != null, "Tried to press down on Android collapsed handle but no handle was found."); + final renderHandle = handleElement!.renderObject as RenderBox; + final handleCenter = renderHandle.localToGlobal(renderHandle.size.center(Offset.zero)); + + await tapAt(handleCenter); + } + + Future pressDownOnUpstreamMobileHandle() async { + final handleElement = find.byKey(DocumentKeys.upstreamHandle).evaluate().firstOrNull; + assert(handleElement != null, "Tried to press down on upstream handle but no handle was found."); + final renderHandle = handleElement!.renderObject as RenderBox; + final handleCenter = renderHandle.localToGlobal(renderHandle.size.center(Offset.zero)); + + final gesture = await startGesture(handleCenter); + return gesture; + } + + Future pressDownOnDownstreamMobileHandle() async { + final handleElement = find.byKey(DocumentKeys.downstreamHandle).evaluate().firstOrNull; + assert(handleElement != null, "Tried to press down on upstream handle but no handle was found."); + final renderHandle = handleElement!.renderObject as RenderBox; + final handleCenter = renderHandle.localToGlobal(renderHandle.size.center(Offset.zero)); + + final gesture = await startGesture(handleCenter); + return gesture; + } + + /// Simulates typing [text], either as keyboard keys, or as insertion deltas of + /// a software keyboard. + /// + /// Provide an [imeOwnerFinder] if there are multiple [ImeOwner]s in the current + /// widget tree. + Future typeTextAdaptive(String text, [Finder? imeOwnerFinder]) async { + if (!testTextInput.hasAnyClients) { + // There isn't any IME connections. + // Type using the hardware keyboard. + await typeKeyboardText(text); + return; + } + + await ime.typeText(text, getter: () => imeClientGetter(imeOwnerFinder)); + } + /// Types the given [text] into a [SuperEditor] by simulating IME text deltas from /// the platform. - Future typeImeText(String text) async { - await ime.typeText(text, getter: imeClientGetter); + /// + /// Provide an [imeOwnerFinder] if there are multiple [ImeOwner]s in the current + /// widget tree. + Future typeImeText(String text, [Finder? imeOwnerFinder]) async { + await ime.typeText(text, getter: () => imeClientGetter(imeOwnerFinder)); + } + + /// Simulates the user holding the spacebar and starting the floating cursor gesture. + /// + /// The initial offset is at (0,0). + Future startFloatingCursorGesture() async { + await _updateFloatingCursor(action: "FloatingCursorDragState.start", offset: Offset.zero); + } + + /// Simulates the user swiping the spacebar by [offset]. + /// + /// (0,0) means the point where the user started the gesture. + /// + /// A floating cursor gesture must be started before calling this method. + Future updateFloatingCursorGesture(Offset offset) async { + await _updateFloatingCursor(action: "FloatingCursorDragState.update", offset: offset); + } + + /// Simulates the user releasing the spacebar and stopping the floating cursor gesture. + /// + /// A floating cursor gesture must be started before calling this method. + Future stopFloatingCursorGesture() async { + await _updateFloatingCursor(action: "FloatingCursorDragState.end", offset: Offset.zero); + } + + Offset _findGlobalOffsetForTextPosition( + String nodeId, + int offset, + TextAffinity affinity, [ + Finder? superEditorFinder, + ]) { + final textComponentKey = _findComponentKeyForTextNode(nodeId, superEditorFinder); + final textRenderBox = textComponentKey.currentContext!.findRenderObject() as RenderBox; + + final localTapOffset = _findLocalOffsetForTextPosition(nodeId, offset, affinity, superEditorFinder); + return localTapOffset + textRenderBox.localToGlobal(Offset.zero); } + Offset _findLocalOffsetForTextPosition( + String nodeId, + int offset, + TextAffinity affinity, [ + Finder? superEditorFinder, + ]) { + final textComponentKey = _findComponentKeyForTextNode(nodeId, superEditorFinder); + final textLayout = (textComponentKey.currentState as TextComponentState).textLayout; + + // Calculate the global tap position based on the TextLayout and desired + // TextPosition. + final position = TextPosition(offset: offset, affinity: affinity); + // For the local tap offset, we add a small vertical adjustment downward. This + // prevents flaky edge effects, which might occur if we try to tap exactly at the + // top of the line. In general, we could use the caret height to choose a vertical + // offset, but the caret height is null when the text is empty. So we use a + // hard-coded value, instead. We also adjust the horizontal offset by a pixel left + // or right depending on the requested affinity. Without this the resulting selection + // may contain an incorrect affinity if the gesture did not occur at a line break. + return textLayout.getOffsetForCaret(position) + Offset(affinity == TextAffinity.upstream ? -1 : 1, 5); + } + + /// Returns the bounding box around the given [position], within the associated component. + /// + /// If the component is a block component, the returned [Rect] will be half of its width. + Rect _getRectForDocumentPosition(DocumentPosition position, DocumentLayout documentLayout, + [Finder? superEditorFinder]) { + final component = documentLayout.getComponentByNodeId(position.nodeId); + if (component == null) { + throw Exception('No component found for node ID: ${position.nodeId}'); + } + + if (component.getBeginningPosition() is UpstreamDownstreamNodePosition) { + // The component is a block component. Compute the rect manually, because + // `getRectForPosition` returns always the rect of the whole block. + // The returned rect will be half of the width of the component. + final componentBox = component.context.findRenderObject() as RenderBox; + final edge = component.getEdgeForPosition(position.nodePosition); + + final positionRect = position.nodePosition == const UpstreamDownstreamNodePosition.upstream() + // For upstream position, the edge is a zero width rect starting from the left. + ? Rect.fromLTWH( + edge.left, + edge.top, + componentBox.size.width / 2, + componentBox.size.height, + ) + // For downstream position, the edge is a zero width rect starting at the right. + // Subtract half of the width to make it start from the center. + : Rect.fromLTWH( + edge.left - componentBox.size.width / 2, + edge.top, + componentBox.size.width / 2, + componentBox.size.height, + ); + + // Translate the rect to global coordinates. + final documentLayoutElement = _findDocumentLayoutElement(superEditorFinder); + final docOffset = componentBox.localToGlobal(Offset.zero, ancestor: documentLayoutElement.findRenderObject()); + return positionRect.translate(docOffset.dx, docOffset.dy); + } + + // The component isn't a block node. Use the default implementation for getRectForPosition. + return documentLayout.getRectForPosition(position)!; + } + + /// Finds and returns the [DocumentLayout] within the only [SuperEditor] in the + /// widget tree, or within the [SuperEditor] found via the optional [superEditorFinder]. DocumentLayout _findDocumentLayout([Finder? superEditorFinder]) { + final documentLayoutElement = _findDocumentLayoutElement(superEditorFinder); + return documentLayoutElement.state as DocumentLayout; + } + + /// Finds and returns the document layout element within the only [SuperEditor] in the + /// widget tree, or within the [SuperEditor] found via the optional [superEditorFinder]. + StatefulElement _findDocumentLayoutElement([Finder? superEditorFinder]) { late final Finder layoutFinder; if (superEditorFinder != null) { layoutFinder = find.descendant(of: superEditorFinder, matching: find.byType(SingleColumnDocumentLayout)); } else { layoutFinder = find.byType(SingleColumnDocumentLayout); } - final documentLayoutElement = layoutFinder.evaluate().single as StatefulElement; - return documentLayoutElement.state as DocumentLayout; + return layoutFinder.evaluate().single as StatefulElement; + } + + /// Finds the [GlobalKey] that's attached to the [TextComponent], which presents the + /// given [nodeId]. + /// + /// The given [nodeId] must refer to a [TextNode] or subclass. + GlobalKey _findComponentKeyForTextNode(String nodeId, [Finder? superEditorFinder]) { + final documentLayout = _findDocumentLayout(superEditorFinder); + + final componentState = documentLayout.getComponentByNodeId(nodeId) as State; + if (componentState is ProxyDocumentComponent) { + return componentState.childDocumentComponentKey; + } else { + return componentState.widget.key as GlobalKey; + } + } + + Future _updateFloatingCursor({required String action, required Offset offset}) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + "TextInputClient.updateFloatingCursor", + [ + -1, + action, + {"X": offset.dx, "Y": offset.dy} + ], + ), + ), + null, + ); } } diff --git a/super_editor/lib/src/test/super_editor_test/supereditor_test_tools.dart b/super_editor/lib/src/test/super_editor_test/supereditor_test_tools.dart new file mode 100644 index 0000000000..d1f3cc77c2 --- /dev/null +++ b/super_editor/lib/src/test/super_editor_test/supereditor_test_tools.dart @@ -0,0 +1,1194 @@ +import 'dart:math'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:super_editor/src/test/flutter_extensions/test_documents.dart'; +import 'package:super_editor/src/test/flutter_extensions/test_tools_user_input.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_keyboard/super_keyboard_test.dart'; +import 'package:text_table/text_table.dart'; + +/// Extensions on [WidgetTester] that configure and pump [SuperEditor] +/// document editors. +extension DocumentTester on WidgetTester { + /// Starts the process for configuring and pumping a new [SuperEditor]. + /// + /// Use the returned [TestDocumentSelector] to continue configuring the + /// [SuperEditor]. + TestDocumentSelector createDocument() { + return TestDocumentSelector(this); + } + + /// Pumps a new [SuperEditor] using an existing [configuration]. + /// + /// Use this method to simulate a [SuperEditor] whose widget tree changes. + TestSuperEditorConfigurator updateDocument(SuperEditorTestConfiguration configuration) { + return TestSuperEditorConfigurator._fromExistingConfiguration(this, configuration); + } +} + +/// Selects a [Document] configuration when composing a [SuperEditor] +/// widget in a test. +/// +/// Each document selection returns a [TestSuperEditorConfigurator], which +/// is used to complete the configuration, and to pump the [SuperEditor]. +class TestDocumentSelector { + const TestDocumentSelector(this._widgetTester); + + final WidgetTester _widgetTester; + + TestSuperEditorConfigurator withCustomContent(MutableDocument document) { + return TestSuperEditorConfigurator._(_widgetTester, document); + } + + /// Configures the editor with a [Document] that's parsed from the + /// given [markdown]. + TestSuperEditorConfigurator fromMarkdown(String markdown) { + return TestSuperEditorConfigurator._( + _widgetTester, + deserializeMarkdownToDocument(markdown), + ); + } + + TestSuperEditorConfigurator withSingleEmptyParagraph() { + return TestSuperEditorConfigurator._( + _widgetTester, + singleParagraphEmptyDoc(), + ); + } + + TestSuperEditorConfigurator withSingleParagraph() { + return TestSuperEditorConfigurator._( + _widgetTester, + singleParagraphDoc(), + ); + } + + TestSuperEditorConfigurator withSingleShortParagraph() { + return TestSuperEditorConfigurator._( + _widgetTester, + singleParagraphDocShortText(), + ); + } + + TestSuperEditorConfigurator withSingleParagraphAndLink() { + return TestSuperEditorConfigurator._( + _widgetTester, + singleParagraphWithLinkDoc(), + ); + } + + TestSuperEditorConfigurator withTwoEmptyParagraphs() { + return TestSuperEditorConfigurator._( + _widgetTester, + twoParagraphEmptyDoc(), + ); + } + + TestSuperEditorConfigurator withSingleOrderedListItem() { + return TestSuperEditorConfigurator._( + _widgetTester, + singleOrderedListItemDoc(), + ); + } + + TestSuperEditorConfigurator withOrderedListItemFollowedByEmptyParagraph() { + return TestSuperEditorConfigurator._( + _widgetTester, + orderedListItemFollowedByEmptyParagraph(), + ); + } + + TestSuperEditorConfigurator withLongTextContent() { + return TestSuperEditorConfigurator._( + _widgetTester, + longTextDoc(), + ); + } + + TestSuperEditorConfigurator withLongDoc() { + return TestSuperEditorConfigurator._( + _widgetTester, + longDoc(), + ); + } +} + +/// Builder that configures and pumps a [SuperEditor] widget. +class TestSuperEditorConfigurator { + TestSuperEditorConfigurator._fromExistingConfiguration(this._widgetTester, this._config); + + TestSuperEditorConfigurator._(this._widgetTester, MutableDocument document) + : _config = SuperEditorTestConfiguration(_widgetTester, document); + + final WidgetTester _widgetTester; + final SuperEditorTestConfiguration _config; + + TestSuperEditorConfigurator withAddedRequestHandlers(List addedRequestHandlers) { + _config.addedRequestHandlers.addAll(addedRequestHandlers); + return this; + } + + TestSuperEditorConfigurator withAddedReactions(List addedReactions) { + _config.addedReactions.addAll(addedReactions); + return this; + } + + /// Configures the [SuperEditor] for standard desktop interactions, + /// e.g., mouse and keyboard input. + TestSuperEditorConfigurator forDesktop({ + TextInputSource inputSource = TextInputSource.ime, + }) { + _config.inputSource = inputSource; + _config.gestureMode = DocumentGestureMode.mouse; + return this; + } + + /// Configures the [SuperEditor] for standard Android interactions, + /// e.g., touch gestures and IME input. + TestSuperEditorConfigurator forAndroid() { + _config.gestureMode = DocumentGestureMode.android; + _config.inputSource = TextInputSource.ime; + return this; + } + + /// Configures the [SuperEditor] for standard iOS interactions, + /// e.g., touch gestures and IME input. + TestSuperEditorConfigurator forIOS() { + _config.gestureMode = DocumentGestureMode.iOS; + _config.inputSource = TextInputSource.ime; + return this; + } + + /// Configures the [SuperEditor] to use the given [inputSource]. + TestSuperEditorConfigurator withInputSource(TextInputSource inputSource) { + _config.inputSource = inputSource; + return this; + } + + /// Configures the [SuperEditor] with the given selection [policies], which dictate the interactions + /// between selection and other details, such as focus change. + TestSuperEditorConfigurator withSelectionPolicies(SuperEditorSelectionPolicies policies) { + _config.selectionPolicies = policies; + return this; + } + + /// Configures the [SuperEditor] with the given selection [styles], which dictate the color of the + /// primary user's selection, and related selection details. + TestSuperEditorConfigurator withSelectionStyles(SelectionStyles? styles) { + _config.selectionStyles = styles; + return this; + } + + TestSuperEditorConfigurator useIosSelectionHeuristics(bool shouldUse) { + _config.useIosSelectionHeuristics = shouldUse; + return this; + } + + TestSuperEditorConfigurator withCaretPolicies({ + bool? displayCaretWithExpandedSelection, + }) { + if (displayCaretWithExpandedSelection != null) { + _config.displayCaretWithExpandedSelection = displayCaretWithExpandedSelection; + } + return this; + } + + TestSuperEditorConfigurator withCaretStyle({CaretStyle? caretStyle}) { + _config.caretStyle = caretStyle; + return this; + } + + TestSuperEditorConfigurator withIosCaretStyle({ + double? width, + Color? color, + double? handleBallDiameter, + }) { + _config.iosCaretWidth = width; + _config.iosHandleColor = color; + _config.iosHandleBallDiameter = handleBallDiameter; + return this; + } + + TestSuperEditorConfigurator withAndroidCaretStyle({ + double? width, + Color? color, + }) { + _config.androidCaretWidth = width; + _config.androidCaretColor = color; + return this; + } + + /// Configures the [SuperEditor]'s [SoftwareKeyboardController]. + TestSuperEditorConfigurator withSoftwareKeyboardController(SoftwareKeyboardController controller) { + _config.softwareKeyboardController = controller; + return this; + } + + /// When `true`, adds [MediaQuery] view insets to simulate the appearance of a software keyboard + /// whenever the IME connection is active - when `false`, does nothing. + TestSuperEditorConfigurator simulateSoftwareKeyboardInsets( + bool doSimulation, { + double simulatedKeyboardHeight = 300, + bool animateKeyboard = false, + }) { + _config + ..simulateSoftwareKeyboardInsets = doSimulation + ..simulatedKeyboardHeight = simulatedKeyboardHeight + ..animateSimulatedSoftwareKeyboard = animateKeyboard; + return this; + } + + /// Configures the [SuperEditor] with the given IME [policies], which dictate the interactions + /// between focus, selection, and the platform IME, including software keyborads on mobile. + TestSuperEditorConfigurator withImePolicies(SuperEditorImePolicies policies) { + _config.imePolicies = policies; + return this; + } + + /// Configures the way in which the user interacts with the IME, e.g., brightness, autocorrection, etc. + TestSuperEditorConfigurator withImeConfiguration(SuperEditorImeConfiguration configuration) { + _config.imeConfiguration = configuration; + return this; + } + + /// Configures the [SuperEditor] to intercept and override desired IME signals, as + /// determined by the given [imeOverrides]. + TestSuperEditorConfigurator withImeOverrides(DeltaTextInputClientDecorator imeOverrides) { + _config.imeOverrides = imeOverrides; + return this; + } + + /// Configures the [SuperEditor] with the given [isImeConnected] notifier, which allows test + /// code to listen for changes to the IME connection from within [SuperEditor]. + TestSuperEditorConfigurator withImeConnectionNotifier(ValueNotifier? isImeConnected) { + _config.isImeConnected = isImeConnected ?? ValueNotifier(false); + return this; + } + + TestSuperEditorConfigurator withAddedKeyboardActions({ + List prepend = const [], + List append = const [], + }) { + _config.prependedKeyboardActions.addAll(prepend); + _config.appendedKeyboardActions.addAll(append); + return this; + } + + /// Configures the [SuperEditor] to use the given selector [handlers]. + TestSuperEditorConfigurator withSelectorHandlers(Map handlers) { + _config.selectorHandlers = handlers; + return this; + } + + /// Configures the [SuperEditor] to use the given [gestureMode]. + TestSuperEditorConfigurator withGestureMode(DocumentGestureMode gestureMode) { + _config.gestureMode = gestureMode; + return this; + } + + TestSuperEditorConfigurator enableHistory(bool isHistoryEnabled) { + _config.isHistoryEnabled = isHistoryEnabled; + return this; + } + + TestSuperEditorConfigurator withHistoryGroupingPolicy(HistoryGroupingPolicy policy) { + _config.historyGroupPolicy = policy; + return this; + } + + /// Configures the [SuperEditor] to constrain its maxHeight and maxWidth using the given [size]. + TestSuperEditorConfigurator withEditorSize(ui.Size? size) { + _config.editorSize = size; + return this; + } + + /// Configures the [SuperEditor] to use only the given [componentBuilders] + TestSuperEditorConfigurator withComponentBuilders(List? componentBuilders) { + _config.componentBuilders = componentBuilders; + return this; + } + + /// Configures the [SuperEditor] to use a custom widget tree above [SuperEditor]. + TestSuperEditorConfigurator withCustomWidgetTreeBuilder(WidgetTreeBuilder? builder) { + _config.widgetTreeBuilder = builder; + return this; + } + + /// Configures the [SuperEditor] to display an [AppBar] with the given height above the [SuperEditor]. + /// + /// If [withCustomWidgetTreeBuilder] is used, this setting is ignored. + TestSuperEditorConfigurator withAppBar(double height) { + _config.appBarHeight = height; + return this; + } + + /// Configures the [SuperEditor] to use the given [scrollController] + TestSuperEditorConfigurator withScrollController(ScrollController? scrollController) { + _config.scrollController = scrollController; + return this; + } + + /// Configures the [SuperEditor] to use the given [focusNode] + TestSuperEditorConfigurator withFocusNode(FocusNode? focusNode) { + _config.focusNode = focusNode; + return this; + } + + /// Configures the [SuperEditor] to use the given [selection] as its initial selection. + TestSuperEditorConfigurator withSelection(DocumentSelection? selection) { + _config.selection = selection; + return this; + } + + /// Configures the [SuperEditor] to use the given [builder] as its android toolbar builder. + TestSuperEditorConfigurator withAndroidToolbarBuilder(DocumentFloatingToolbarBuilder? builder) { + _config.androidToolbarBuilder = builder; + return this; + } + + /// Configures the [SuperEditor] to use the given [builder] as its android collapsed handle builder. + TestSuperEditorConfigurator withAndroidCollapsedHandleBuilder(DocumentCollapsedHandleBuilder? builder) { + _config.androidCollapsedHandleBuilder = builder; + return this; + } + + /// Configures the [SuperEditor] to use the given [builder] as its android expanded handles builder. + TestSuperEditorConfigurator withAndroidExpandedHandlesBuilder(DocumentExpandedHandlesBuilder? builder) { + _config.androidExpandedHandlesBuilder = builder; + return this; + } + + /// Configures the [SuperEditor] to use the given [builder] as its iOS toolbar builder. + TestSuperEditorConfigurator withiOSToolbarBuilder(DocumentFloatingToolbarBuilder? builder) { + _config.iOSToolbarBuilder = builder; + return this; + } + + /// Configures the [ThemeData] used for the [MaterialApp] that wraps + /// the [SuperEditor]. + TestSuperEditorConfigurator useAppTheme(ThemeData theme) { + _config.appTheme = theme; + return this; + } + + /// Configures the [SuperEditor] to use the given [stylesheet]. + TestSuperEditorConfigurator useStylesheet(Stylesheet? stylesheet) { + _config.stylesheet = stylesheet; + return this; + } + + /// Adds the given component builders to the list of component builders that are + /// used to render the document layout in the pumped [SuperEditor]. + TestSuperEditorConfigurator withAddedComponents(List newComponents) { + _config.addedComponents.addAll(newComponents); + return this; + } + + /// Configures the [SuperEditor] to auto-focus when first pumped, or not. + TestSuperEditorConfigurator autoFocus(bool autoFocus) { + _config.autoFocus = autoFocus; + return this; + } + + /// Configures the [SuperEditor] to use the given [key]. + TestSuperEditorConfigurator withKey(Key? key) { + _config.key = key; + return this; + } + + /// Configures the [SuperEditor] [DocumentLayout] to use the given [layoutKey]. + TestSuperEditorConfigurator withLayoutKey(GlobalKey? layoutKey) { + _config.layoutKey = layoutKey; + return this; + } + + /// Configures the [SuperEditor] to use the given [inputRole]. + TestSuperEditorConfigurator withInputRole(String inputRole) { + _config.inputRole = inputRole; + return this; + } + + /// Configures the [SuperEditor] to use only the given [tapDelegateFactories]. + TestSuperEditorConfigurator withTapDelegateFactories( + List? tapDelegateFactories) { + _config.tapDelegateFactories = tapDelegateFactories; + return this; + } + + /// Applies the given [plugin] to the pumped [SuperEditor]. + TestSuperEditorConfigurator withPlugin(SuperEditorPlugin plugin) { + _config.plugins.add(plugin); + return this; + } + + /// Configures the [SuperEditor] to be displayed inside a [CustomScrollView]. + /// + /// The [CustomScrollView] is constrained by the size provided in [withEditorSize]. + /// + /// Use [withScrollController] to define the [ScrollController] of the [CustomScrollView]. + TestSuperEditorConfigurator insideCustomScrollView() { + _config.insideCustomScrollView = true; + return this; + } + + /// Configures the [SuperEditor] to use the given [tapRegionGroupId]. + /// + /// This DOESN'T wrap the editor with a [TapRegion]. + TestSuperEditorConfigurator withTapRegionGroupId(String? tapRegionGroupId) { + _config.tapRegionGroupId = tapRegionGroupId; + return this; + } + + /// Pumps a [SuperEditor] widget tree with the desired configuration, and returns + /// a [TestDocumentContext], which includes the artifacts connected to the widget + /// tree, e.g., the [DocumentEditor], [DocumentComposer], etc. + /// + /// If you need access to the pumped [Widget], use [build] instead of this method, + /// and then call [WidgetTester.pump] with the returned [Widget]. + Future pump() async { + final testDocumentContext = _createTestDocumentContext(); + await _widgetTester.pumpWidget( + _build(testDocumentContext).widget, + ); + return testDocumentContext; + } + + /// Builds a Super Editor experience based on chosen configurations and + /// returns a [ConfiguredSuperEditorWidget], which includes the associated + /// Super Editor [Widget]. + /// + /// If you want to immediately pump this UI into a [WidgetTester], use + /// [pump], which does that for you. + ConfiguredSuperEditorWidget build() { + return _build(); + } + + /// Builds a [SuperEditor] widget tree based on the configuration in this + /// class and the (optional) [TestDocumentContext]. + /// + /// If no [TestDocumentContext] is provided, one will be created based on the current + /// configuration of this class. + ConfiguredSuperEditorWidget _build([TestDocumentContext? testDocumentContext]) { + final context = testDocumentContext ?? _createTestDocumentContext(); + final superEditor = _buildConstrainedContent( + _buildAncestorScrollable( + child: _buildSuperEditor(context), + ), + ); + + return ConfiguredSuperEditorWidget( + context, + _buildWidgetTree(superEditor), + ); + } + + /// Creates a [TestDocumentContext] based on the configurations in this class. + /// + /// A [TestDocumentContext] is useful as a return value for clients, so that + /// those clients can access important pieces within a [SuperEditor] widget. + TestDocumentContext _createTestDocumentContext() { + // Only assign if non-null in case we're updating an existing configuration + // from a previous widget pump. + _config.layoutKey ??= GlobalKey(); + + final layoutKey = _config.layoutKey!; + final focusNode = _config.focusNode ?? FocusNode(); + final composer = MutableDocumentComposer(initialSelection: _config.selection); + final editor = createDefaultDocumentEditor( + document: _config.document, + composer: composer, + historyGroupingPolicy: _config.historyGroupPolicy ?? neverMergePolicy, + isHistoryEnabled: _config.isHistoryEnabled, + ) + ..requestHandlers.insertAll(0, _config.addedRequestHandlers) + ..reactionPipeline.insertAll(0, _config.addedReactions); + + return TestDocumentContext._( + focusNode: focusNode, + layoutKey: layoutKey, + document: _config.document, + composer: composer, + editor: editor, + configuration: _config, + ); + } + + /// Builds a complete screen experience, which includes the given [superEditor]. + Widget _buildWidgetTree(Widget superEditor) { + if (_config.widgetTreeBuilder != null) { + return _buildSimulatedSoftwareKeyboard( + child: _config.widgetTreeBuilder!(superEditor), + ); + } + return MaterialApp( + theme: _config.appTheme, + // By default, Flutter chooses the shortcuts based on the platform. For "native" platforms, + // the defaults already work correctly, because we set `debugDefaultTargetPlatformOverride` to force + // the desired platform. However, for web Flutter checks for `kIsWeb`, which we can't control. + // + // Use our own version of the shortcuts, so we can set `debugIsWebOverride` to `true` to force + // Flutter to pick the web shortcuts. + shortcuts: defaultFlutterShortcuts, + home: _buildSimulatedSoftwareKeyboard( + child: Scaffold( + appBar: _config.appBarHeight != null + ? PreferredSize( + preferredSize: ui.Size(double.infinity, _config.appBarHeight!), + child: SafeArea( + child: SizedBox( + height: _config.appBarHeight!, + child: const ColoredBox(color: Colors.yellow), + ), + ), + ) + : null, + body: superEditor, + resizeToAvoidBottomInset: false, + // ^ Don't automatically resize content to avoid keyboard. We want to be + // able to test our keyboard scaffold, which needs full screen height. + // If a test ever needs this to be `true` then we should make this configurable. + ), + ), + debugShowCheckedModeBanner: false, + ); + } + + Widget _buildSimulatedSoftwareKeyboard({ + required Widget child, + }) { + return SoftwareKeyboardHeightSimulator( + isEnabled: _config.simulateSoftwareKeyboardInsets, + keyboardHeight: _config.simulatedKeyboardHeight, + animateKeyboard: _config.animateSimulatedSoftwareKeyboard, + child: child, + ); + } + + /// Constrains the width and height of the given [superEditor], based on configurations + /// in this class. + Widget _buildConstrainedContent(Widget superEditor) { + if (_config.editorSize != null) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: _config.editorSize!.width, + maxHeight: _config.editorSize!.height, + ), + child: superEditor, + ); + } + return superEditor; + } + + /// Places [child] inside a [CustomScrollView], based on configurations in this class. + Widget _buildAncestorScrollable({required Widget child}) { + if (!_config.insideCustomScrollView) { + return child; + } + + return CustomScrollView( + controller: _config.scrollController, + slivers: [ + child, + ], + ); + } + + /// Builds a [SuperEditor] widget based on the configuration of the given + /// [testDocumentContext], as well as other configurations in this class. + Widget _buildSuperEditor(TestDocumentContext testDocumentContext) { + return _TestSuperEditor( + testDocumentContext: testDocumentContext, + testConfiguration: _config, + ); + } +} + +class _TestSuperEditor extends StatefulWidget { + const _TestSuperEditor({ + required this.testDocumentContext, + required this.testConfiguration, + }); + + final TestDocumentContext testDocumentContext; + final SuperEditorTestConfiguration testConfiguration; + + @override + State<_TestSuperEditor> createState() => _TestSuperEditorState(); +} + +class _TestSuperEditorState extends State<_TestSuperEditor> { + late final SuperEditorIosControlsController? _iOsControlsController; + late final SuperEditorAndroidControlsController? _androidControlsController; + + @override + void initState() { + super.initState(); + + _iOsControlsController = SuperEditorIosControlsController( + useIosSelectionHeuristics: widget.testConfiguration.useIosSelectionHeuristics, + toolbarBuilder: widget.testConfiguration.iOSToolbarBuilder, + ); + + _androidControlsController = SuperEditorAndroidControlsController( + toolbarBuilder: widget.testConfiguration.androidToolbarBuilder, + collapsedHandleBuilder: widget.testConfiguration.androidCollapsedHandleBuilder, + expandedHandlesBuilder: widget.testConfiguration.androidExpandedHandlesBuilder, + ); + } + + @override + void dispose() { + _iOsControlsController?.dispose(); + _androidControlsController?.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget testSuperEditor = _buildSuperEditor(); + + if (_iOsControlsController != null) { + testSuperEditor = SuperEditorIosControlsScope( + controller: _iOsControlsController!, + child: testSuperEditor, + ); + } + + if (_androidControlsController != null) { + testSuperEditor = SuperEditorAndroidControlsScope( + controller: _androidControlsController!, + child: testSuperEditor, + ); + } + + return testSuperEditor; + } + + Widget _buildSuperEditor() { + return SuperEditor( + key: widget.testConfiguration.key, + focusNode: widget.testDocumentContext.focusNode, + autofocus: widget.testConfiguration.autoFocus, + tapRegionGroupId: widget.testConfiguration.tapRegionGroupId, + contentTapDelegateFactories: + widget.testConfiguration.tapDelegateFactories ?? [superEditorLaunchLinkTapHandlerFactory], + editor: widget.testDocumentContext.editor, + documentLayoutKey: widget.testDocumentContext.layoutKey, + inputRole: widget.testConfiguration.inputRole, + inputSource: widget.testConfiguration.inputSource, + selectionPolicies: widget.testConfiguration.selectionPolicies ?? const SuperEditorSelectionPolicies(), + selectionStyle: widget.testConfiguration.selectionStyles, + softwareKeyboardController: widget.testConfiguration.softwareKeyboardController, + imePolicies: widget.testConfiguration.imePolicies ?? const SuperEditorImePolicies(), + imeConfiguration: widget.testConfiguration.imeConfiguration, + imeOverrides: widget.testConfiguration.imeOverrides, + isImeConnected: widget.testConfiguration.isImeConnected, + keyboardActions: [ + ...widget.testConfiguration.prependedKeyboardActions, + ...(widget.testConfiguration.inputSource == TextInputSource.ime + ? defaultImeKeyboardActions + : defaultKeyboardActions), + ...widget.testConfiguration.appendedKeyboardActions, + ], + selectorHandlers: widget.testConfiguration.selectorHandlers, + gestureMode: widget.testConfiguration.gestureMode, + stylesheet: widget.testConfiguration.stylesheet, + componentBuilders: [ + ...widget.testConfiguration.addedComponents, + ...(widget.testConfiguration.componentBuilders ?? defaultComponentBuilders), + if (widget.testConfiguration.componentBuilders == null) TaskComponentBuilder(widget.testDocumentContext.editor) + ], + scrollController: widget.testConfiguration.scrollController, + documentOverlayBuilders: _createOverlayBuilders(), + plugins: widget.testConfiguration.plugins, + ); + } + + List _createOverlayBuilders() { + // We show the default overlays except in the cases where we want to hide the caret + // or use a custom caret style. In those case, we don't include the defaults - we provide + // a configured caret overlay builder, instead. + // + // If you introduce further configuration to overlay builders, make sure that in the default + // situation, we're using `defaultSuperEditorDocumentOverlayBuilders`, so that most tests + // verify the defaults that most apps will use. + if (widget.testConfiguration.displayCaretWithExpandedSelection && + widget.testConfiguration.caretStyle == null && + widget.testConfiguration.iosCaretWidth == null && + widget.testConfiguration.iosHandleColor == null && + widget.testConfiguration.iosHandleBallDiameter == null && + widget.testConfiguration.androidCaretWidth == null) { + return defaultSuperEditorDocumentOverlayBuilders; + } + + // Copy and modify the default overlay builders + return [ + // Adds a Leader around the document selection at a focal point for the + // iOS floating toolbar. + const SuperEditorIosToolbarFocalPointDocumentLayerBuilder(), + // Displays caret and drag handles, specifically for iOS. + SuperEditorIosHandlesDocumentLayerBuilder( + caretWidth: widget.testConfiguration.iosCaretWidth, + handleColor: widget.testConfiguration.iosHandleColor, + handleBallDiameter: widget.testConfiguration.iosHandleBallDiameter, + ), + + // Adds a Leader around the document selection at a focal point for the + // Android floating toolbar. + const SuperEditorAndroidToolbarFocalPointDocumentLayerBuilder(), + // Displays caret and drag handles, specifically for Android. + SuperEditorAndroidHandlesDocumentLayerBuilder( + caretWidth: widget.testConfiguration.androidCaretWidth ?? 2.0, + caretColor: widget.testConfiguration.androidCaretColor, + ), + + // Displays caret for typical desktop use-cases. + DefaultCaretOverlayBuilder( + displayCaretWithExpandedSelection: widget.testConfiguration.displayCaretWithExpandedSelection, + caretStyle: widget.testConfiguration.caretStyle ?? const CaretStyle(), + ), + ]; + } +} + +class SuperEditorTestConfiguration { + SuperEditorTestConfiguration(this.tester, this.document); + + final WidgetTester tester; + + ThemeData? appTheme; + Key? key; + FocusNode? focusNode; + bool autoFocus = false; + String? tapRegionGroupId; + ui.Size? editorSize; + final MutableDocument document; + final addedRequestHandlers = []; + final addedReactions = []; + GlobalKey? layoutKey; + String? inputRole; + List? componentBuilders; + Stylesheet? stylesheet; + ScrollController? scrollController; + bool insideCustomScrollView = false; + DocumentGestureMode? gestureMode; + bool isHistoryEnabled = false; + HistoryGroupingPolicy? historyGroupPolicy; + TextInputSource? inputSource; + SuperEditorSelectionPolicies? selectionPolicies; + SelectionStyles? selectionStyles; + bool displayCaretWithExpandedSelection = true; + CaretStyle? caretStyle; + + // By default we don't use iOS-style selection heuristics in tests because in tests + // we want to know exactly where we're placing the caret. + bool useIosSelectionHeuristics = false; + double? iosCaretWidth; + Color? iosHandleColor; + double? iosHandleBallDiameter; + + double? androidCaretWidth; + Color? androidCaretColor; + + SoftwareKeyboardController? softwareKeyboardController; + bool simulateSoftwareKeyboardInsets = false; + double simulatedKeyboardHeight = 300; + bool animateSimulatedSoftwareKeyboard = false; + SuperEditorImePolicies? imePolicies; + SuperEditorImeConfiguration? imeConfiguration; + DeltaTextInputClientDecorator? imeOverrides; + ValueNotifier isImeConnected = ValueNotifier(false); + Map? selectorHandlers; + final prependedKeyboardActions = []; + final appendedKeyboardActions = []; + final addedComponents = []; + + DocumentFloatingToolbarBuilder? androidToolbarBuilder; + DocumentCollapsedHandleBuilder? androidCollapsedHandleBuilder; + DocumentExpandedHandlesBuilder? androidExpandedHandlesBuilder; + + DocumentFloatingToolbarBuilder? iOSToolbarBuilder; + + DocumentSelection? selection; + + List? tapDelegateFactories; + + final plugins = {}; + + WidgetTreeBuilder? widgetTreeBuilder; + double? appBarHeight; +} + +/// Must return a widget tree containing the given [superEditor] +typedef WidgetTreeBuilder = Widget Function(Widget superEditor); + +class TestDocumentContext { + const TestDocumentContext._({ + required this.focusNode, + required this.layoutKey, + required this.document, + required this.composer, + required this.editor, + required this.configuration, + }); + + final FocusNode focusNode; + final GlobalKey layoutKey; + // TODO: remove these document, editor, composer references + final MutableDocument document; + final MutableDocumentComposer composer; + final Editor editor; + SuperEditorContext findEditContext() => + ((find.byType(SuperEditor).evaluate().first as StatefulElement).state as SuperEditorState).editContext; + + final SuperEditorTestConfiguration configuration; +} + +class ConfiguredSuperEditorWidget { + const ConfiguredSuperEditorWidget(this.context, this.widget); + + final TestDocumentContext context; + final Widget widget; +} + +Matcher equalsMarkdown(String markdown) => DocumentEqualsMarkdownMatcher(markdown); + +class DocumentEqualsMarkdownMatcher extends Matcher { + const DocumentEqualsMarkdownMatcher(this._expectedMarkdown); + + final String _expectedMarkdown; + + @override + Description describe(Description description) { + return description.add("given Document has equivalent content to the given markdown"); + } + + @override + bool matches(covariant Object target, Map matchState) { + return _calculateMismatchReason(target, matchState) == null; + } + + @override + Description describeMismatch( + covariant Object target, + Description mismatchDescription, + Map matchState, + bool verbose, + ) { + final mismatchReason = _calculateMismatchReason(target, matchState); + if (mismatchReason != null) { + mismatchDescription.add(mismatchReason); + } + return mismatchDescription; + } + + String? _calculateMismatchReason( + Object target, + Map matchState, + ) { + late Document actualDocument; + if (target is Document) { + actualDocument = target; + } else { + // If we weren't given a Document, then we expect to receive a Finder + // that locates a SuperEditor, which contains a Document. + if (target is! Finder) { + return "the given target isn't a Document or a Finder: $target"; + } + + final document = SuperEditorInspector.findDocument(target); + if (document == null) { + return "Finder didn't match any SuperEditor widgets: $Finder"; + } + actualDocument = document; + } + + final actualMarkdown = serializeDocumentToMarkdown(actualDocument); + final stringMatcher = equals(_expectedMarkdown); + final matcherState = {}; + final matches = stringMatcher.matches(actualMarkdown, matcherState); + if (matches) { + // The document matches the markdown. Our matcher matches. + return null; + } + + return stringMatcher.describeMismatch(actualMarkdown, StringDescription(), matchState, false).toString(); + } +} + +Matcher documentEquivalentTo(Document expectedDocument) => EquivalentDocumentMatcher(expectedDocument); + +class EquivalentDocumentMatcher extends Matcher { + const EquivalentDocumentMatcher(this._expectedDocument); + + final Document _expectedDocument; + + @override + Description describe(Description description) { + return description.add("given Document has equivalent content to expected Document"); + } + + @override + bool matches(covariant Object target, Map matchState) { + return _calculateMismatchReason(target, matchState) == null; + } + + @override + Description describeMismatch( + covariant Object target, + Description mismatchDescription, + Map matchState, + bool verbose, + ) { + final mismatchReason = _calculateMismatchReason(target, matchState); + if (mismatchReason != null) { + mismatchDescription.add(mismatchReason); + } + return mismatchDescription; + } + + String? _calculateMismatchReason( + Object target, + Map matchState, + ) { + late Document actualDocument; + if (target is Document) { + actualDocument = target; + } else { + // If we weren't given a Document, then we expect to receive a Finder + // that locates a SuperEditor, which contains a Document. + if (target is! Finder) { + return "the given target isn't a Document or a Finder: $target"; + } + + final document = SuperEditorInspector.findDocument(target); + if (document == null) { + return "Finder didn't match any SuperEditor widgets: $Finder"; + } + actualDocument = document; + } + + final messages = []; + bool nodeCountMismatch = false; + bool nodeTypeOrContentMismatch = false; + + if (_expectedDocument.nodeCount != actualDocument.nodeCount) { + messages.add("expected ${_expectedDocument.nodeCount} document nodes but found ${actualDocument.nodeCount}"); + nodeCountMismatch = true; + } else { + messages.add("document have the same number of nodes"); + } + + final maxNodeCount = max(_expectedDocument.nodeCount, actualDocument.nodeCount); + final nodeComparisons = List.generate(maxNodeCount, (index) => ["", "", " "]); + for (int i = 0; i < maxNodeCount; i += 1) { + if (i < _expectedDocument.nodeCount && i < actualDocument.nodeCount) { + nodeComparisons[i][0] = _expectedDocument.getNodeAt(i)!.runtimeType.toString(); + nodeComparisons[i][1] = actualDocument.getNodeAt(i)!.runtimeType.toString(); + + if (_expectedDocument.getNodeAt(i)!.runtimeType != actualDocument.getNodeAt(i)!.runtimeType) { + nodeComparisons[i][2] = "Wrong Type"; + nodeTypeOrContentMismatch = true; + } else if (!_expectedDocument.getNodeAt(i)!.hasEquivalentContent(actualDocument.getNodeAt(i)!)) { + nodeComparisons[i][2] = "Different Content"; + nodeTypeOrContentMismatch = true; + } + } else if (i < _expectedDocument.nodeCount) { + nodeComparisons[i][0] = _expectedDocument.getNodeAt(i)!.runtimeType.toString(); + nodeComparisons[i][1] = "NA"; + nodeComparisons[i][2] = "Missing Node"; + } else if (i < actualDocument.nodeCount) { + nodeComparisons[i][0] = "NA"; + nodeComparisons[i][1] = actualDocument.getNodeAt(i)!.runtimeType.toString(); + nodeComparisons[i][2] = "Missing Node"; + } + } + + if (nodeCountMismatch || nodeTypeOrContentMismatch) { + String messagesList = messages.join(", "); + messagesList += "\n"; + messagesList += const TableRenderer().render(nodeComparisons, columns: ["Expected", "Actual", "Difference"]); + return messagesList; + } + + return null; + } +} + +/// A [ComponentBuilder] which builds an [ImageComponent] that always renders +/// images as a [SizedBox] with the given [size]. +class FakeImageComponentBuilder implements ComponentBuilder { + const FakeImageComponentBuilder({ + required this.size, + this.fillColor, + }); + + /// The size of the image component. + final ui.Size size; + + /// The color that fills the entire image component. + final Color? fillColor; + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + return null; + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! ImageComponentViewModel) { + return null; + } + + return ImageComponent( + componentKey: componentContext.componentKey, + imageUrl: componentViewModel.imageUrl, + selection: componentViewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection?, + selectionColor: componentViewModel.selectionColor, + imageBuilder: (context, imageUrl) => ColoredBox( + color: fillColor ?? Colors.transparent, + child: SizedBox( + height: size.height, + width: size.width, + ), + ), + ); + } +} + +/// Builds [TaskComponentViewModel]s and [ExpandingTaskComponent]s for every +/// [TaskNode] in a document. +class ExpandingTaskComponentBuilder extends ComponentBuilder { + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + if (node is! TaskNode) { + return null; + } + + return TaskComponentViewModel( + nodeId: node.id, + padding: EdgeInsets.zero, + isComplete: node.isComplete, + setComplete: (bool isComplete) {}, + text: node.text, + textStyleBuilder: noStyleBuilder, + selectionColor: const Color(0x00000000), + ); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! TaskComponentViewModel) { + return null; + } + + return ExpandingTaskComponent( + key: componentContext.componentKey, + viewModel: componentViewModel, + ); + } +} + +/// A task component which expands its height when it's selected. +class ExpandingTaskComponent extends StatefulWidget { + const ExpandingTaskComponent({ + super.key, + required this.viewModel, + }); + + final TaskComponentViewModel viewModel; + + @override + State createState() => _ExpandingTaskComponentState(); +} + +class _ExpandingTaskComponentState extends State + with ProxyDocumentComponent, ProxyTextComposable { + final _textKey = GlobalKey(); + + @override + GlobalKey> get childDocumentComponentKey => _textKey; + + @override + TextComposable get childTextComposable => childDocumentComponentKey.currentState as TextComposable; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent( + key: _textKey, + text: widget.viewModel.text, + textStyleBuilder: widget.viewModel.textStyleBuilder, + textSelection: widget.viewModel.selection, + selectionColor: widget.viewModel.selectionColor, + highlightWhenEmpty: widget.viewModel.highlightWhenEmpty, + ), + if (widget.viewModel.selection != null) // + const SizedBox(height: 20) + ], + ); + } +} + +class StandardEditorPieces { + StandardEditorPieces(this.document, this.composer, this.editor); + + final Document document; + final DocumentComposer composer; + final Editor editor; +} + +/// Fake [DocumentLayout], intended for tests that interact with +/// a logical [DocumentLayout] but do not depend upon a real +/// widget tree with a real [DocumentLayout] implementation. +class FakeDocumentLayout with Mock implements DocumentLayout {} + +/// Fake [SuperEditorScroll], intended for tests that interact +/// with logical resources but do not depend upon a real widget +/// tree with a real `Scrollable`. +class FakeSuperEditorScroller implements DocumentScroller { + @override + void dispose() {} + + @override + double get viewportDimension => throw UnimplementedError(); + + @override + double get minScrollExtent => throw UnimplementedError(); + + @override + double get maxScrollExtent => throw UnimplementedError(); + + @override + double get scrollOffset => throw UnimplementedError(); + + @override + void jumpTo(double newScrollOffset) => throw UnimplementedError(); + + @override + void jumpBy(double delta) => throw UnimplementedError(); + + @override + void animateTo(double to, {required Duration duration, Curve curve = Curves.easeInOut}) => throw UnimplementedError(); + + @override + void attach(ScrollPosition scrollPosition) => throw UnimplementedError(); + + @override + void detach() => throw UnimplementedError(); + + @override + void addScrollChangeListener(ui.VoidCallback listener) => throw UnimplementedError(); + + @override + void removeScrollChangeListener(ui.VoidCallback listener) => throw UnimplementedError(); +} diff --git a/super_editor/lib/src/test/super_editor_test/tasks_test_tools.dart b/super_editor/lib/src/test/super_editor_test/tasks_test_tools.dart new file mode 100644 index 0000000000..bb0508892b --- /dev/null +++ b/super_editor/lib/src/test/super_editor_test/tasks_test_tools.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_inspector.dart'; + +/// Queries the state of [TaskComponent]s in a [SuperEditor]. +class TaskInspector { + TaskInspector._(); + + static bool isChecked(String nodeId, [Finder? superEditorFinder]) { + final checkbox = _findTaskCheckbox(nodeId, superEditorFinder); + return checkbox.value == true; + } +} + +/// Extension on [WidgetTester] that interacts with [TaskComponent]s in a [SuperEditor]. +extension TaskRobot on WidgetTester { + Future tapOnCheckbox(String nodeId, [Finder? superEditorFinder]) async { + final checkbox = _findTaskCheckbox(nodeId, superEditorFinder); + await tap(find.byWidget(checkbox)); + await pumpAndSettle(); + } +} + +TaskComponent _findTaskComponent(String nodeId, [Finder? superEditorFinder]) { + return SuperEditorInspector.findWidgetForComponent(nodeId, superEditorFinder) as TaskComponent; +} + +Checkbox _findTaskCheckbox(String nodeId, [Finder? superEditorFinder]) { + final taskWidget = _findTaskComponent(nodeId, superEditorFinder); + + final checkboxes = find.descendant(of: find.byWidget(taskWidget), matching: find.byType(Checkbox)).evaluate(); + assert(checkboxes.isNotEmpty, "Couldn't find the Checkbox widget within a task widget with node ID: $nodeId"); + assert(checkboxes.length == 1, + "Found multiple Checkbox widgets within a task widget. We don't know which one to use. Node id: $nodeId"); + return checkboxes.first.widget as Checkbox; +} diff --git a/super_editor/test/super_reader/reader_test_tools.dart b/super_editor/lib/src/test/super_reader_test/reader_test_tools.dart similarity index 73% rename from super_editor/test/super_reader/reader_test_tools.dart rename to super_editor/lib/src/test/super_reader_test/reader_test_tools.dart index 1b4d8b1b08..af9c796bb7 100644 --- a/super_editor/test/super_reader/reader_test_tools.dart +++ b/super_editor/lib/src/test/super_reader_test/reader_test_tools.dart @@ -4,14 +4,11 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/src/default_editor/document_scrollable.dart'; import 'package:super_editor/src/test/super_reader_test/super_reader_inspector.dart'; +import 'package:super_editor/src/test/flutter_extensions/test_documents.dart'; import 'package:super_editor/super_editor.dart'; -import 'package:super_editor_markdown/super_editor_markdown.dart'; import 'package:text_table/text_table.dart'; -import 'test_documents.dart'; - /// Extensions on [WidgetTester] that configure and pump [SuperReader] /// document editors. extension DocumentTester on WidgetTester { @@ -61,6 +58,13 @@ class TestDocumentSelector { ); } + TestDocumentConfigurator withSingleParagraphShort() { + return TestDocumentConfigurator._( + _widgetTester, + singleParagraphShortDoc(), + ); + } + TestDocumentConfigurator withTwoEmptyParagraphs() { return TestDocumentConfigurator._( _widgetTester, @@ -84,20 +88,25 @@ class TestDocumentConfigurator { final MutableDocument? _document; DocumentGestureMode? _gestureMode; ThemeData? _appTheme; + SelectionStyles? _selectionStyles; Stylesheet? _stylesheet; final _addedComponents = []; - bool _autoFocus = false; ui.Size? _editorSize; List? _componentBuilders; WidgetTreeBuilder? _widgetTreeBuilder; ScrollController? _scrollController; + bool _insideCustomScrollView = false; FocusNode? _focusNode; + bool _autoFocus = false; + String? _tapRegionGroupId; DocumentSelection? _selection; + WidgetBuilder? _androidToolbarBuilder; + DocumentFloatingToolbarBuilder? _iOSToolbarBuilder; /// Configures the [SuperReader] for standard desktop interactions, /// e.g., mouse and keyboard input. TestDocumentConfigurator forDesktop({ - DocumentInputSource inputSource = DocumentInputSource.keyboard, + TextInputSource inputSource = TextInputSource.keyboard, }) { _gestureMode = DocumentGestureMode.mouse; return this; @@ -175,6 +184,18 @@ class TestDocumentConfigurator { } } + /// Configures the [SuperEditor] to use the given [builder] as its android toolbar builder. + TestDocumentConfigurator withAndroidToolbarBuilder(WidgetBuilder? builder) { + _androidToolbarBuilder = builder; + return this; + } + + /// Configures the [SuperEditor] to use the given [builder] as its iOS toolbar builder. + TestDocumentConfigurator withiOSToolbarBuilder(DocumentFloatingToolbarBuilder? builder) { + _iOSToolbarBuilder = builder; + return this; + } + /// Configures the [ThemeData] used for the [MaterialApp] that wraps /// the [SuperReader]. TestDocumentConfigurator useAppTheme(ThemeData theme) { @@ -182,6 +203,13 @@ class TestDocumentConfigurator { return this; } + /// Configures the [SuperReader] with the given selection [styles], which dictate the color of the + /// primary user's selection, and related selection details. + TestDocumentConfigurator withSelectionStyles(SelectionStyles? styles) { + _selectionStyles = styles; + return this; + } + /// Configures the [SuperReader] to use the given [stylesheet]. TestDocumentConfigurator useStylesheet(Stylesheet stylesheet) { _stylesheet = stylesheet; @@ -201,6 +229,24 @@ class TestDocumentConfigurator { return this; } + /// Configures the [SuperReader] to be displayed inside a [CustomScrollView]. + /// + /// The [CustomScrollView] is constrained by the size provided in [withEditorSize]. + /// + /// Use [withScrollController] to define the [ScrollController] of the [CustomScrollView]. + TestDocumentConfigurator insideCustomScrollView() { + _insideCustomScrollView = true; + return this; + } + + /// Configures the [SuperReader] to use the given [tapRegionGroupId]. + /// + /// This DOESN'T wrap the reader with a [TapRegion]. + TestDocumentConfigurator withTapRegionGroupId(String? tapRegionGroupId) { + _tapRegionGroupId = tapRegionGroupId; + return this; + } + /// Pumps a [SuperReader] widget tree with the desired configuration, and returns /// a [TestDocumentContext], which includes the artifacts connected to the widget /// tree, e.g., the [DocumentEditor], [DocumentComposer], etc. @@ -208,11 +254,13 @@ class TestDocumentConfigurator { assert(_document != null); final layoutKey = GlobalKey(); - final documentContext = ReaderContext( - document: _document!, + final documentContext = SuperReaderContext( + editor: createDefaultDocumentEditor( + document: _document!, + composer: MutableDocumentComposer(), + ), getDocumentLayout: () => layoutKey.currentState as DocumentLayout, - selection: ValueNotifier(_selection), - scrollController: AutoScrollController(), + scroller: DocumentScroller(), ); final testContext = TestDocumentContext._( focusNode: _focusNode ?? FocusNode(), @@ -221,20 +269,29 @@ class TestDocumentConfigurator { documentContext: documentContext, ); - final superDocument = _buildContent( - SuperReader( - focusNode: testContext.focusNode, - document: documentContext.document, - documentLayoutKey: layoutKey, - selection: documentContext.selection, - gestureMode: _gestureMode ?? _defaultGestureMode, - stylesheet: _stylesheet, - componentBuilders: [ - ..._addedComponents, - ...(_componentBuilders ?? defaultComponentBuilders), - ], - autofocus: _autoFocus, - scrollController: _scrollController, + final superDocument = _buildConstrainedContent( + _buildAncestorScrollable( + child: SuperReaderIosControlsScope( + controller: SuperReaderIosControlsController( + toolbarBuilder: _iOSToolbarBuilder, + ), + child: SuperReader( + focusNode: testContext.focusNode, + autofocus: _autoFocus, + tapRegionGroupId: _tapRegionGroupId, + editor: documentContext.editor, + documentLayoutKey: layoutKey, + selectionStyle: _selectionStyles, + gestureMode: _gestureMode ?? _defaultGestureMode, + stylesheet: _stylesheet, + componentBuilders: [ + ..._addedComponents, + ...(_componentBuilders ?? readOnlyDefaultComponentBuilders), + ], + scrollController: _scrollController, + androidToolbarBuilder: _androidToolbarBuilder, + ), + ), ), ); @@ -245,7 +302,7 @@ class TestDocumentConfigurator { return testContext; } - Widget _buildContent(Widget superReader) { + Widget _buildConstrainedContent(Widget superReader) { if (_editorSize != null) { return ConstrainedBox( constraints: BoxConstraints( @@ -258,6 +315,20 @@ class TestDocumentConfigurator { return superReader; } + /// Places [child] inside a [CustomScrollView], based on configurations in this class. + Widget _buildAncestorScrollable({required Widget child}) { + if (!_insideCustomScrollView) { + return child; + } + + return CustomScrollView( + controller: _scrollController, + slivers: [ + child, + ], + ); + } + Widget _buildWidgetTree(Widget superReader) { if (_widgetTreeBuilder != null) { return _widgetTreeBuilder!(superReader); @@ -267,6 +338,7 @@ class TestDocumentConfigurator { home: Scaffold( body: superReader, ), + debugShowCheckedModeBanner: false, ); } } @@ -287,7 +359,7 @@ class TestDocumentContext { // simulate content changes in a read-only document. final MutableDocument document; final GlobalKey layoutKey; - final ReaderContext documentContext; + final SuperReaderContext documentContext; } Matcher equalsMarkdown(String markdown) => DocumentEqualsMarkdownMatcher(markdown); @@ -411,35 +483,34 @@ class EquivalentDocumentMatcher extends Matcher { bool nodeCountMismatch = false; bool nodeTypeOrContentMismatch = false; - if (_expectedDocument.nodes.length != actualDocument.nodes.length) { - messages - .add("expected ${_expectedDocument.nodes.length} document nodes but found ${actualDocument.nodes.length}"); + if (_expectedDocument.nodeCount != actualDocument.nodeCount) { + messages.add("expected ${_expectedDocument.nodeCount} document nodes but found ${actualDocument.nodeCount}"); nodeCountMismatch = true; } else { messages.add("document have the same number of nodes"); } - final maxNodeCount = max(_expectedDocument.nodes.length, actualDocument.nodes.length); + final maxNodeCount = max(_expectedDocument.nodeCount, actualDocument.nodeCount); final nodeComparisons = List.generate(maxNodeCount, (index) => ["", "", " "]); for (int i = 0; i < maxNodeCount; i += 1) { - if (i < _expectedDocument.nodes.length && i < actualDocument.nodes.length) { - nodeComparisons[i][0] = _expectedDocument.nodes[i].runtimeType.toString(); - nodeComparisons[i][1] = actualDocument.nodes[i].runtimeType.toString(); + if (i < _expectedDocument.nodeCount && i < actualDocument.nodeCount) { + nodeComparisons[i][0] = _expectedDocument.getNodeAt(i)!.runtimeType.toString(); + nodeComparisons[i][1] = actualDocument.getNodeAt(i)!.runtimeType.toString(); - if (_expectedDocument.nodes[i].runtimeType != actualDocument.nodes[i].runtimeType) { + if (_expectedDocument.getNodeAt(i)!.runtimeType != actualDocument.getNodeAt(i)!.runtimeType) { nodeComparisons[i][2] = "Wrong Type"; nodeTypeOrContentMismatch = true; - } else if (!_expectedDocument.nodes[i].hasEquivalentContent(actualDocument.nodes[i])) { + } else if (!_expectedDocument.getNodeAt(i)!.hasEquivalentContent(actualDocument.getNodeAt(i)!)) { nodeComparisons[i][2] = "Different Content"; nodeTypeOrContentMismatch = true; } - } else if (i < _expectedDocument.nodes.length) { - nodeComparisons[i][0] = _expectedDocument.nodes[i].runtimeType.toString(); + } else if (i < _expectedDocument.nodeCount) { + nodeComparisons[i][0] = _expectedDocument.getNodeAt(i)!.runtimeType.toString(); nodeComparisons[i][1] = "NA"; nodeComparisons[i][2] = "Missing Node"; - } else if (i < actualDocument.nodes.length) { + } else if (i < actualDocument.nodeCount) { nodeComparisons[i][0] = "NA"; - nodeComparisons[i][1] = actualDocument.nodes[i].runtimeType.toString(); + nodeComparisons[i][1] = actualDocument.getNodeAt(i)!.runtimeType.toString(); nodeComparisons[i][2] = "Missing Node"; } } diff --git a/super_editor/lib/src/test/super_reader_test/super_reader_inspector.dart b/super_editor/lib/src/test/super_reader_test/super_reader_inspector.dart index 54260db4d0..3d70a4560a 100644 --- a/super_editor/lib/src/test/super_reader_test/super_reader_inspector.dart +++ b/super_editor/lib/src/test/super_reader_test/super_reader_inspector.dart @@ -1,5 +1,7 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/test/flutter_extensions/finders.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -51,16 +53,6 @@ class SuperReaderInspector { return alignment.withinRect(rect); } - /// Returns the (x,y) offset for a caret, if that caret appeared at the given [position]. - /// - /// {@macro super_document_finder} - static Offset calculateOffsetForCaret(DocumentPosition position, [Finder? finder]) { - final documentLayout = _findDocumentLayout(finder); - final positionRect = documentLayout.getRectForPosition(position); - assert(positionRect != null); - return positionRect!.topLeft; - } - /// Returns `true` if the entire content rectangle at [position] is visible on /// screen, or `false` otherwise. /// @@ -103,7 +95,35 @@ class SuperReaderInspector { /// {@macro super_document_finder} static AttributedText findTextInParagraph(String nodeId, [Finder? superDocumentFinder]) { final documentLayout = _findDocumentLayout(superDocumentFinder); - return (documentLayout.getComponentByNodeId(nodeId) as TextComponentState).widget.text; + final component = documentLayout.getComponentByNodeId(nodeId); + + if (component is TextComponentState) { + return component.widget.text; + } + + if (component is ProxyDocumentComponent) { + return (component.childDocumentComponentKey.currentState as TextComponentState).widget.text; + } + + throw Exception('The component for node id $nodeId is not a TextComponent.'); + } + + /// Finds the paragraph with the given [nodeId] and returns the paragraph's content as a [TextSpan]. + /// + /// A [TextSpan] is the fundamental way that Flutter styles text. It's the lowest level reflection + /// of what the user will see, short of rendering the actual UI. + /// + /// {@macro super_reader_finder} + static TextSpan findRichTextInParagraph(String nodeId, [Finder? superReaderFinder]) { + final documentLayout = _findDocumentLayout(superReaderFinder); + + final textComponentState = documentLayout.getComponentByNodeId(nodeId)!; + final superText = find + .descendant(of: find.byWidget(textComponentState.widget), matching: find.byType(SuperText)) + .evaluate() + .single + .widget as SuperText; + return superText.richText as TextSpan; } /// Finds and returns the [TextStyle] that's applied to the top-level of the [TextSpan] @@ -113,13 +133,13 @@ class SuperReaderInspector { static TextStyle? findParagraphStyle(String nodeId, [Finder? superDocumentFinder]) { final documentLayout = _findDocumentLayout(superDocumentFinder); - final textComponentState = documentLayout.getComponentByNodeId(nodeId) as TextComponentState; - final superTextWithSelection = find - .descendant(of: find.byWidget(textComponentState.widget), matching: find.byType(SuperTextWithSelection)) + final textComponentState = documentLayout.getComponentByNodeId(nodeId)!; + final superText = find + .descendant(of: find.byWidget(textComponentState.widget), matching: find.byType(SuperText)) .evaluate() .single - .widget as SuperTextWithSelection; - return superTextWithSelection.richText.style; + .widget as SuperText; + return superText.richText.style; } /// Returns the [DocumentNode] at given the [index]. @@ -135,11 +155,11 @@ class SuperReaderInspector { throw Exception('SuperReader not found'); } - if (index >= doc.nodes.length) { - throw Exception('Tried to access index $index in a document where the max index is ${doc.nodes.length - 1}'); + if (index >= doc.nodeCount) { + throw Exception('Tried to access index $index in a document where the max index is ${doc.nodeCount - 1}'); } - final node = doc.nodes[index]; + final node = doc.getNodeAt(index); if (node is! NodeType) { throw Exception('Tried to access a ${node.runtimeType} as $NodeType'); } @@ -161,5 +181,186 @@ class SuperReaderInspector { return documentLayoutElement.state as DocumentLayout; } + /// Returns `true` if [SuperReader]'s policy believes that a mobile toolbar should + /// be visible right now, or `false` otherwise. + /// + /// This inspection is different from [isMobileToolbarVisible] in a couple ways: + /// * On mobile web, [SuperReader] defers to the browser's built-in overlay + /// controls. Therefore, [wantsMobileToolbarToBeVisible] is `true` but + /// [isMobileToolbarVisible] is `false`. + /// * When an app customizes the toolbar, [SuperReader] might want to build + /// and display a toolbar, but the app overrode the toolbar widget and chose + /// to build empty space instead of a toolbar. In this case + /// [wantsMobileToolbarToBeVisible] is `true`, but [isMobileToolbarVisible] + /// is `false`. + static bool wantsMobileToolbarToBeVisible([Finder? superReaderFinder]) { + // TODO: add Android support + final toolbarManager = find.state(superReaderFinder); + if (toolbarManager == null) { + throw Exception( + "Tried to verify that SuperReader wants mobile toolbar to be visible, but couldn't find the toolbar manager widget."); + } + + return toolbarManager.wantsToDisplayToolbar; + } + + /// Returns `true` if the mobile floating toolbar is currently visible, or `false` + /// if it's not. + /// + /// The mobile floating toolbar looks different for iOS and Android, but on both + /// platforms it appears on top of the editor, near selected content. + /// + /// This method doesn't take a `superReaderFinder` because the toolbar is displayed + /// in the application overlay, and is therefore completely independent from the + /// [SuperReader] subtree. There's no obvious way to associate a toolbar with + /// a specific [SuperReader]. + /// + /// See also: [wantsMobileToolbarToBeVisible]. + static bool isMobileToolbarVisible() { + return find.byKey(DocumentKeys.mobileToolbar).evaluate().isNotEmpty; + } + + /// Returns `true` if [SuperReader]'s policy believes that a mobile magnifier + /// should be visible right now, or `false` otherwise. + /// + /// This inspection is different from [isMobileMagnifierVisible] in a couple ways: + /// * On mobile web, [SuperReader] defers to the browser's built-in overlay + /// controls. Therefore, [wantsMobileMagnifierToBeVisible] is `true` but + /// [isMobileMagnifierVisible] is `false`. + /// * When an app customizes the magnifier, [SuperReader] might want to build + /// and display a magnifier, but the app overrode the magnifier widget and chose + /// to build empty space instead of a magnifier. In this case + /// [wantsMobileMagnifierToBeVisible] is `true`, but [isMobileMagnifierVisible] + /// is `false`. + static bool wantsMobileMagnifierToBeVisible([Finder? superReaderFinder]) { + // TODO: add Android support + final magnifierManager = find.state(superReaderFinder); + if (magnifierManager == null) { + throw Exception( + "Tried to verify that SuperReader wants mobile magnifier to be visible, but couldn't find the magnifier manager widget."); + } + + return magnifierManager.wantsToDisplayMagnifier; + } + + /// Returns `true` if a mobile magnifier is currently visible, or `false` if it's + /// not. + /// + /// The mobile magnifier looks different for iOS and Android. The magnifier also + /// follows different focal points depending on whether it's iOS or Android. + /// But in both cases, a magnifier is a small shape near the user's finger or + /// selection, which shows the editor content at an enlarged/magnified level. + /// + /// This method doesn't take a `superReaderFinder` because the magnifier is displayed + /// in the application overlay, and is therefore completely independent from the + /// [SuperReader] subtree. There's no obvious way to associate a magnifier with + /// a specific [SuperReader]. + /// + /// See also: [wantsMobileMagnifierToBeVisible] + static bool isMobileMagnifierVisible() { + return find.byKey(DocumentKeys.magnifier).evaluate().isNotEmpty; + } + + /// Returns `true` if any type of mobile drag handles are visible, or `false` + /// if not. + /// + /// On iOS, drag handles include the caret, as well as the upstream and downstream + /// handles. + /// + /// On Android, drag handles include the caret handle, as well as the upstream and + /// downstream drag handles. The caret drag handle on Android disappears after a brief + /// period of inactivity, and reappears upon another user interaction. + static Finder findAllMobileDragHandles([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return find.byWidgetPredicate( + (widget) => + widget.key == DocumentKeys.androidCaretHandle || + widget.key == DocumentKeys.upstreamHandle || + widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.iOS: + return find.byWidgetPredicate( + (widget) => + widget.key == DocumentKeys.caret || + widget.key == DocumentKeys.upstreamHandle || + widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileCaret([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.caret); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileCaretDragHandle([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return find.byKey(DocumentKeys.androidCaretHandle); + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.caret); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileExpandedDragHandles([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byWidgetPredicate( + (widget) => widget.key == DocumentKeys.upstreamHandle || widget.key == DocumentKeys.downstreamHandle, + ); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileUpstreamDragHandle([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.upstreamHandle); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + + static Finder findMobileDownstreamDragHandle([Finder? superReaderFinder]) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return find.byKey(DocumentKeys.downstreamHandle); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return FindsNothing(); + } + } + SuperReaderInspector._(); } diff --git a/super_editor/lib/src/test/super_reader_test/super_reader_robot.dart b/super_editor/lib/src/test/super_reader_test/super_reader_robot.dart index 1350fd45d9..bf0b80ae06 100644 --- a/super_editor/lib/src/test/super_reader_test/super_reader_robot.dart +++ b/super_editor/lib/src/test/super_reader_test/super_reader_robot.dart @@ -1,8 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_test_robots/flutter_test_robots.dart'; -import 'package:super_editor/src/test/ime.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; import 'package:super_editor/super_editor.dart'; /// Extensions on [WidgetTester] for interacting with a [SuperReader] the way @@ -84,9 +85,14 @@ extension SuperReaderRobot on WidgetTester { /// to ensure that the drag rectangle never has a zero-width or a /// zero-height, because such a drag rectangle wouldn't be seen as /// intersecting any content. + /// + /// Provide a [pointerDeviceKind] to override the device kind used in the gesture. + /// If [pointerDeviceKind] is `null`, it defaults to [PointerDeviceKind.touch] + /// on mobile, and [PointerDeviceKind.mouse] on other platforms. Future dragSelectDocumentFromPositionByOffset({ required DocumentPosition from, required Offset delta, + PointerDeviceKind? pointerDeviceKind, Finder? superReaderFinder, }) async { final documentLayout = _findDocumentLayout(superReaderFinder); @@ -124,8 +130,13 @@ extension SuperReaderRobot on WidgetTester { } } + final deviceKind = pointerDeviceKind ?? + (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.android + ? PointerDeviceKind.touch + : PointerDeviceKind.mouse); + // Simulate the drag. - final gesture = await startGesture(dragStartOffset, kind: PointerDeviceKind.mouse); + final gesture = await startGesture(dragStartOffset, kind: deviceKind); // Move slightly so that a "pan start" is reported. // @@ -183,6 +194,36 @@ extension SuperReaderRobot on WidgetTester { await pumpAndSettle(); } + Future pressDownOnDownstreamMobileHandle() async { + final handleElement = find + .byWidgetPredicate((widget) => + (widget is AndroidSelectionHandle && widget.handleType == HandleType.downstream) || + (widget is IOSSelectionHandle && widget.handleType == HandleType.downstream)) + .evaluate() + .firstOrNull; + assert(handleElement != null, "Tried to press down on downstream handle but no handle was found."); + final renderHandle = handleElement!.renderObject as RenderBox; + final handleCenter = renderHandle.localToGlobal(renderHandle.size.center(Offset.zero)); + + final gesture = await startGesture(handleCenter); + return gesture; + } + + Future pressDownOnUpstreamMobileHandle() async { + final handleElement = find + .byWidgetPredicate((widget) => + (widget is AndroidSelectionHandle && widget.handleType == HandleType.upstream) || + (widget is IOSSelectionHandle && widget.handleType == HandleType.upstream)) + .evaluate() + .firstOrNull; + assert(handleElement != null, "Tried to press down on upstream handle but no handle was found."); + final renderHandle = handleElement!.renderObject as RenderBox; + final handleCenter = renderHandle.localToGlobal(renderHandle.size.center(Offset.zero)); + + final gesture = await startGesture(handleCenter); + return gesture; + } + DocumentLayout _findDocumentLayout([Finder? superReaderFinder]) { late final Finder layoutFinder; if (superReaderFinder != null) { diff --git a/super_editor/lib/src/test/super_text_field_test/super_text_field_inspector.dart b/super_editor/lib/src/test/super_text_field_test/super_text_field_inspector.dart new file mode 100644 index 0000000000..8286c3e0e0 --- /dev/null +++ b/super_editor/lib/src/test/super_text_field_test/super_text_field_inspector.dart @@ -0,0 +1,384 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +/// Inspects that state of a [SuperTextField] in a test. +class SuperTextFieldInspector { + /// Finds and returns the [ProseTextLayout] within a [SuperTextField]. + /// + /// {@macro supertextfield_finder} + static ProseTextLayout findProseTextLayout([Finder? superTextFieldFinder]) { + final finder = superTextFieldFinder ?? find.byType(SuperTextField); + final element = finder.evaluate().single as StatefulElement; + return (element.state as SuperTextFieldState).textLayout; + } + + /// Finds and returns the [AttributedText] within a [SuperTextField]. + /// + /// {@macro supertextfield_finder} + static AttributedText findText([Finder? superTextFieldFinder]) { + final finder = superTextFieldFinder ?? find.byType(SuperTextField); + final element = finder.evaluate().single as StatefulElement; + final state = element.state as SuperTextFieldState; + return state.controller.text; + } + + /// Finds and returns the [RichText] within a [SuperTextField]. + /// + /// {@macro supertextfield_finder} + static InlineSpan findRichText([Finder? superTextFieldFinder]) { + final resolvedSuperTextFieldFinder = superTextFieldFinder ?? find.byType(SuperTextField); + + // Try to find a SuperTextField that contains a SuperText. It's possible + // that a SuperTextField uses a SuperTextWithSelection instead of a SuperText, + // that condition is handled later. + final superTextFinder = find.descendant(of: resolvedSuperTextFieldFinder, matching: find.byType(SuperText)); + final superTextElements = superTextFinder.evaluate(); + + if (superTextElements.length > 1) { + throw Exception("Found more than 1 super text field match with finder: $resolvedSuperTextFieldFinder"); + } + + if (superTextElements.length == 1) { + final element = superTextFinder.evaluate().single as StatefulElement; + final state = element.state as SuperTextState; + return state.widget.richText; + } + + // We didn't find a SuperTextField with a SuperText. Now we'll search for a + // SuperTextField with a selection. + final superTextWithSelectionFinder = + find.descendant(of: resolvedSuperTextFieldFinder, matching: find.byType(SuperTextWithSelection)); + final superTextWithSelectionElements = superTextWithSelectionFinder.evaluate(); + + if (superTextWithSelectionElements.length > 1) { + throw Exception("Found more than 1 super text field match with finder: $resolvedSuperTextFieldFinder"); + } + + if (superTextWithSelectionElements.length == 1) { + final element = superTextWithSelectionFinder.evaluate().single as StatefulElement; + final state = element.state as SuperTextState; + return state.widget.richText; + } + + throw Exception("Couldn't find a super text field variant with the given finder: $resolvedSuperTextFieldFinder"); + } + + /// Finds and returns the [TextSelection] within a [SuperTextField]. + /// + /// {@macro supertextfield_finder} + static TextSelection? findSelection([Finder? superTextFieldFinder]) { + final finder = superTextFieldFinder ?? find.byType(SuperTextField); + final element = finder.evaluate().single as StatefulElement; + final state = element.state as SuperTextFieldState; + return state.controller.selection; + } + + /// Returns `true` if the [SuperTextField] currently has focus. + /// + /// {@macro supertextfield_finder} + static bool hasFocus([Finder? superTextFieldFinder]) { + final finder = superTextFieldFinder ?? find.byType(SuperTextField); + final element = finder.evaluate().single as StatefulElement; + final state = element.state as SuperTextFieldState; + return state.hasFocus; + } + + /// Returns `true` if the given [SuperTextField] is a single-line text field. + /// + /// {@macro supertextfield_finder} + static bool isSingleLine([Finder? superTextFieldFinder]) { + final finder = superTextFieldFinder ?? find.byType(SuperTextField); + + final fieldFinder = findInnerPlatformTextField(finder); + final match = fieldFinder.evaluate().single.widget; + + switch (match.runtimeType) { + case SuperDesktopTextField: + return (match as SuperDesktopTextField).maxLines == 1; + case SuperAndroidTextField: + return (match as SuperAndroidTextField).maxLines == 1; + case SuperIOSTextField: + return (match as SuperIOSTextField).maxLines == 1; + default: + throw Exception("Found unknown SuperTextField platform widget: $match"); + } + } + + /// Returns `true` if the given [SuperTextField] is a multi-line text field. + /// + /// {@macro supertextfield_finder} + static bool isMultiLine([Finder? superTextFieldFinder]) { + return !isSingleLine(superTextFieldFinder); + } + + /// Returns `true` if the given [SuperTextField] is scrollable, i.e., the content + /// exceeds the viewport size. + static bool hasScrollableExtent([Finder? superTextFieldFinder]) { + final desktopScrollController = findDesktopScrollController(superTextFieldFinder); + if (desktopScrollController != null) { + return desktopScrollController.position.maxScrollExtent > 0; + } + + final mobileScrollController = findMobileScrollController(superTextFieldFinder); + if (mobileScrollController != null) { + return mobileScrollController.endScrollOffset > 0; + } + + throw Exception("Couldn't find a SuperTextField to check the scrollable extent. Finder: $superTextFieldFinder"); + } + + /// Returns `true` if the given [SuperTextField] has a scroll offset of zero, i.e., + /// is scrolled to the beginning of the viewport. + /// + /// This inspection applies to both horizontal and vertical scrolling text fields. + /// + /// {@macro supertextfield_finder} + static bool isScrolledToBeginning([Finder? superTextFieldFinder]) { + return findScrollOffset(superTextFieldFinder) == 0.0; + } + + /// Returns `true` if the given [SuperTextField] is scrolled all the away to the + /// end of the viewport. + /// + /// This inspection applies to both horizontal and vertical scrolling text fields. + /// + /// {@macro supertextfield_finder} + static bool isScrolledToEnd([Finder? superTextFieldFinder]) { + final maxScrollOffset = findDesktopScrollController(superTextFieldFinder)?.position.maxScrollExtent ?? + findMobileScrollController(superTextFieldFinder)?.endScrollOffset; + assert(maxScrollOffset != null, + "Couldn't check if SuperTextField is scrolled to the end because no SuperTextField was found."); + return findScrollOffset(superTextFieldFinder) == maxScrollOffset; + } + + /// Finds and returns the scroll offset, in the direction of scrolling, + /// within a [SuperTextField]. + /// + /// {@macro supertextfield_finder} + static double? findScrollOffset([Finder? superTextFieldFinder]) { + final finder = superTextFieldFinder ?? find.byType(SuperTextField); + + final fieldFinder = findInnerPlatformTextField(finder); + final match = fieldFinder.evaluate().single.widget; + + if (match is SuperDesktopTextField) { + final textScrollViewElement = find + .descendant( + of: finder, + matching: find.byType(SuperTextFieldScrollview), + ) + .evaluate() + .single as StatefulElement; + final textScrollView = textScrollViewElement.widget as SuperTextFieldScrollview; + + return textScrollView.scrollController.offset; + } + + // Both mobile textfields use TextScrollView. + final textScrollViewElement = find + .descendant( + of: finder, + matching: find.byType(TextScrollView), + ) + .evaluate() + .single as StatefulElement; + final textScrollView = textScrollViewElement.widget as TextScrollView; + + return textScrollView.textScrollController.scrollOffset; + } + + static double? findMaxScrollOffset([Finder? superTextFieldFinder]) { + final finder = superTextFieldFinder ?? find.byType(SuperTextField); + + final fieldFinder = findInnerPlatformTextField(finder); + final match = fieldFinder.evaluate().single.widget; + + if (match is SuperDesktopTextField) { + final textScrollViewElement = find + .descendant( + of: finder, + matching: find.byType(SuperTextFieldScrollview), + ) + .evaluate() + .single as StatefulElement; + final textScrollView = textScrollViewElement.widget as SuperTextFieldScrollview; + + return textScrollView.scrollController.position.maxScrollExtent; + } + + // Both mobile textfields use TextScrollView. + final textScrollViewElement = find + .descendant( + of: finder, + matching: find.byType(TextScrollView), + ) + .evaluate() + .single as StatefulElement; + final textScrollView = textScrollViewElement.widget as TextScrollView; + + return textScrollView.textScrollController.endScrollOffset; + } + + static ScrollController? findDesktopScrollController([Finder? superTextFieldFinder]) { + final finder = superTextFieldFinder ?? find.byType(SuperTextField); + + final fieldFinder = findInnerPlatformTextField(finder); + final match = fieldFinder.evaluate().single.widget; + if (match is! SuperDesktopTextField) { + return null; + } + + final textScrollViewElement = find + .descendant( + of: finder, + matching: find.byType(SuperTextFieldScrollview), + ) + .evaluate() + .single as StatefulElement; + final textScrollView = textScrollViewElement.widget as SuperTextFieldScrollview; + + return textScrollView.scrollController; + } + + static TextScrollController? findMobileScrollController([Finder? superTextFieldFinder]) { + final finder = superTextFieldFinder ?? find.byType(SuperTextField); + + final fieldFinder = findInnerPlatformTextField(finder); + final match = fieldFinder.evaluate().single.widget; + if (match is! SuperAndroidTextField && match is! SuperIOSTextField) { + return null; + } + + // Both mobile textfields use TextScrollView. + final textScrollViewElement = find + .descendant( + of: finder, + matching: find.byType(TextScrollView), + ) + .evaluate() + .single as StatefulElement; + final textScrollView = textScrollViewElement.widget as TextScrollView; + + return textScrollView.textScrollController; + } + + /// Finds and returns the bounding rectangle for the caret in the given [SuperTextField], + /// represented as coordinates that are local to the viewport. + /// + /// The viewport is the rectangle within which (possibly) scrollable text is displayed. + /// + /// {@macro supertextfield_finder} + static Rect? findCaretRectInViewport([Finder? superTextFieldFinder]) { + final rootFieldFinder = superTextFieldFinder ?? find.byType(SuperTextField); + + final desktopTextField = find.descendant(of: rootFieldFinder, matching: find.byType(SuperDesktopTextField)); + if (desktopTextField.evaluate().isNotEmpty) { + return _findCaretRectInViewportOnDesktop(desktopTextField); + } + + final iOSTextField = find.descendant(of: rootFieldFinder, matching: find.byType(SuperIOSTextField)); + if (iOSTextField.evaluate().isNotEmpty) { + return _findCaretRectInViewportOnMobile(iOSTextField); + } + + final androidTextField = find.descendant(of: rootFieldFinder, matching: find.byType(SuperAndroidTextField)); + if (androidTextField.evaluate().isNotEmpty) { + return _findCaretRectInViewportOnMobile(androidTextField); + } + + throw Exception( + "Couldn't find the caret rectangle because we couldn't find a SuperTextField. Finder: $superTextFieldFinder"); + } + + static Rect? _findCaretRectInViewportOnDesktop(Finder desktopTextField) { + final viewport = find + .descendant(of: desktopTextField, matching: find.byType(SuperTextFieldScrollview)) + .evaluate() + .single + .renderObject as RenderBox; + + final caretDisplayElement = find + .descendant(of: desktopTextField, matching: find.byType(TextLayoutCaret)) + .evaluate() + .single as StatefulElement; + final caretDisplay = caretDisplayElement.state as TextLayoutCaretState; + final caretGlobalRect = caretDisplay.globalCaretGeometry!; + + final viewportOffset = viewport.localToGlobal(Offset.zero); + return caretGlobalRect.translate(-viewportOffset.dx, -viewportOffset.dy); + } + + static Rect? _findCaretRectInViewportOnMobile(Finder mobileFieldFinder) { + final viewport = find + .descendant(of: mobileFieldFinder, matching: find.byType(TextScrollView)) + .evaluate() + .single + .renderObject as RenderBox; + + final caretDisplayElement = find + .descendant(of: mobileFieldFinder, matching: find.byType(TextLayoutCaret)) + .evaluate() + .single as StatefulElement; + final caretDisplay = caretDisplayElement.state as TextLayoutCaretState; + final caretGlobalRect = caretDisplay.globalCaretGeometry!; + + final viewportOffset = viewport.localToGlobal(Offset.zero); + return caretGlobalRect.translate(-viewportOffset.dx, -viewportOffset.dy); + } + + static bool isAndroidCollapsedHandleVisible([Finder? superTextFieldFinder]) { + final fieldFinder = + SuperTextFieldInspector.findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); + final match = (fieldFinder.evaluate().single as StatefulElement).state as SuperAndroidTextFieldState; + + return match.isCollapsedHandleVisible; + } + + /// Finds and returns the platform textfield within a [SuperTextField]. + /// + /// {@macro supertextfield_finder} + static Finder findInnerPlatformTextField([Finder? superTextFieldFinder]) { + final rootFieldFinder = superTextFieldFinder ?? find.byType(SuperTextField); + + final rootMatches = rootFieldFinder.evaluate(); + if (rootMatches.isEmpty) { + throw Exception("Couldn't find a super text field variant with the given finder: $rootFieldFinder"); + } + if (rootMatches.length > 1) { + throw Exception("Found more than 1 super text field match with finder: $rootFieldFinder"); + } + + final rootMatch = rootMatches.single.widget; + if (rootMatch is! SuperTextField) { + // The match isn't a generic SuperTextField. Assume that it's a platform + // specific super text field, which is what we're looking for. Return it. + return rootFieldFinder; + } + + final desktopFieldCandidates = + find.descendant(of: rootFieldFinder, matching: find.byType(SuperDesktopTextField)).evaluate(); + if (desktopFieldCandidates.isNotEmpty) { + return find.descendant(of: rootFieldFinder, matching: find.byType(SuperDesktopTextField)); + } + + final androidFieldCandidates = + find.descendant(of: rootFieldFinder, matching: find.byType(SuperAndroidTextField)).evaluate(); + if (androidFieldCandidates.isNotEmpty) { + return find.descendant(of: rootFieldFinder, matching: find.byType(SuperAndroidTextField)); + } + + final iosFieldCandidates = + find.descendant(of: rootFieldFinder, matching: find.byType(SuperIOSTextField)).evaluate(); + if (iosFieldCandidates.isNotEmpty) { + return find.descendant(of: rootFieldFinder, matching: find.byType(SuperIOSTextField)); + } + + throw Exception( + "Couldn't find the platform-specific super text field within the root SuperTextField. Root finder: $rootFieldFinder"); + } + + SuperTextFieldInspector._(); +} diff --git a/super_editor/lib/src/test/super_text_field_test/super_text_field_robot.dart b/super_editor/lib/src/test/super_text_field_test/super_text_field_robot.dart new file mode 100644 index 0000000000..42ee6d8112 --- /dev/null +++ b/super_editor/lib/src/test/super_text_field_test/super_text_field_robot.dart @@ -0,0 +1,593 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; +import 'package:super_editor/src/test/super_text_field_test/super_text_field_inspector.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +/// Extensions on [WidgetTester] for interacting with a [SuperTextField] the way +/// a user would. +extension SuperTextFieldRobot on WidgetTester { + /// Taps to place a caret at the given [offset]. + /// + /// {@template supertextfield_finder} + /// By default, this method expects a single [SuperTextField] in the widget tree and + /// finds it `byType`. To specify one [SuperTextField] among many, pass a [superTextFieldFinder]. + /// {@endtemplate} + Future placeCaretInSuperTextField(int offset, + [Finder? superTextFieldFinder, TextAffinity affinity = TextAffinity.downstream]) async { + final fieldFinder = + SuperTextFieldInspector.findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); + final match = fieldFinder.evaluate().single.widget; + bool found = false; + final scrollDelta = SuperTextFieldInspector.findScrollOffset(superTextFieldFinder)!; + final scrollOffset = + SuperTextFieldInspector.isSingleLine(superTextFieldFinder) ? Offset(scrollDelta, 0) : Offset(0, scrollDelta); + + if (match is SuperDesktopTextField) { + final didTap = await _tapAtTextPositionOnDesktop( + state(fieldFinder), offset, affinity, scrollOffset); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + found = true; + } else if (match is SuperAndroidTextField) { + final didTap = await _tapAtTextPositionOnAndroid( + state(fieldFinder), offset, affinity, scrollOffset); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + found = true; + } else if (match is SuperIOSTextField) { + final didTap = + await _tapAtTextPositionOnIOS(state(fieldFinder), offset, affinity, scrollOffset); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + found = true; + } + + if (found) { + await pumpAndSettle(kTapTimeout); + } else { + throw Exception("Couldn't find a SuperTextField with the given Finder: $fieldFinder"); + } + } + + Future tapOnCaretInSuperTextField([Finder? superTextFieldFinder]) async { + final caretLayerFinder = find.descendant( + of: superTextFieldFinder ?? find.byType(SuperTextField), + matching: find.byType(TextLayoutCaret), + ); + expect(caretLayerFinder, findsOne); + + final caretLayerElement = caretLayerFinder.evaluate().first as StatefulElement; + final caretLayerState = caretLayerElement.state as TextLayoutCaretState; + final caretGeometry = caretLayerState.globalCaretGeometry!; + + await tapAt(caretGeometry.center); + await pump(); + } + + Future dragCaretByDistanceInSuperTextField(Offset delta, [Finder? superTextFieldFinder]) async { + final caretLayerFinder = find.descendant( + of: superTextFieldFinder ?? find.byType(SuperTextField), + matching: find.byType(TextLayoutCaret), + ); + expect(caretLayerFinder, findsOne); + + final caretLayerElement = caretLayerFinder.evaluate().first as StatefulElement; + final caretLayerState = caretLayerElement.state as TextLayoutCaretState; + final caretGeometry = caretLayerState.globalCaretGeometry!; + + final gesture = await startGesture(caretGeometry.center); + await pump(kTapMinTime); + + for (int i = 0; i < 50; i += 1) { + await gesture.moveBy(delta / 50); + await pump(const Duration(milliseconds: 50)); + } + + return gesture; + } + + Future dragAndroidCollapsedHandleByDistanceInSuperTextField(Offset delta, + [Finder? superTextFieldFinder]) async { + // Ensure that the collapsed handle is visible. + expect(SuperTextFieldInspector.isAndroidCollapsedHandleVisible(superTextFieldFinder), isTrue); + + // TODO: lookup the actual handle size and offset when follow_the_leader correctly reports global bounds for followers + // Use our knowledge that the handle sits directly beneath the caret to drag it. + final caretLayerFinder = find.descendant( + of: superTextFieldFinder ?? find.byType(SuperTextField), + matching: find.byType(TextLayoutCaret), + ); + expect(caretLayerFinder, findsOne); + + final caretLayerElement = caretLayerFinder.evaluate().first as StatefulElement; + final caretLayerState = caretLayerElement.state as TextLayoutCaretState; + final caretBottom = caretLayerState.globalCaretGeometry!.bottomCenter; + final handleCenter = caretBottom + const Offset(0, 12); + + final gesture = await startGesture(handleCenter); + await pump(kTapMinTime); + + for (int i = 0; i < 50; i += 1) { + await gesture.moveBy(delta / 50); + await pump(const Duration(milliseconds: 50)); + } + + return gesture; + } + + /// Drags the [SuperTextField] upstream handle by the given delta and + /// returns the [TestGesture] used to perform the drag. + /// + /// {@macro supertextfield_finder} + Future dragUpstreamMobileHandleByDistanceInSuperTextField( + Offset delta, [ + Finder? superTextFieldFinder, + ]) async { + final fieldFinder = + SuperTextFieldInspector.findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); + final match = fieldFinder.evaluate().single.widget; + + if (match is SuperAndroidTextField) { + return _dragAndroidUpstreamHandleByDistanceInSuperTextField(delta, fieldFinder); + } + + if (match is SuperIOSTextField) { + return _dragIOSUpstreamHandleByDistanceInSuperTextField(delta, fieldFinder); + } + + throw Exception("Couldn't find a SuperTextField with the given Finder: $fieldFinder"); + } + + /// Drags the [SuperTextField] downstream handle by the given delta and + /// returns the [TestGesture] used to perform the drag. + /// + /// {@macro supertextfield_finder} + Future dragDownstreamMobileHandleByDistanceInSuperTextField( + Offset delta, [ + Finder? superTextFieldFinder, + ]) async { + final fieldFinder = + SuperTextFieldInspector.findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); + final match = fieldFinder.evaluate().single.widget; + + if (match is SuperAndroidTextField) { + return _dragAndroidDownstreamHandleByDistanceInSuperTextField(delta, fieldFinder); + } + + if (match is SuperIOSTextField) { + return _dragIOSDownstreamHandleByDistanceInSuperTextField(delta, fieldFinder); + } + + throw Exception("Couldn't find a SuperTextField with the given Finder: $fieldFinder"); + } + + /// Drags the [SuperAndroidTextField] upstream handle by the given delta and + /// returns the [TestGesture] used to perform the drag. + /// + /// {@macro supertextfield_finder} + Future _dragAndroidUpstreamHandleByDistanceInSuperTextField( + Offset delta, [ + Finder? superTextFieldFinder, + ]) async { + // TODO: lookup the actual handle size and offset when follow_the_leader correctly reports global bounds for followers + // Use our knowledge that the handle sits directly beneath the caret to drag it. + final handleFinder = find.descendant( + of: superTextFieldFinder ?? find.byType(SuperAndroidTextField), + matching: find.byWidgetPredicate( + (widget) => + widget is AndroidSelectionHandle && // + widget.handleType == HandleType.upstream, + ), + ); + + expect(handleFinder, findsOne); + + return await _dragHandleByDistanceInSuperTextField(handleFinder, delta); + } + + /// Drags the [SuperAndroidTextField] downstream handle by the given delta and + /// returns the [TestGesture] used to perform the drag. + /// + /// {@macro supertextfield_finder} + Future _dragAndroidDownstreamHandleByDistanceInSuperTextField( + Offset delta, [ + Finder? superTextFieldFinder, + ]) async { + // TODO: lookup the actual handle size and offset when follow_the_leader correctly reports global bounds for followers + // Use our knowledge that the handle sits directly beneath the caret to drag it. + final handleFinder = find.descendant( + of: superTextFieldFinder ?? find.byType(SuperAndroidTextField), + matching: find.byWidgetPredicate( + (widget) => + widget is AndroidSelectionHandle && // + widget.handleType == HandleType.downstream, + ), + ); + + expect(handleFinder, findsOne); + + return await _dragHandleByDistanceInSuperTextField(handleFinder, delta); + } + + /// Drags the [SuperIOSTextField] upstream handle by the given delta and + /// returns the [TestGesture] used to perform the drag. + /// + /// {@macro supertextfield_finder} + Future _dragIOSUpstreamHandleByDistanceInSuperTextField( + Offset delta, [ + Finder? superTextFieldFinder, + ]) async { + // TODO: lookup the actual handle size and offset when follow_the_leader correctly reports global bounds for followers + // Use our knowledge that the handle sits directly beneath the caret to drag it. + final handleFinder = find.descendant( + of: superTextFieldFinder ?? find.byType(SuperIOSTextField), + matching: find.byWidgetPredicate( + (widget) => + widget is IOSSelectionHandle && // + widget.handleType == HandleType.upstream, + ), + ); + + expect(handleFinder, findsOne); + + return await _dragHandleByDistanceInSuperTextField(handleFinder, delta); + } + + /// Drags the [SuperIOSTextField] downstream handle by the given delta and + /// returns the [TestGesture] used to perform the drag. + /// + /// {@macro supertextfield_finder} + Future _dragIOSDownstreamHandleByDistanceInSuperTextField( + Offset delta, [ + Finder? superTextFieldFinder, + ]) async { + // TODO: lookup the actual handle size and offset when follow_the_leader correctly reports global bounds for followers + // Use our knowledge that the handle sits directly beneath the caret to drag it. + final handleFinder = find.descendant( + of: superTextFieldFinder ?? find.byType(SuperIOSTextField), + matching: find.byWidgetPredicate( + (widget) => + widget is IOSSelectionHandle && // + widget.handleType == HandleType.downstream, + ), + ); + + expect(handleFinder, findsOne); + + return await _dragHandleByDistanceInSuperTextField(handleFinder, delta); + } + + /// Drags the [SuperTextField] handle found by [superTextFieldHandleFinder] by + /// the given delta and returns the [TestGesture] used to perform the drag. + /// + /// Can be used to drag [SuperTextField] collapsed or expanded selection handles. + Future _dragHandleByDistanceInSuperTextField( + Finder superTextFieldHandleFinder, + Offset delta, + ) async { + final handleCenter = getCenter(superTextFieldHandleFinder); + + final gesture = await startGesture(handleCenter); + await pump(kTapMinTime); + + for (int i = 0; i < 50; i += 1) { + await gesture.moveBy(delta / 50); + await pump(const Duration(milliseconds: 50)); + } + + return gesture; + } + + /// Tap on an Android collapsed drag handle. + /// + /// {@macro supertextfield_finder} + Future tapOnAndroidCollapsedHandle([Finder? superTextFieldFinder]) async { + final handleElement = find + .byWidgetPredicate( + (widget) => + widget is AndroidSelectionHandle && // + widget.handleType == HandleType.collapsed, + ) + .evaluate() + .firstOrNull; + assert(handleElement != null, "Tried to press down on Android collapsed handle but no handle was found."); + final renderHandle = handleElement!.renderObject as RenderBox; + final handleCenter = renderHandle.localToGlobal(renderHandle.size.center(Offset.zero)); + + await tapAt(handleCenter); + } + + /// Taps in a [SuperTextField] at the given [offset] + /// + /// {@macro supertextfield_finder} + Future tapAtSuperTextField( + int offset, { + Finder? superTextFieldFinder, + TextAffinity affinity = TextAffinity.downstream, + int buttons = kPrimaryButton, + }) async { + await _tapAtSuperTextField(offset, 1, superTextFieldFinder, affinity, buttons); + } + + /// Double taps in a [SuperTextField] at the given [offset] + /// + /// {@macro supertextfield_finder} + Future doubleTapAtSuperTextField(int offset, + [Finder? superTextFieldFinder, TextAffinity affinity = TextAffinity.downstream]) async { + await _tapAtSuperTextField(offset, 2, superTextFieldFinder, affinity); + } + + /// Triple taps in a [SuperTextField] at the given [offset] + /// + /// {@macro supertextfield_finder} + Future tripleTapAtSuperTextField(int offset, + [Finder? superTextFieldFinder, TextAffinity affinity = TextAffinity.downstream]) async { + await _tapAtSuperTextField(offset, 3, superTextFieldFinder, affinity); + } + + Future _tapAtSuperTextField(int offset, int tapCount, + [Finder? superTextFieldFinder, + TextAffinity affinity = TextAffinity.downstream, + int buttons = kPrimaryButton]) async { + // TODO: De-duplicate this behavior with placeCaretInSuperTextField + final fieldFinder = + SuperTextFieldInspector.findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); + final match = fieldFinder.evaluate().single.widget; + final scrollDelta = SuperTextFieldInspector.findScrollOffset(superTextFieldFinder)!; + final scrollOffset = + SuperTextFieldInspector.isSingleLine(superTextFieldFinder) ? Offset(scrollDelta, 0) : Offset(0, scrollDelta); + + if (match is SuperDesktopTextField) { + final superDesktopTextField = state(fieldFinder); + for (int i = 1; i <= tapCount; i++) { + bool didTap = await _tapAtTextPositionOnDesktop( + superDesktopTextField, + offset, + affinity, + scrollOffset, + buttons, + ); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + await pump(tapCount > 1 ? kDoubleTapMinTime : kDoubleTapTimeout); + } + + await pumpAndSettle(); + + return; + } + + if (match is SuperAndroidTextField) { + for (int i = 1; i <= tapCount; i++) { + bool didTap = await _tapAtTextPositionOnAndroid( + state(fieldFinder), + offset, + affinity, + scrollOffset, + buttons, + ); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + await pump(tapCount > 1 ? kDoubleTapMinTime : kDoubleTapTimeout); + } + + await pumpAndSettle(); + + return; + } + + if (match is SuperIOSTextField) { + for (int i = 1; i <= tapCount; i++) { + bool didTap = await _tapAtTextPositionOnIOS( + state(fieldFinder), + offset, + affinity, + scrollOffset, + buttons, + ); + if (!didTap) { + throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); + } + await pump(tapCount > 1 ? kDoubleTapMinTime : kDoubleTapTimeout); + } + + await pumpAndSettle(); + + return; + } + + throw Exception("Couldn't find a SuperTextField with the given Finder: $fieldFinder"); + } + + Future _tapAtTextPositionOnDesktop( + SuperDesktopTextFieldState textField, + int offset, [ + TextAffinity textAffinity = TextAffinity.downstream, + Offset scrollOffset = Offset.zero, + int buttons = kPrimaryButton, + ]) async { + final textFieldBox = textField.context.findRenderObject() as RenderBox; + return await _tapAtTextPositionInTextLayout( + textField.textLayout, + textField.textLayoutOffsetInField, + textFieldBox, + offset, + textAffinity, + scrollOffset, + buttons, + ); + } + + Future _tapAtTextPositionOnAndroid( + SuperAndroidTextFieldState textField, + int offset, [ + TextAffinity textAffinity = TextAffinity.downstream, + Offset scrollOffset = Offset.zero, + int buttons = kPrimaryButton, + ]) async { + final textFieldBox = textField.context.findRenderObject() as RenderBox; + return await _tapAtTextPositionInTextLayout( + textField.textLayout, + textField.textLayoutOffsetInField, + textFieldBox, + offset, + textAffinity, + scrollOffset, + buttons, + ); + } + + Future _tapAtTextPositionOnIOS( + SuperIOSTextFieldState textField, + int offset, [ + TextAffinity textAffinity = TextAffinity.downstream, + Offset scrollOffset = Offset.zero, + int buttons = kPrimaryButton, + ]) async { + final textFieldBox = textField.context.findRenderObject() as RenderBox; + return await _tapAtTextPositionInTextLayout( + textField.textLayout, + textField.textLayoutOffsetInField, + textFieldBox, + offset, + textAffinity, + scrollOffset, + buttons, + ); + } + + Future _tapAtTextPositionInTextLayout( + TextLayout textLayout, + Offset textOffsetInField, // i.e., the padding around the text + RenderBox textFieldBox, + int offset, [ + TextAffinity textAffinity = TextAffinity.downstream, + Offset scrollOffset = Offset.zero, + int buttons = kPrimaryButton, + ]) async { + final textPositionOffset = textLayout.getOffsetForCaret( + TextPosition(offset: offset, affinity: textAffinity), + ); + + // Adjust the text offset from text layout coordinates to text field viewport coordinates + // by adding the scroll offset. + Offset adjustedOffset = textPositionOffset - scrollOffset; + + // When upgrading Superlist to Flutter 3, some tests showed a caret offset + // dy of -0.2. This didn't happen everywhere, but it did happen some places. + // Until we get to the bottom of this issue, we'll add a constant offset to + // make up for this. + adjustedOffset += const Offset(0, 0.2); + + // There's a problem on Windows and Linux where we get -0.0 instead of 0.0. + // We adjust the offset to get rid of the -0.0, because a -0.0 fails the + // Rect bounds check. (https://github.com/flutter/flutter/issues/100033) + adjustedOffset = Offset( + adjustedOffset.dx, + // I tried checking "== -0.0" but it didn't catch the problem. This + // approach looks for an arbitrarily small epsilon and then interprets + // any such bounds as zero. + adjustedOffset.dy.abs() < 1e-6 ? 0.0 : adjustedOffset.dy, + ); + + if (adjustedOffset.dx == textFieldBox.size.width) { + adjustedOffset += const Offset(-10, 0); + } + + if (!textFieldBox.size.contains(adjustedOffset)) { + throw Exception( + "Couldn't tap at text position because it's not visible. Text field viewport size: ${textFieldBox.size}, text offset in viewport $adjustedOffset. Raw text offset: $textPositionOffset). Text field scroll offset: $scrollOffset", + ); + } + + final globalTapOffset = textOffsetInField + adjustedOffset + textFieldBox.localToGlobal(Offset.zero); + await tapAt( + globalTapOffset, + buttons: buttons, + ); + return true; + } + + Future selectSuperTextFieldText(int start, int end, [Finder? superTextFieldFinder]) async { + final fieldFinder = + SuperTextFieldInspector.findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); + final match = fieldFinder.evaluate().single.widget; + + if (match is SuperDesktopTextField) { + final didSelectText = await _selectTextOnDesktop(state(fieldFinder), start, end); + if (!didSelectText) { + throw Exception("One or both of the desired text offsets weren't tappable in SuperTextField: $start -> $end"); + } + + // Pump and settle so that the gesture recognizer doesn't retain pending timers. + await pumpAndSettle(); + + return; + } + + if (match is SuperAndroidTextField) { + throw Exception("Selecting text on an Android SuperTextField is not yet supported"); + } + + if (match is SuperIOSTextField) { + throw Exception("Selecting text on an iOS SuperTextField is not yet supported"); + } + + throw Exception("Couldn't find a SuperTextField with the given Finder: $fieldFinder"); + } + + Future _selectTextOnDesktop(SuperDesktopTextFieldState textField, int start, int end) async { + final startTextPositionOffset = textField.textLayout.getOffsetForCaret(TextPosition(offset: start)); + final endTextPositionOffset = textField.textLayout.getOffsetForCaret(TextPosition(offset: end)); + final textFieldBox = textField.context.findRenderObject() as RenderBox; + + // When upgrading Superlist to Flutter 3, some tests showed a caret offset + // dy of -0.2. This didn't happen everywhere, but it did happen some places. + // Until we get to the bottom of this issue, we'll add a constant offset to + // make up for this. + Offset adjustedStartOffset = startTextPositionOffset + const Offset(0, 0.2); + Offset adjustedEndOffset = endTextPositionOffset + const Offset(0, 0.2); + + // There's a problem on Windows and Linux where we get -0.0 instead of 0.0. + // We adjust the offset to get rid of the -0.0, because a -0.0 fails the + // Rect bounds check. (https://github.com/flutter/flutter/issues/100033) + adjustedStartOffset = Offset( + adjustedStartOffset.dx, + // I tried checking "== -0.0" but it didn't catch the problem. This + // approach looks for an arbitrarily small epsilon and then interprets + // any such bounds as zero. + adjustedStartOffset.dy.abs() < 1e-6 ? 0.0 : adjustedStartOffset.dy, + ); + adjustedEndOffset = Offset( + adjustedEndOffset.dx, + adjustedEndOffset.dy.abs() < 1e-6 ? 0.0 : adjustedEndOffset.dy, + ); + + if (!textFieldBox.size.contains(adjustedStartOffset)) { + return false; + } + if (!textFieldBox.size.contains(adjustedEndOffset)) { + return false; + } + + final globalStartDragOffset = adjustedStartOffset + textFieldBox.localToGlobal(Offset.zero); + final globalEndDragOffset = adjustedEndOffset + textFieldBox.localToGlobal(Offset.zero); + + await dragFrom( + globalStartDragOffset, + globalEndDragOffset - globalStartDragOffset, + kind: PointerDeviceKind.mouse, + ); + + return true; + } +} diff --git a/super_editor/lib/src/test/test_globals.dart b/super_editor/lib/src/test/test_globals.dart new file mode 100644 index 0000000000..909c00adac --- /dev/null +++ b/super_editor/lib/src/test/test_globals.dart @@ -0,0 +1,5 @@ +class Testing { + static bool isInTest = false; + + const Testing._(); +} diff --git a/super_editor/lib/src/undo_redo.dart b/super_editor/lib/src/undo_redo.dart new file mode 100644 index 0000000000..de6002c529 --- /dev/null +++ b/super_editor/lib/src/undo_redo.dart @@ -0,0 +1,43 @@ +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; + +/// Undoes the most recent change within the [Editor]. +ExecutionInstruction undoWhenCmdZOrCtrlZIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.keyZ || + !keyEvent.isPrimaryShortcutKeyPressed || + HardwareKeyboard.instance.isShiftPressed) { + return ExecutionInstruction.continueExecution; + } + + editContext.editor.undo(); + + return ExecutionInstruction.haltExecution; +} + +/// Re-runs the most recently undone change within the [Editor]. +ExecutionInstruction redoWhenCmdShiftZOrCtrlShiftZIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.keyZ || + !keyEvent.isPrimaryShortcutKeyPressed || + !HardwareKeyboard.instance.isShiftPressed) { + return ExecutionInstruction.continueExecution; + } + + editContext.editor.redo(); + + return ExecutionInstruction.haltExecution; +} diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index e04c741e5f..623c60ff53 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -7,57 +7,116 @@ export 'package:super_text_layout/src/caret_layer.dart'; export 'src/core/document.dart'; export 'src/core/document_composer.dart'; export 'src/core/document_debug_paint.dart'; -export 'src/core/document_editor.dart'; export 'src/core/document_interaction.dart'; export 'src/core/document_layout.dart'; export 'src/core/document_selection.dart'; export 'src/core/edit_context.dart'; +export 'src/core/editor.dart'; export 'src/core/styles.dart'; +// Chat +export 'src/chat/message_page_scaffold.dart'; +export 'src/chat/plugins/chat_preview_mode_plugin.dart'; +export 'src/chat/super_message.dart'; +export 'src/chat/super_message_android_overlays.dart'; +export 'src/chat/super_message_android_touch_interactor.dart'; +export 'src/chat/super_message_ios_overlays.dart'; +export 'src/chat/super_message_ios_touch_interactor.dart'; +export 'src/chat/super_message_keyboard_interactor.dart'; +export 'src/chat/super_message_mouse_interactor.dart'; + // Super Editor +export 'src/default_editor/ai/content_fading.dart'; export 'src/default_editor/attributions.dart'; +export 'src/default_editor/blocks/indentation.dart'; export 'src/default_editor/blockquote.dart'; export 'src/default_editor/box_component.dart'; export 'src/default_editor/common_editor_operations.dart'; +export 'src/default_editor/composer/composer_reactions.dart'; +export 'src/default_editor/debug_visualization.dart'; +export 'src/default_editor/default_document_editor.dart'; +export 'src/default_editor/default_document_editor_reactions.dart'; export 'src/default_editor/document_caret_overlay.dart'; +export 'src/default_editor/document_focus_and_selection_policies.dart'; export 'src/infrastructure/document_gestures.dart'; export 'src/default_editor/document_gestures_mouse.dart'; -export 'src/default_editor/document_gestures_touch.dart'; -export 'src/default_editor/document_input_ime.dart'; -export 'src/default_editor/document_input_keyboard.dart'; -export 'src/default_editor/document_keyboard_actions.dart'; +export 'src/infrastructure/document_gestures_interaction_overrides.dart'; +export 'src/default_editor/document_gestures_touch_ios.dart'; +export 'src/default_editor/document_gestures_touch_android.dart'; +export 'src/default_editor/document_ime/document_input_ime.dart'; +export 'src/default_editor/document_layers/attributed_text_bounds_overlay.dart'; +export 'src/default_editor/document_hardware_keyboard/document_input_keyboard.dart'; export 'src/default_editor/horizontal_rule.dart'; export 'src/default_editor/image.dart'; export 'src/default_editor/layout_single_column/layout_single_column.dart'; +export 'src/default_editor/layout_single_column/super_editor_dry_layout.dart'; export 'src/default_editor/list_items.dart'; export 'src/default_editor/multi_node_editing.dart'; export 'src/default_editor/paragraph.dart'; +export 'src/default_editor/layout_single_column/selection_aware_viewmodel.dart'; export 'src/default_editor/selection_binary.dart'; export 'src/default_editor/selection_upstream_downstream.dart'; export 'src/default_editor/super_editor.dart'; +export 'src/default_editor/tables/table_block.dart'; +export 'src/default_editor/tasks.dart'; export 'src/default_editor/text.dart'; +export 'src/default_editor/tables/table_markdown.dart'; +export 'src/default_editor/text_ai.dart'; export 'src/default_editor/text_tools.dart'; +export 'src/default_editor/text/custom_underlines.dart'; +export 'src/default_editor/text_tokenizing/action_tags.dart'; +export 'src/default_editor/text_tokenizing/pattern_tags.dart'; +export 'src/default_editor/text_tokenizing/tags.dart'; +export 'src/default_editor/text_tokenizing/stable_tags.dart'; +export 'src/default_editor/spelling_and_grammar/spelling_and_grammar_styler.dart'; export 'src/default_editor/unknown_component.dart'; // Document operations used by SuperEditor and/or SuperReader, // also made available for public use. export 'src/document_operations/selection_operations.dart'; -export 'src/infrastructure/_listenable_builder.dart'; +export 'src/infrastructure/multi_listenable_builder.dart'; export 'src/infrastructure/_logging.dart'; export 'src/infrastructure/attributed_text_styles.dart'; +export 'src/infrastructure/attribution_layout_bounds.dart'; export 'src/infrastructure/composable_text.dart'; -export 'src/infrastructure/focus.dart'; +export 'src/infrastructure/content_layers.dart'; +export 'src/infrastructure/content_layers_for_boxes.dart'; +export 'src/infrastructure/content_layers_for_slivers.dart'; +export 'src/infrastructure/documents/document_layers.dart'; +export 'src/infrastructure/documents/document_scroller.dart'; +export 'src/infrastructure/documents/selection_leader_document_layer.dart'; export 'src/infrastructure/ime_input_owner.dart'; export 'src/infrastructure/keyboard.dart'; export 'src/infrastructure/multi_tap_gesture.dart'; +export 'src/infrastructure/pausable_value_notifier.dart'; +export 'src/infrastructure/flutter/overlay_with_groups.dart'; +export 'src/infrastructure/flutter/text_selection.dart'; export 'src/infrastructure/platforms/android/android_document_controls.dart'; +export 'src/infrastructure/platforms/android/toolbar.dart'; export 'src/infrastructure/platforms/ios/ios_document_controls.dart'; +export 'src/infrastructure/platforms/ios/ios_system_context_menu.dart'; +export 'src/infrastructure/platforms/ios/floating_cursor.dart'; +export 'src/infrastructure/platforms/ios/toolbar.dart'; +export 'src/infrastructure/platforms/ios/magnifier.dart'; +export 'src/infrastructure/platforms/ios/selection_heuristics.dart'; +export 'src/infrastructure/platforms/mac/mac_ime.dart'; export 'src/infrastructure/platforms/mobile_documents.dart'; +export 'src/infrastructure/scrolling/desktop_mouse_wheel_and_trackpad_scrolling.dart'; export 'src/infrastructure/scrolling_diagnostics/scrolling_diagnostics.dart'; +export 'src/infrastructure/signal_notifier.dart'; +export 'src/infrastructure/sliver_hybrid_stack.dart'; export 'src/infrastructure/strings.dart'; -export 'src/infrastructure/super_textfield/super_textfield.dart'; +export 'src/super_textfield/super_textfield.dart'; export 'src/infrastructure/touch_controls.dart'; +export 'src/infrastructure/text_input.dart'; +export 'src/infrastructure/popovers.dart'; +export 'src/infrastructure/document_context.dart'; +export 'src/infrastructure/selectable_list.dart'; +export 'src/infrastructure/actions.dart'; +export 'src/infrastructure/keyboard_panel_scaffold.dart'; + +export 'src/default_editor/tap_handlers/tap_handlers.dart'; // Super Reader export 'src/super_reader/read_only_document_android_touch_interactor.dart'; @@ -66,3 +125,50 @@ export 'src/super_reader/read_only_document_keyboard_interactor.dart'; export 'src/super_reader/read_only_document_mouse_interactor.dart'; export 'src/super_reader/reader_context.dart'; export 'src/super_reader/super_reader.dart'; +export 'src/super_reader/tasks.dart'; + +// Markdown Serialization +export 'src/infrastructure/serialization/markdown/document_to_markdown_serializer.dart'; +export 'src/infrastructure/serialization/markdown/image_syntax.dart'; +export 'src/infrastructure/serialization/markdown/markdown_inline_parser.dart'; +export 'src/infrastructure/serialization/markdown/markdown_inline_upstream_plugin.dart'; +export 'src/infrastructure/serialization/markdown/markdown_to_attributed_text_parsing.dart'; +export 'src/infrastructure/serialization/markdown/markdown_to_document_parsing.dart'; +export 'src/infrastructure/serialization/markdown/super_editor_paste_markdown.dart'; +export 'src/infrastructure/serialization/markdown/super_editor_syntax.dart'; +export 'src/infrastructure/serialization/markdown/table.dart'; + +// Quill Serialization +export 'src/infrastructure/serialization/quill/parsing/block_formats.dart'; +export 'src/infrastructure/serialization/quill/parsing/inline_formats.dart'; +export 'src/infrastructure/serialization/quill/parsing/parser.dart'; + +export 'src/infrastructure/serialization/quill/serializing/serializing.dart'; +export 'src/infrastructure/serialization/quill/serializing/serializers.dart'; + +export 'src/infrastructure/serialization/quill/content/formatting.dart'; +export 'src/infrastructure/serialization/quill/content/multimedia.dart'; + +// HTML Serialization +export 'src/infrastructure/serialization/html/document_to_html.dart'; +export 'src/infrastructure/serialization/html/html_blockquotes.dart'; +export 'src/infrastructure/serialization/html/html_code.dart'; +export 'src/infrastructure/serialization/html/html_headers.dart'; +export 'src/infrastructure/serialization/html/html_horizontal_rules.dart'; +export 'src/infrastructure/serialization/html/html_images.dart'; +export 'src/infrastructure/serialization/html/html_inline_text_styles.dart'; +export 'src/infrastructure/serialization/html/html_list_items.dart'; +export 'src/infrastructure/serialization/html/html_paragraphs.dart'; +export 'src/infrastructure/serialization/plain_text/document_to_plain_text.dart'; + +// Export from super_text_layout so that downstream clients don't +// have to add this package to get access to these classes. +export 'package:super_text_layout/super_text_layout.dart' + show + UnderlineStyle, + SquiggleUnderlineStyle, + SquiggleUnderlinePainter, + DottedUnderlineStyle, + DottedUnderlinePainter, + StraightUnderlineStyle, + StraightUnderlinePainter; diff --git a/super_editor/lib/super_editor_test.dart b/super_editor/lib/super_editor_test.dart index 2d6562ca94..6605522995 100644 --- a/super_editor/lib/super_editor_test.dart +++ b/super_editor/lib/super_editor_test.dart @@ -1,5 +1,10 @@ library super_editor_test; +export 'src/infrastructure/platforms/platform.dart'; export 'src/test/ime.dart'; export 'src/test/super_editor_test/supereditor_inspector.dart'; export 'src/test/super_editor_test/supereditor_robot.dart'; +export 'src/test/super_editor_test/supereditor_test_tools.dart'; + +// Quill Serialization +export 'src/infrastructure/serialization/quill/testing/quill_delta_comparison.dart'; diff --git a/super_editor/lib/super_reader_test.dart b/super_editor/lib/super_reader_test.dart index 2a81dd5804..ea02cd2e00 100644 --- a/super_editor/lib/super_reader_test.dart +++ b/super_editor/lib/super_reader_test.dart @@ -1,5 +1,6 @@ library super_document_test; export 'src/test/ime.dart'; +export 'src/test/super_reader_test/reader_test_tools.dart'; export 'src/test/super_reader_test/super_reader_inspector.dart'; export 'src/test/super_reader_test/super_reader_robot.dart'; diff --git a/super_editor/lib/super_test.dart b/super_editor/lib/super_test.dart new file mode 100644 index 0000000000..6d5742505c --- /dev/null +++ b/super_editor/lib/super_test.dart @@ -0,0 +1,10 @@ +export 'src/test/flutter_extensions/finders.dart' + if (dart.library.js_interop) 'src/test/extensions/flutter_extensions_stub.dart'; +export 'src/test/flutter_extensions/test_documents.dart' + if (dart.library.js_interop) 'src/test/extensions/flutter_extensions_stub.dart'; +export 'src/test/flutter_extensions/test_flutter_extensions.dart' + if (dart.library.js_interop) 'src/test/extensions/flutter_extensions_stub.dart'; +export 'src/test/flutter_extensions/test_tools_goldens.dart' + if (dart.library.js_interop) 'src/test/extensions/flutter_extensions_stub.dart'; +export 'src/test/flutter_extensions/test_tools_user_input.dart' + if (dart.library.js_interop) 'src/test/extensions/flutter_extensions_stub.dart'; diff --git a/super_editor/lib/super_text_field.dart b/super_editor/lib/super_text_field.dart new file mode 100644 index 0000000000..d3dea4f61c --- /dev/null +++ b/super_editor/lib/super_text_field.dart @@ -0,0 +1,10 @@ +library super_text_field; + +// The whole text field. +export 'src/super_textfield/super_textfield.dart'; + +// Tap handlers. +export 'src/super_textfield/infrastructure/text_field_tap_handlers.dart'; + +// Tools for building new text fields. +export 'src/super_textfield/infrastructure/text_field_border.dart'; diff --git a/super_editor/lib/super_text_field_test.dart b/super_editor/lib/super_text_field_test.dart new file mode 100644 index 0000000000..37ab490be5 --- /dev/null +++ b/super_editor/lib/super_text_field_test.dart @@ -0,0 +1,2 @@ +export 'src/test/super_text_field_test/super_text_field_inspector.dart'; +export 'src/test/super_text_field_test/super_text_field_robot.dart'; diff --git a/super_editor/pubspec.yaml b/super_editor/pubspec.yaml index 1f7120c7c3..9dc873ad39 100644 --- a/super_editor/pubspec.yaml +++ b/super_editor/pubspec.yaml @@ -1,39 +1,60 @@ name: super_editor description: Configurable, composable, extensible text editor and document renderer for Flutter. -version: 0.2.3+1 +version: 0.3.0-dev.10 homepage: https://github.com/superlistapp/super_editor +funding: + - https://flutterbountyhunters.com + - https://github.com/sponsors/matthew-carroll +topics: + - rich-text-editor + - editor + - quill + - markdown screenshots: - - description: 'SuperEditor on Mac desktop' + - description: "SuperEditor on Mac desktop" path: doc/marketing/screenshot_mac_desktop.png - - description: 'SuperEditor dark mode' + - description: "SuperEditor dark mode" path: doc/marketing/screenshot_mac_desktop_dark-mode.png - - description: 'SuperEditor text selection' + - description: "SuperEditor text selection" path: doc/marketing/screenshot_mac_desktop_text-selection.png - - description: 'SuperEditor text styles' + - description: "SuperEditor text styles" path: doc/marketing/screenshot_mac_desktop_bold-selection.png environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" flutter: ">=1.17.0" dependencies: flutter: sdk: flutter - attributed_text: ^0.2.0 - characters: ^1.2.0 + attributed_text: ^0.4.0 + characters: ^1.3.0 collection: ^1.15.0 - http: ^0.13.1 - linkify: ^4.0.0 - logging: ^1.0.1 - super_text_layout: ^0.1.4 - uuid: ^3.0.3 + dart_quill_delta: ^9.4.1 + follow_the_leader: ^0.5.3 + http: ^1.2.2 + linkify: ^5.0.0 + logging: ^1.3.0 + markdown: ^7.2.1 + super_text_layout: ^0.1.20 + super_keyboard: ^0.4.0 + url_launcher: ^6.3.1 + uuid: ^4.5.1 + overlord: 0.4.2 # Dependencies for testing tools that we ship with super_editor flutter_test: sdk: flutter - flutter_test_robots: ^0.0.17 + flutter_test_robots: ^0.0.24 + clock: ^1.1.1 + golden_toolkit: ^0.15.0 + meta: ^1.8.0 + matcher: ^0.12.16+1 + mockito: ^5.0.4 + path: ^1.8.3 + text_table: ^4.0.3 dependency_overrides: # Override to local mono-repo path so devs can test this repo @@ -44,12 +65,12 @@ dependency_overrides: path: ../super_text_layout dev_dependencies: + args: ^2.3.1 flutter_lints: ^2.0.1 - golden_toolkit: ^0.11.0 - mockito: ^5.0.4 - super_editor_markdown: - path: ../super_editor_markdown - text_table: ^4.0.1 + flutter_test_runners: ^0.0.4 + flutter_test_goldens: ^0.0.7 + golden_bricks: ^1.0.0 + golden_runner: ^0.2.0 flutter: # no Flutter configuration diff --git a/super_editor/test/chat/chat_preview_mode_test.dart b/super_editor/test/chat/chat_preview_mode_test.dart new file mode 100644 index 0000000000..b24c05cb7a --- /dev/null +++ b/super_editor/test/chat/chat_preview_mode_test.dart @@ -0,0 +1,455 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_inspector.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_keyboard/super_keyboard.dart'; + +import '../infrastructure/keyboard_panel_scaffold_test.dart'; + +void main() { + group("Chat > preview mode >", () { + testWidgetsOnMobilePhone("activates and deactivates with focus", (tester) async { + await _pumpScaffold(tester, _longDocument); + + // Ensure we begin in preview mode, hiding everything after the first + // component. + expect(SuperEditorInspector.maybeFindWidgetForComponent("1"), isNotNull); + expect(SuperEditorInspector.maybeFindWidgetForComponent("2"), isNull); + expect(SuperEditorInspector.maybeFindWidgetForComponent("3"), isNull); + + // Tap the editor to focus it, and disable preview mode. + await tester.placeCaretInParagraph("1", 0); + + // Ensure we're now in normal mode, showing the entire document. + expect(SuperEditorInspector.maybeFindWidgetForComponent("1"), isNotNull); + expect(SuperEditorInspector.maybeFindWidgetForComponent("2"), isNotNull); + expect(SuperEditorInspector.maybeFindWidgetForComponent("3"), isNotNull); + }); + }); +} + +final _longDocument = MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("This is the first paragraph which takes up multiple lines of height."), + ), + ParagraphNode(id: "2", text: AttributedText("This is paragraph 2.")), + ParagraphNode(id: "3", text: AttributedText("This is paragraph 3.")), + ], +); + +Future _pumpScaffold(WidgetTester tester, MutableDocument document) async { + final editor = createDefaultAiMessageEditor(document: document); + final messagePageController = MessagePageController(); + final scrollController = ScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: MessagePageScaffold( + controller: messagePageController, + contentBuilder: (context, bottomSpacing) { + return const SizedBox(); + }, + bottomSheetBuilder: (context) { + return _ChatEditor( + editor: editor, + inputRole: 'chat-preview-test-editor', + messagePageController: messagePageController, + scrollController: scrollController, + ); + }, + ), + ), + ), + ); +} + +// TODO: When we have a good selection of public chat editor APIs, delete all of the +// following infrastructure and replace it with the standard public versions from +// the package (at the time of writing, we don't have ready-made public chat APIs yet). +class _ChatEditor extends StatefulWidget { + const _ChatEditor({ + required this.editor, + required this.inputRole, + required this.messagePageController, + required this.scrollController, + }); + + final Editor editor; + final String inputRole; + final MessagePageController messagePageController; + final ScrollController scrollController; + + @override + State<_ChatEditor> createState() => _ChatEditorState(); +} + +class _ChatEditorState extends State<_ChatEditor> { + final _editorKey = GlobalKey(); + final _editorFocusNode = FocusNode(); + + final _previewModePlugin = ChatPreviewModePlugin(); + + late final KeyboardPanelController<_Panel> _keyboardPanelController; + late final SoftwareKeyboardController _softwareKeyboardController; + final _isImeConnected = ValueNotifier(false); + + @override + void initState() { + super.initState(); + + _softwareKeyboardController = SoftwareKeyboardController(); + _keyboardPanelController = KeyboardPanelController( + _softwareKeyboardController, + ); + + widget.messagePageController.addListener(_onMessagePageControllerChange); + + _isImeConnected.addListener(_onImeConnectionChange); + + SuperKeyboard.instance.mobileGeometry.addListener(_onKeyboardChange); + } + + @override + void didUpdateWidget(_ChatEditor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.messagePageController != oldWidget.messagePageController) { + oldWidget.messagePageController.removeListener(_onMessagePageControllerChange); + widget.messagePageController.addListener(_onMessagePageControllerChange); + } + } + + @override + void dispose() { + SuperKeyboard.instance.mobileGeometry.removeListener(_onKeyboardChange); + + widget.messagePageController.removeListener(_onMessagePageControllerChange); + + _keyboardPanelController.dispose(); + _isImeConnected.dispose(); + + super.dispose(); + } + + void _onKeyboardChange() { + // FIXME: I had to comment this out so that panels can open. Otherwise, if we leave + // this behavior in, and we try to open a panel, this check triggers and closes + // the IME (and therefore the panel) when the panel tries to open. + // // On Android, we've found that when swiping to go back, the keyboard often + // // closes without Flutter reporting the closure of the IME connection. + // // Therefore, the keyboard closes, but editors and text fields retain focus, + // // selection, and a supposedly open IME connection. + // // + // // Flutter issue: https://github.com/flutter/flutter/issues/165734 + // // + // // To hack around this bug in Flutter, when super_keyboard reports keyboard + // // closure, and this controller thinks the keyboard is open, we give up + // // focus so that our app state synchronizes with the closed IME connection. + // final keyboardState = SuperKeyboard.instance.mobileGeometry.value.keyboardState; + // if (_isImeConnected.value && (keyboardState == KeyboardState.closing || keyboardState == KeyboardState.closed)) { + // _editorFocusNode.unfocus(); + // } + } + + void _onImeConnectionChange() { + widget.messagePageController.collapsedMode = + _isImeConnected.value ? MessagePageSheetCollapsedMode.intrinsic : MessagePageSheetCollapsedMode.preview; + } + + void _onMessagePageControllerChange() { + if (widget.messagePageController.isPreview) { + // Always scroll the editor to the top when in preview mode. + widget.scrollController.position.jumpTo(0); + } + } + + @override + Widget build(BuildContext context) { + return KeyboardPanelScaffold( + controller: _keyboardPanelController, + isImeConnected: _isImeConnected, + contentBuilder: (BuildContext context, _Panel? openPanel) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + if (_editorFocusNode.hasFocus) { + return const SizedBox(); + } + + return child!; + }, + child: IconButton( + onPressed: () { + _editorFocusNode.requestFocus(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + // We wait for the end of the frame to show the panel because giving + // focus to the editor will first cause the keyboard to show. If we + // opened the panel immediately then it would be covered by the keyboard. + _keyboardPanelController.showKeyboardPanel(_Panel.thePanel); + }); + }, + icon: const Icon(Icons.add), + ), + ), + Expanded(child: _buildEditor()), + ListenableBuilder( + listenable: _editorFocusNode, + builder: (context, child) { + if (_editorFocusNode.hasFocus) { + return const SizedBox(); + } + + return child!; + }, + child: IconButton(onPressed: () {}, icon: const Icon(Icons.multitrack_audio)), + ), + ], + ); + }, + toolbarBuilder: (BuildContext context, _Panel? openPanel) { + return Container( + width: double.infinity, + height: 54, + color: Colors.white.withValues(alpha: 0.3), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + GestureDetector( + onTap: () { + if (!_keyboardPanelController.isKeyboardPanelOpen) { + _keyboardPanelController.showKeyboardPanel(_Panel.thePanel); + } else { + // This line is here to debug an issue in ClickUp + _keyboardPanelController.hideKeyboardPanel(); + _keyboardPanelController.showSoftwareKeyboard(); + } + }, + child: const Icon(Icons.add), + ), + const Spacer(), + GestureDetector( + onTap: () { + _softwareKeyboardController.close(); + }, + child: const Icon(Icons.keyboard_hide_outlined), + ), + ], + ), + ); + }, + keyboardPanelBuilder: (BuildContext context, _Panel? openPanel) { + if (openPanel == null) { + return const SizedBox(); + } + + return Container(width: double.infinity, height: 300, color: Colors.red); + }, + ); + } + + Widget _buildEditor() { + return _SuperEditorFocusOnTap( + editorFocusNode: _editorFocusNode, + editor: widget.editor, + child: SuperEditorDryLayout( + controller: widget.scrollController, + superEditor: SuperEditor( + key: _editorKey, + focusNode: _editorFocusNode, + editor: widget.editor, + inputRole: widget.inputRole, + softwareKeyboardController: _softwareKeyboardController, + isImeConnected: _isImeConnected, + imePolicies: const SuperEditorImePolicies(), + selectionPolicies: const SuperEditorSelectionPolicies(), + shrinkWrap: false, + stylesheet: _chatStylesheet, + componentBuilders: const [ + HintComponentBuilder("Send a message...", _hintTextStyleBuilder), + ...defaultComponentBuilders, + ], + plugins: { + _previewModePlugin, + }, + ), + ), + ); + } +} + +final _chatStylesheet = Stylesheet( + rules: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.symmetric(horizontal: 24), + Styles.textStyle: const TextStyle( + color: Colors.black, + fontSize: 18, + height: 1.4, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 38, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 26, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("header3"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF333333), + fontSize: 22, + fontWeight: FontWeight.bold, + ), + }; + }, + ), + StyleRule( + const BlockSelector("paragraph"), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(bottom: 12), + }; + }, + ), + StyleRule( + const BlockSelector("blockquote"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.grey, + fontSize: 20, + fontWeight: FontWeight.bold, + height: 1.4, + ), + }; + }, + ), + StyleRule( + BlockSelector.all.last(), + (doc, docNode) { + return { + Styles.padding: const CascadingPadding.only(bottom: 48), + }; + }, + ), + ], + inlineTextStyler: defaultInlineTextStyler, + inlineWidgetBuilders: defaultInlineWidgetBuilderChain, +); + +TextStyle _hintTextStyleBuilder(context) => const TextStyle( + color: Colors.grey, + ); + +enum _Panel { + thePanel; +} + +// FIXME: This widget is required because of the current shrink wrap behavior +// of Super Editor. If we set `shrinkWrap` to `false` then the bottom +// sheet always expands to max height. But if we set `shrinkWrap` to +// `true`, when we manually expand the bottom sheet, the only +// tappable area is wherever the document components actually appear. +// In the average case, that means only the top area of the bottom +// sheet can be tapped to place the caret. +// +// This widget should wrap Super Editor and make the whole area tappable. +/// A widget, that when pressed, gives focus to the [editorFocusNode], and places +/// the caret at the end of the content within an [editor]. +/// +/// It's expected that the [child] subtree contains the associated `SuperEditor`, +/// which owns the [editor] and [editorFocusNode]. +class _SuperEditorFocusOnTap extends StatelessWidget { + const _SuperEditorFocusOnTap({ + super.key, + required this.editorFocusNode, + required this.editor, + required this.child, + }); + + final FocusNode editorFocusNode; + + final Editor editor; + + /// The SuperEditor that we're wrapping with this tap behavior. + final Widget child; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: editorFocusNode, + builder: (context, child) { + return ListenableBuilder( + listenable: editor.composer.selectionNotifier, + builder: (context, child) { + final shouldControlTap = editor.composer.selection == null || !editorFocusNode.hasFocus; + return GestureDetector( + onTap: editor.composer.selection == null || !editorFocusNode.hasFocus ? _selectEditor : null, + behavior: HitTestBehavior.opaque, + child: IgnorePointer( + ignoring: shouldControlTap, + // ^ Prevent the Super Editor from aggressively responding to + // taps, so that we can respond. + child: child, + ), + ); + }, + child: child, + ); + }, + child: child, + ); + } + + void _selectEditor() { + editorFocusNode.requestFocus(); + + final endNode = editor.document.last; + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: endNode.id, + nodePosition: endNode.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ) + ]); + } +} diff --git a/super_editor/test/chat/message_page_scaffold_test.dart b/super_editor/test/chat/message_page_scaffold_test.dart new file mode 100644 index 0000000000..326ad2e48e --- /dev/null +++ b/super_editor/test/chat/message_page_scaffold_test.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../infrastructure/keyboard_panel_scaffold_test.dart'; + +void main() { + group('Message page scaffold >', () { + testWidgets('can add and remove ancestor inherited widgets', (tester) async { + double? mostRecentTextSize; + + final messagePageScaffold = MessagePageScaffold( + contentBuilder: (context, __) { + mostRecentTextSize = DefaultTextStyle.of(context).style.fontSize; + return const SizedBox(); + }, + bottomSheetBuilder: (context) { + return const SizedBox(); + }, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: messagePageScaffold, + ), + ), + ); + expect(mostRecentTextSize, 14); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: DefaultTextStyle( + style: const TextStyle( + fontSize: 28, + ), + child: messagePageScaffold, + ), + ), + ), + ); + expect(mostRecentTextSize, 28); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: messagePageScaffold, + ), + ), + ); + expect(mostRecentTextSize, 14); + }); + + testWidgets('re-runs child builder functions when inherited widget changes', (tester) async { + final textDirection = ValueNotifier(TextDirection.ltr); + TextDirection? mostRecentContentTextDirection; + TextDirection? mostRecentBottomSheetTextDirection; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _TextDirectionChanger( + textDirection: textDirection, + child: MessagePageScaffold( + contentBuilder: (context, __) { + mostRecentContentTextDirection = Directionality.of(context); + return const SizedBox(); + }, + bottomSheetBuilder: (context) { + mostRecentBottomSheetTextDirection = Directionality.of(context); + return const SizedBox(); + }, + ), + ), + ), + ), + ); + expect(mostRecentContentTextDirection, TextDirection.ltr); + expect(mostRecentBottomSheetTextDirection, TextDirection.ltr); + + textDirection.value = TextDirection.rtl; + await tester.pump(); + expect(mostRecentContentTextDirection, TextDirection.rtl); + expect(mostRecentBottomSheetTextDirection, TextDirection.rtl); + + textDirection.value = TextDirection.ltr; + await tester.pump(); + expect(mostRecentContentTextDirection, TextDirection.ltr); + expect(mostRecentBottomSheetTextDirection, TextDirection.ltr); + }); + + testWidgets('rebuilds stateful child widgets when inherited widget changes', (tester) async { + final textDirection = ValueNotifier(TextDirection.ltr); + TextDirection? mostRecentContentTextDirection; + TextDirection? mostRecentBottomSheetTextDirection; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _TextDirectionChanger( + textDirection: textDirection, + child: MessagePageScaffold( + contentBuilder: (context, __) { + return _StatefulWidgetThatUsesInheritedWidget( + onBuildWithTextDirection: (newDirection) => mostRecentContentTextDirection = newDirection, + child: const SizedBox(), + ); + }, + bottomSheetBuilder: (context) { + return _StatefulWidgetThatUsesInheritedWidget( + onBuildWithTextDirection: (newDirection) => mostRecentBottomSheetTextDirection = newDirection, + child: const SizedBox(), + ); + }, + ), + ), + ), + ), + ); + expect(mostRecentContentTextDirection, TextDirection.ltr); + expect(mostRecentBottomSheetTextDirection, TextDirection.ltr); + + textDirection.value = TextDirection.rtl; + await tester.pump(); + expect(mostRecentContentTextDirection, TextDirection.rtl); + expect(mostRecentBottomSheetTextDirection, TextDirection.rtl); + + textDirection.value = TextDirection.ltr; + await tester.pump(); + expect(mostRecentContentTextDirection, TextDirection.ltr); + expect(mostRecentBottomSheetTextDirection, TextDirection.ltr); + }); + + testWidgets('can navigate to and from', (tester) async { + final navigatorKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + routes: { + '/': (context) { + return const Scaffold( + body: Center( + child: Text('Home'), + ), + ); + }, + 'message-scaffold': (context) { + return Scaffold( + body: MessagePageScaffold( + contentBuilder: (_, __) => const SizedBox(), + bottomSheetBuilder: (_) => const SizedBox(), + ), + ); + }, + }, + ), + ); + expect(find.text('Home'), findsOne); + + navigatorKey.currentState!.pushNamed('message-scaffold'); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsNothing); + + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOne); + }); + + testWidgetsOnMobilePhone('works when bottom sheet animates from below the screen', (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder(builder: (context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("Launch Bottom Sheet"), + ElevatedButton( + onPressed: () { + showModalBottomSheet( + context: context, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + elevation: 24, + clipBehavior: Clip.antiAlias, + scrollControlDisabledMaxHeightRatio: 0.93, + builder: (context) { + return MessagePageScaffold( + contentBuilder: (context, bottomSheetHeight) { + return const Placeholder(); + }, + bottomSheetBuilder: (context) { + return KeyboardScaffoldSafeArea( + // ^ This widget is really what we're testing here. It used to + // throw a layout area when its content was below the bottom of + // the screen. + child: Container( + width: double.infinity, + height: 200, + color: Colors.red, + ), + ); + }, + ); + }, + ); + }, + child: const Text("Open Sheet"), + ), + ], + ), + ); + }), + ), + )); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + // Ensure the bottom sheet was actually launched. + expect(find.byType(MessagePageScaffold), findsOne); + + // If there's no error during opening animation, things should be fine. + // Before this test and related modifications, we were getting layout errors + // as the bottom sheet tried to animate up from below the bottom of the screen. + }); + }); +} + +class _TextDirectionChanger extends StatefulWidget { + const _TextDirectionChanger({ + required this.textDirection, + required this.child, + }); + + final ValueNotifier textDirection; + final Widget child; + + @override + State<_TextDirectionChanger> createState() => _TextDirectionChangerState(); +} + +class _TextDirectionChangerState extends State<_TextDirectionChanger> { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.textDirection, + builder: (context, value, child) { + return Directionality( + textDirection: widget.textDirection.value, + child: child!, + ); + }, + child: widget.child, + ); + } +} + +class _StatefulWidgetThatUsesInheritedWidget extends StatefulWidget { + const _StatefulWidgetThatUsesInheritedWidget({ + required this.onBuildWithTextDirection, + required this.child, + }); + + final void Function(TextDirection) onBuildWithTextDirection; + final Widget child; + + @override + State<_StatefulWidgetThatUsesInheritedWidget> createState() => _StatefulWidgetThatUsesInheritedWidgetState(); +} + +class _StatefulWidgetThatUsesInheritedWidgetState extends State<_StatefulWidgetThatUsesInheritedWidget> { + @override + Widget build(BuildContext context) { + widget.onBuildWithTextDirection(Directionality.of(context)); + return widget.child; + } +} diff --git a/super_editor/test/chat/super_message/super_message_gestures_test.dart b/super_editor/test/chat/super_message/super_message_gestures_test.dart new file mode 100644 index 0000000000..8c469cea75 --- /dev/null +++ b/super_editor/test/chat/super_message/super_message_gestures_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/links.dart'; +import 'package:super_editor/src/test/flutter_extensions/test_documents.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_tools.dart'; + +void main() { + group("Chat > SuperMessage > gestures >", () { + testWidgetsOnAllPlatforms("launches URL on tap", (tester) async { + // Setup test version of UrlLauncher to log URL launches. + final testUrlLauncher = TestUrlLauncher(); + UrlLauncher.instance = testUrlLauncher; + addTearDown(() => UrlLauncher.instance = null); + + // Pump the UI. + await _pumpScaffold(tester, document: singleParagraphWithLinkDoc()); + + // Tap on the link. + await tester.tapInParagraph("1", 27); + + // Ensure that we tried to launch the URL. + expect(testUrlLauncher.urlLaunchLog.length, 1); + expect(testUrlLauncher.urlLaunchLog.first.toString(), "https://fake.url"); + }); + + testWidgetsOnAllPlatforms("launches different URLs on tap", (tester) async { + // Setup test version of UrlLauncher to log URL launches. + final testUrlLauncher = TestUrlLauncher(); + UrlLauncher.instance = testUrlLauncher; + addTearDown(() => UrlLauncher.instance = null); + + // Pump the UI. + final document = deserializeMarkdownToDocument("[Google](https://google.com) and [Flutter](https://flutter.dev)"); + final paragraphId = document.first.id; + await _pumpScaffold(tester, document: document); + + // Tap on the first link. + await tester.tapInParagraph(paragraphId, 3); + + // Ensure that we tried to launch the first URL. + expect(testUrlLauncher.urlLaunchLog.length, 1); + expect(testUrlLauncher.urlLaunchLog.first.toString(), "https://google.com"); + + // Tap on the second link. + await tester.tapInParagraph(paragraphId, 14); + + // Ensure that we tried to launch the second URL. + expect(testUrlLauncher.urlLaunchLog.length, 2); + expect(testUrlLauncher.urlLaunchLog.last.toString(), "https://flutter.dev"); + }); + }); +} + +Future _pumpScaffold( + WidgetTester tester, { + MutableDocument? document, +}) async { + final editor = createDefaultAiMessageEditor( + document: document ?? MutableDocument.empty("1"), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: Column( + children: [ + SuperMessage(editor: editor), + ], + ), + ), + ), + ), + ); +} diff --git a/super_editor/test/chat/super_message/super_message_toolbar_test.dart b/super_editor/test/chat/super_message/super_message_toolbar_test.dart new file mode 100644 index 0000000000..8618613113 --- /dev/null +++ b/super_editor/test/chat/super_message/super_message_toolbar_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group("Chat > SuperMessage > toolbar >", () { + group("iOS >", () { + testWidgetsOnIos("dismisses after copy button is pressed", (tester) async { + await _pumpScaffold(tester); + + // Long press to select text and show the toolbar. + await tester.longPressInParagraph("1", 10); + expect(find.byType(DefaultIOSSuperMessageToolbar), findsOne); + + // Press "Copy" on the toolbar, which should dismiss it. + await tester.tap(find.text("Copy")); + await tester.pump(); + + // Ensure toolbar is dismissed. + expect(find.byType(DefaultIOSSuperMessageToolbar), findsNothing); + }); + + testWidgetsOnIos("does not dismiss after select-all button is pressed", (tester) async { + await _pumpScaffold(tester); + + // Long press to select text and show the toolbar. + await tester.longPressInParagraph("1", 10); + expect(find.byType(DefaultIOSSuperMessageToolbar), findsOne); + + // Press "Select All" on the toolbar, which should dismiss it. + await tester.tap(find.text("Select All")); + await tester.pump(); + + // Ensure toolbar is dismissed. + expect(find.byType(DefaultIOSSuperMessageToolbar), findsOne); + }); + }); + + group("Android >", () { + testWidgetsOnAndroid("dismisses after copy button is pressed", (tester) async { + await _pumpScaffold(tester); + + // Long press to select text and show the toolbar. + await tester.longPressInParagraph("1", 10); + expect(find.byType(DefaultAndroidSuperMessageToolbar), findsOne); + + // Press "Copy" on the toolbar, which should dismiss it. + await tester.tap(find.text("Copy")); + await tester.pump(); + + // Ensure toolbar is dismissed. + expect(find.byType(DefaultAndroidSuperMessageToolbar), findsNothing); + }); + + testWidgetsOnAndroid("does not dismiss after select-all button is pressed", (tester) async { + await _pumpScaffold(tester); + + // Long press to select text and show the toolbar. + await tester.longPressInParagraph("1", 10); + expect(find.byType(DefaultAndroidSuperMessageToolbar), findsOne); + + // Press "Select All" on the toolbar, which should dismiss it. + await tester.tap(find.text("Select All")); + await tester.pump(); + + // Ensure toolbar is dismissed. + expect(find.byType(DefaultAndroidSuperMessageToolbar), findsOne); + }); + }); + }); +} + +Future _pumpScaffold(WidgetTester tester) async { + final editor = createDefaultAiMessageEditor( + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("This is a message that is displayed in a SuperMessage"), + ), + ], + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + children: [ + SuperMessage(editor: editor), + ], + ), + ), + ), + ); +} diff --git a/super_editor/test/flutter_test_config.dart b/super_editor/test/flutter_test_config.dart index 2eab8f3204..261f710387 100644 --- a/super_editor/test/flutter_test_config.dart +++ b/super_editor/test/flutter_test_config.dart @@ -1,10 +1,18 @@ import 'dart:async'; +import 'package:super_editor/src/super_textfield/ios/ios_textfield.dart'; +import 'package:super_editor/src/test/test_globals.dart'; import 'package:super_text_layout/super_text_layout.dart'; Future testExecutable(FutureOr Function() testMain) async { // Disable indeterminate animations BlinkController.indeterminateAnimationsEnabled = false; + // Disable iOS selection heuristics, i.e, place the caret at the exact + // tapped position instead of placing it at word boundaries. + IOSTextFieldTouchInteractor.useIosSelectionHeuristics = false; + + Testing.isInTest = true; + return testMain(); } diff --git a/super_editor/test/infrastructure/content_layers_test.dart b/super_editor/test/infrastructure/content_layers_test.dart new file mode 100644 index 0000000000..d0eedd521c --- /dev/null +++ b/super_editor/test/infrastructure/content_layers_test.dart @@ -0,0 +1,1101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/infrastructure/render_sliver_ext.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group("Content layers", () { + testWidgets( + "build without any layers", + (tester) async { + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + content: (_) => SliverToBoxAdapter( + child: LayoutBuilder( + builder: (context, constraints) { + // The content should be able to take up whatever width it wants, within the available space. + // The height is infinite because `ContentLayers` is a sliver. + expect(constraints.isTight, isFalse); + expect(constraints.maxWidth, _windowSize.width); + expect(constraints.maxHeight, double.infinity); + + return SizedBox.fromSize(size: _windowSize); + }, + ), + ), + ) + : LayoutBuilder( + builder: (context, constraints) { + // The content should be able to take up whatever width and height it wants, within the + // available space. + expect(constraints.isTight, isFalse); + expect(constraints.maxWidth, _windowSize.width); + expect(constraints.maxHeight, _windowSize.height); + + return SizedBox.fromSize(size: _windowSize); + }, + ), + ); + + // Getting here without an error means the test passes. + }, + variant: _layoutVariant, + ); + + testWidgets( + "build with a single underlay and is same size as content", + (tester) async { + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + underlays: [ + _buildSizeValidatingLayer(), + ], + ) + : BoxContentLayers( + content: (_) => SizedBox.fromSize(size: _windowSize), + underlays: [ + _buildSizeValidatingLayer(), + ], + ), + ); + + // Getting here without an error means the test passes. + }, + variant: _layoutVariant, + ); + + testWidgets( + "build with a single overlay and is same size as content", + (tester) async { + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + overlays: [ + _buildSizeValidatingLayer(), + ], + ) + : BoxContentLayers( + content: (_) => SizedBox.fromSize(size: _windowSize), + overlays: [ + _buildSizeValidatingLayer(), + ], + ), + ); + + // Getting here without an error means the test passes. + }, + variant: _layoutVariant, + ); + + testWidgets( + "build with a single underlay and overlay and they are the same size as content", + (tester) async { + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + underlays: [ + _buildSizeValidatingLayer(), + ], + overlays: [ + _buildSizeValidatingLayer(), + ], + ) + : BoxContentLayers( + content: (_) => SizedBox.fromSize(size: _windowSize), + underlays: [ + _buildSizeValidatingLayer(), + ], + overlays: [ + _buildSizeValidatingLayer(), + ], + ), + ); + + // Getting here without an error means the test passes. + }, + variant: _layoutVariant, + ); + + testWidgets( + "build with multiple underlays and overlays and they are the same size as content", + (tester) async { + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + underlays: [ + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + ], + overlays: [ + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + ], + ) + : BoxContentLayers( + content: (_) => SizedBox.fromSize(size: _windowSize), + underlays: [ + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + ], + overlays: [ + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + _buildSizeValidatingLayer(), + ], + ), + ); + + // Getting here without an error means the test passes. + }, + variant: _layoutVariant, + ); + + testWidgets( + "rebuilds layers when they setState()", + (tester) async { + final contentRebuildSignal = ValueNotifier(0); + final contentBuildTracker = ValueNotifier(0); + + final underlayRebuildSignal = ValueNotifier(0); + final underlayBuildTracker = ValueNotifier(0); + + final overlayRebuildSignal = ValueNotifier(0); + final overlayBuildTracker = ValueNotifier(0); + + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + content: (onBuildScheduled) => _RebuildableWidget( + rebuildSignal: contentRebuildSignal, + buildTracker: contentBuildTracker, + onBuildScheduled: onBuildScheduled, + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + ), + underlays: [ + (context) => _RebuildableContentLayerWidget( + rebuildSignal: underlayRebuildSignal, + buildTracker: underlayBuildTracker, + child: const SizedBox(), + ), + ], + overlays: [ + (context) => _RebuildableContentLayerWidget( + rebuildSignal: overlayRebuildSignal, + buildTracker: overlayBuildTracker, + child: const SizedBox(), + ), + ], + ) + : BoxContentLayers( + content: (onBuildScheduled) => _RebuildableWidget( + rebuildSignal: contentRebuildSignal, + buildTracker: contentBuildTracker, + onBuildScheduled: onBuildScheduled, + child: SizedBox.fromSize(size: _windowSize), + ), + underlays: [ + (context) => _RebuildableContentLayerWidget( + rebuildSignal: underlayRebuildSignal, + buildTracker: underlayBuildTracker, + child: const SizedBox(), + ), + ], + overlays: [ + (context) => _RebuildableContentLayerWidget( + rebuildSignal: overlayRebuildSignal, + buildTracker: overlayBuildTracker, + child: const SizedBox(), + ), + ], + ), + ); + expect(contentBuildTracker.value, 1); + expect(underlayBuildTracker.value, 1); + expect(overlayBuildTracker.value, 1); + + // Tell the underlay widget to rebuild itself. + underlayRebuildSignal.value += 1; + await tester.pump(); + expect(underlayBuildTracker.value, 2); + expect(contentBuildTracker.value, 1); + + // Tell the overlay widget to rebuild itself. + overlayRebuildSignal.value += 1; + await tester.pump(); + expect(overlayBuildTracker.value, 2); + expect(contentBuildTracker.value, 1); + }, + variant: _layoutVariant, + ); + + testWidgets( + "lays out the content before building the layers during full tree build", + (tester) async { + final didContentLayout = ValueNotifier(false); + bool didUnderlayLayout = false; + + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + content: (_) => _SliverLayoutTrackingWidget( + onLayout: () { + didContentLayout.value = true; + }, + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + ), + underlays: [ + (context) { + expect(didContentLayout.value, isTrue); + didUnderlayLayout = true; + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + expect(didContentLayout.value, isTrue); + expect(didUnderlayLayout, isTrue); + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ) + : BoxContentLayers( + content: (_) => _BoxLayoutTrackingWidget( + onLayout: () { + didContentLayout.value = true; + }, + child: SizedBox.fromSize(size: _windowSize), + ), + underlays: [ + (context) { + expect(didContentLayout.value, isTrue); + didUnderlayLayout = true; + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + expect(didContentLayout.value, isTrue); + expect(didUnderlayLayout, isTrue); + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ), + ); + + // Getting here without an error means the test passes. + }, + variant: _layoutVariant, + ); + + testWidgets( + "lays out the content before building the layers when the content root rebuilds", + (tester) async { + final rebuildSignal = ValueNotifier(0); + final buildTracker = ValueNotifier(0); + final contentLayoutCount = ValueNotifier(0); + final layerLayoutCount = ValueNotifier(0); + + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + content: (onBuildScheduled) => _RebuildableWidget( + rebuildSignal: rebuildSignal, + buildTracker: buildTracker, + onBuildScheduled: onBuildScheduled, + child: _SliverLayoutTrackingWidget( + onLayout: () { + contentLayoutCount.value += 1; + }, + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + ), + ), + underlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + layerLayoutCount.value += 1; + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ) + : BoxContentLayers( + content: (onBuildScheduled) => _RebuildableWidget( + rebuildSignal: rebuildSignal, + buildTracker: buildTracker, + onBuildScheduled: onBuildScheduled, + child: _BoxLayoutTrackingWidget( + onLayout: () { + contentLayoutCount.value += 1; + }, + child: SizedBox.fromSize(size: _windowSize), + ), + ), + underlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + layerLayoutCount.value += 1; + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ), + ); + expect(buildTracker.value, 1); + + // Tell the content widget to rebuild itself. + rebuildSignal.value += 1; + await tester.pump(); + + // We expect build and layout to run twice. First, during the initial pump. Second, + // after we tell the content to rebuild. + expect(buildTracker.value, 2); + expect(contentLayoutCount.value, 2); + expect(layerLayoutCount.value, 2); + }, + variant: _layoutVariant, + ); + + testWidgets( + "lays out the content before building the layers when a content descendant rebuilds", + (tester) async { + final rebuildSignal = ValueNotifier(0); + final buildTracker = ValueNotifier(0); + final contentLayoutCount = ValueNotifier(0); + final layerLayoutCount = ValueNotifier(0); + + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + // Place a couple stateful widgets above the _RebuildableWidget to ensure that + // when a widget deeper in the tree rebuilds, we still rebuild ContentLayers. + content: (_) => _NoRebuildWidget( + child: _NoRebuildWidget( + child: _RebuildableWidget( + rebuildSignal: rebuildSignal, + buildTracker: buildTracker, + // We don't pass in the onBuildScheduled callback here because we're simulating + // an entire subtree that a client might provide as content. + child: _SliverLayoutTrackingWidget( + onLayout: () { + contentLayoutCount.value += 1; + }, + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + ), + ), + ), + ), + underlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + layerLayoutCount.value += 1; + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ) + : BoxContentLayers( + // Place a couple stateful widgets above the _RebuildableWidget to ensure that + // when a widget deeper in the tree rebuilds, we still rebuild ContentLayers. + content: (_) => _NoRebuildWidget( + child: _NoRebuildWidget( + child: _RebuildableWidget( + rebuildSignal: rebuildSignal, + buildTracker: buildTracker, + // We don't pass in the onBuildScheduled callback here because we're simulating + // an entire subtree that a client might provide as content. + child: _BoxLayoutTrackingWidget( + onLayout: () { + contentLayoutCount.value += 1; + }, + child: SizedBox.fromSize(size: _windowSize), + ), + ), + ), + ), + underlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + expect(contentLayoutCount.value, layerLayoutCount.value + 1); + layerLayoutCount.value += 1; + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ), + ); + expect(buildTracker.value, 1); + expect(contentLayoutCount.value, 1); + expect(layerLayoutCount.value, 1); + + // Tell the content widget to rebuild itself. + rebuildSignal.value += 1; + await tester.pump(); + + // We expect build and layout to run twice. First, during the initial pump. Second, + // after we tell the content to rebuild. + expect(buildTracker.value, 2); + expect(contentLayoutCount.value, 2); + expect(layerLayoutCount.value, 2); + }, + variant: _layoutVariant, + ); + + testWidgets( + "re-uses layer Elements instead of always re-inflating layer Widgets", + (tester) async { + final rebuildSignal = ValueNotifier(0); + final buildTracker = ValueNotifier(0); + final contentKey = GlobalKey(); + final contentLayoutCount = ValueNotifier(0); + final underlayElementTracker = ValueNotifier(null); + Element? underlayElement; + final overlayElementTracker = ValueNotifier(null); + Element? overlayElement; + + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + content: (_) => _RebuildableWidget( + key: contentKey, + rebuildSignal: rebuildSignal, + buildTracker: buildTracker, + // We don't pass in the onBuildScheduled callback here because we're simulating + // an entire subtree that a client might provide as content. + child: _SliverLayoutTrackingWidget( + onLayout: () { + contentLayoutCount.value += 1; + }, + child: SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + ), + ), + underlays: [ + (context) => _RebuildableContentLayerWidget( + elementTracker: underlayElementTracker, + onBuild: () { + // Ensure that this layer can access the render object of the content. + final contentSliver = contentKey.currentContext!.findRenderObject() as RenderSliver?; + expect(contentSliver, isNotNull); + expect(contentSliver!.geometry, isNotNull); + final viewport = context.findAncestorRenderObjectOfType(); + // Build happens during viewport layout, which is not finished at this point. So transform to viewport + // coordinate space is as far as we can go. + expect(contentSliver.localToGlobal(Offset.zero, ancestor: viewport), isNotNull); + }, + child: const SizedBox.expand(), + ), + ], + overlays: [ + (context) => _RebuildableContentLayerWidget( + elementTracker: overlayElementTracker, + onBuild: () { + // Ensure that this layer can access the render object of the content. + final contentSliver = contentKey.currentContext!.findRenderObject() as RenderSliver?; + expect(contentSliver, isNotNull); + expect(contentSliver!.geometry, isNotNull); + final viewport = context.findAncestorRenderObjectOfType(); + // Build happens during viewport layout, which is not finished at this point. So transform to viewport + // coordinate space is as far as we can go. + expect(contentSliver.localToGlobal(Offset.zero, ancestor: viewport), isNotNull); + }, + child: const SizedBox.expand(), + ), + ], + ) + : BoxContentLayers( + content: (_) => _RebuildableWidget( + rebuildSignal: rebuildSignal, + buildTracker: buildTracker, + // We don't pass in the onBuildScheduled callback here because we're simulating + // an entire subtree that a client might provide as content. + child: _BoxLayoutTrackingWidget( + onLayout: () { + contentLayoutCount.value += 1; + }, + child: const SizedBox.expand(), + ), + ), + underlays: [ + (context) => _RebuildableContentLayerWidget( + elementTracker: underlayElementTracker, + child: const SizedBox.expand(), + ), + ], + overlays: [ + (context) => _RebuildableContentLayerWidget( + elementTracker: overlayElementTracker, + child: const SizedBox.expand(), + ), + ], + ), + ); + expect(buildTracker.value, 1); + + underlayElement = underlayElementTracker.value; + expect(underlayElement, isNotNull); + + overlayElement = overlayElementTracker.value; + expect(overlayElement, isNotNull); + + // Tell the content widget to rebuild itself. + rebuildSignal.value += 1; + await tester.pump(); + + // We expect build and layout to run twice. First, during the initial pump. Second, + // after we tell the content to rebuild. + expect(buildTracker.value, 2); + expect(contentLayoutCount.value, 2); + expect(underlayElementTracker.value, underlayElement); + expect(overlayElementTracker.value, overlayElement); + }, + variant: _layoutVariant, + ); + + testWidgets( + "lets layers access inherited widgets", + (tester) async { + await _pumpScaffold( + tester, + _layoutVariant.currentValue!, + child: _layoutVariant.currentValue! == _LayoutMode.slivers + ? SliverContentLayers( + content: (_) => SliverToBoxAdapter( + child: SizedBox.fromSize(size: _windowSize), + ), + underlays: [ + (context) { + // Ensure that this layer can access ancestors. + final directionality = Directionality.of(context); + expect(directionality, isNotNull); + + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + // Ensure that this layer can access ancestors. + final directionality = Directionality.of(context); + expect(directionality, isNotNull); + + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ) + : BoxContentLayers( + content: (_) => SizedBox.fromSize(size: _windowSize), + underlays: [ + (context) { + // Ensure that this layer can access ancestors. + final directionality = Directionality.of(context); + expect(directionality, isNotNull); + + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + overlays: [ + (context) { + // Ensure that this layer can access ancestors. + final directionality = Directionality.of(context); + expect(directionality, isNotNull); + + return const ContentLayerProxyWidget( + child: SizedBox(), + ); + }, + ], + ), + ); + + // Getting here without an error means the test passes. + }, + variant: _layoutVariant, + ); + + testWidgets("SuperEditor ContentLayers full rebuild", (tester) async { + // This test recreates a nuanced timing scenario where rebuilding + // all of SuperEditor, while also rebuilding its selection layer, + // results in an attempt to access a dirty document layout during + // selection layer build. + + final rebuildSignal = ValueNotifier(0); + + final editorContext = tester // + .createDocument() + .withSingleEmptyParagraph() + .build(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _RebuildableContentLayerWidget( + rebuildSignal: rebuildSignal, + builder: (context) => SuperEditor( + editor: editorContext.context.editor, + inputSource: TextInputSource.keyboard, + ), + ), + ), + ), + ); + + await tester.placeCaretInParagraph("1", 0); + + // Insert a character so that the document content and the selection layer changes. + await tester.sendKeyEvent(LogicalKeyboardKey.keyH); + + // CRITICAL: We must request a full SuperEditor rebuild before starting to pump + // another frame. This timing is critical to causing the content layout bug. + // + // This SuperEditor rebuild simulates a situation where a change in the editor + // requests a rebuild of the content, rebuild of a layer, and also a rebuild + // of the full editor, all in the same frame. This has happened, for example, in + // the demo app where the user types text to create a tag. When the tag is + // recognized, it includes a new character, a new caret position, and a new tag + // display in the widget tree around the editor. + rebuildSignal.value += 1; + + // Pumping this frame would trigger the build order bug if it still existed. + await tester.pump(); + }); + }); +} + +Future _pumpScaffold( + WidgetTester tester, + _LayoutMode layoutMode, { + required Widget child, +}) async { + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + tester.view + ..physicalSize = _windowSize + ..devicePixelRatio = 1.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: layoutMode == _LayoutMode.slivers + ? CustomScrollView( + slivers: [ + child, + ], + ) + : child, + ), + ), + ); +} + +final _layoutVariant = ValueVariant(_LayoutMode.values.toSet()); + +enum _LayoutMode { + boxes, + slivers; +} + +// We control the window size in these tests so that we can easily compare and validate +// the layout sizes for underlays and overlays. +const _windowSize = Size(600, 1000); + +/// Returns a [LayoutBuilder] that expects its constraints to be the same as the window, +/// used for quickly verifying the constraints given to underlays and overlays in +/// ContentLayers widgets in this test suite. +ContentLayerWidgetBuilder _buildSizeValidatingLayer() { + return (context) => const _SizeValidatingLayer(); +} + +class _SizeValidatingLayer extends ContentLayerStatefulWidget { + const _SizeValidatingLayer(); + + @override + ContentLayerState createState() => _SizeValidatingLayerState(); +} + +class _SizeValidatingLayerState extends ContentLayerState<_SizeValidatingLayer, Object> { + @override + Object? computeLayoutData(Element? contentElement, RenderObject? contentLayout) => null; + + @override + Widget doBuild(BuildContext context, Object? layoutData) { + return LayoutBuilder( + builder: (context, constraints) { + _expectLayerConstraintsThatMatchContent(constraints); + return const SizedBox(); + }, + ); + } +} + +void _expectLayerConstraintsThatMatchContent(BoxConstraints constraints) { + expect(constraints.isTight, isTrue); + expect(constraints.maxWidth, _windowSize.width); + expect(constraints.maxHeight, _windowSize.height); +} + +/// A [StatefulWidget] that never rebuilds. +/// +/// Used to inject an `Element` above another widget to test what happens when a descendant +/// rebuilds, and that descendant isn't the top-level widget in a subtree. +class _NoRebuildWidget extends StatefulWidget { + const _NoRebuildWidget({ + Key? key, + required this.child, + }) : super(key: key); + + final Widget child; + + @override + State<_NoRebuildWidget> createState() => _NoRebuildWidgetState(); +} + +class _NoRebuildWidgetState extends State<_NoRebuildWidget> { + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// Widget that can be told to rebuild from the outside, and also tracks its build count. +class _RebuildableWidget extends StatefulWidget { + const _RebuildableWidget({ + Key? key, + this.rebuildSignal, + this.buildTracker, + this.elementTracker, + this.onBuildScheduled, + this.onBuild, + this.builder, + this.child, + }) : assert(child != null || builder != null, "Must provide either a child OR a builder."), + assert(child == null || builder == null, "Can't provide a child AND a builder. Choose one."), + super(key: key); + + /// Signal that instructs this widget to call `setState()`. + final Listenable? rebuildSignal; + + /// The number of times this widget has run `build()`. + final ValueNotifier? buildTracker; + + /// The [Element] that currently owns this `Widget` and its `State`. + final ValueNotifier? elementTracker; + + /// Callback that's invoked when this widget calls `setState()`. + final VoidCallback? onBuildScheduled; + + /// Callback that's invoked during this widget's `build()` method. + final VoidCallback? onBuild; + + final WidgetBuilder? builder; + final Widget? child; + + @override + State<_RebuildableWidget> createState() => _RebuildableWidgetState(); +} + +class _RebuildableWidgetState extends State<_RebuildableWidget> { + @override + void initState() { + super.initState(); + widget.rebuildSignal?.addListener(_onRebuildSignal); + } + + @override + void didUpdateWidget(_RebuildableWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.rebuildSignal != oldWidget.rebuildSignal) { + oldWidget.rebuildSignal?.removeListener(_onRebuildSignal); + widget.rebuildSignal?.addListener(_onRebuildSignal); + } + } + + @override + void dispose() { + widget.rebuildSignal?.removeListener(_onRebuildSignal); + super.dispose(); + } + + void _onRebuildSignal() { + setState(() { + // rebuild + }); + + // Explicitly mark our RenderObject as needing layout so that we simulate content + // that rebuilds because its layout changed. Without this call, we'd get a widget + // rebuild, but we wouldn't trigger another content layout pass. We want that + // layout pass so that our tests can inspect the order of operations and ensure that + // when the content layout changes, the content is always laid out before layers. + context.findRenderObject()?.markNeedsLayout(); + } + + // This override is a regrettable requirement for ContentLayers, which is needed so + // that ContentLayers can remove the layers to prevent them from building during a + // regular build phase when the content changes. This is the result of Flutter making + // it impossible to monitor dirty subtrees, and making it impossible to control build + // order. + @override + void setState(VoidCallback fn) { + super.setState(fn); + widget.onBuildScheduled?.call(); + } + + @override + Widget build(BuildContext context) { + widget.buildTracker?.value += 1; + widget.elementTracker?.value = context as Element; + + widget.onBuild?.call(); + + return widget.child != null ? widget.child! : widget.builder!.call(context); + } +} + +/// Content layer that can be told to rebuild from the outside, and also tracks its build count. +class _RebuildableContentLayerWidget extends ContentLayerStatefulWidget { + const _RebuildableContentLayerWidget({ + Key? key, + this.rebuildSignal, + this.buildTracker, + this.elementTracker, + this.onBuildScheduled, + this.onBuild, + this.builder, + this.child, + }) : assert(child != null || builder != null, "Must provide either a child OR a builder."), + assert(child == null || builder == null, "Can't provide a child AND a builder. Choose one."), + super(key: key); + + /// Signal that instructs this widget to call `setState()`. + final Listenable? rebuildSignal; + + /// The number of times this widget has run `build()`. + final ValueNotifier? buildTracker; + + /// The [Element] that currently owns this `Widget` and its `State`. + final ValueNotifier? elementTracker; + + /// Callback that's invoked when this widget calls `setState()`. + final VoidCallback? onBuildScheduled; + + /// Callback that's invoked during this widget's `build()` method. + final VoidCallback? onBuild; + + final WidgetBuilder? builder; + final Widget? child; + + @override + ContentLayerState createState() => _RebuildableContentLayerWidgetState(); +} + +class _RebuildableContentLayerWidgetState extends ContentLayerState<_RebuildableContentLayerWidget, Object> { + @override + void initState() { + super.initState(); + widget.rebuildSignal?.addListener(_onRebuildSignal); + } + + @override + void didUpdateWidget(_RebuildableContentLayerWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.rebuildSignal != oldWidget.rebuildSignal) { + oldWidget.rebuildSignal?.removeListener(_onRebuildSignal); + widget.rebuildSignal?.addListener(_onRebuildSignal); + } + } + + @override + void dispose() { + widget.rebuildSignal?.removeListener(_onRebuildSignal); + super.dispose(); + } + + void _onRebuildSignal() { + setState(() { + // rebuild + }); + + // Explicitly mark our RenderObject as needing layout so that we simulate content + // that rebuilds because its layout changed. Without this call, we'd get a widget + // rebuild, but we wouldn't trigger another content layout pass. We want that + // layout pass so that our tests can inspect the order of operations and ensure that + // when the content layout changes, the content is always laid out before layers. + context.findRenderObject()?.markNeedsLayout(); + } + + // This override is a regrettable requirement for ContentLayers, which is needed so + // that ContentLayers can remove the layers to prevent them from building during a + // regular build phase when the content changes. This is the result of Flutter making + // it impossible to monitor dirty subtrees, and making it impossible to control build + // order. + @override + void setState(VoidCallback fn) { + super.setState(fn); + widget.onBuildScheduled?.call(); + } + + @override + Object? computeLayoutData(Element? contentElement, RenderObject? contentLayout) => null; + + @override + Widget doBuild(BuildContext context, Object? object) { + widget.buildTracker?.value += 1; + widget.elementTracker?.value = context as Element; + + widget.onBuild?.call(); + + return widget.child != null ? widget.child! : widget.builder!.call(context); + } +} + +/// Widget that reports every time it runs layout as a Render Box. +class _BoxLayoutTrackingWidget extends SingleChildRenderObjectWidget { + const _BoxLayoutTrackingWidget({ + required this.onLayout, + required Widget child, + }) : super(child: child); + + final VoidCallback onLayout; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderBoxLayoutTrackingWidget(onLayout); + } +} + +class _RenderBoxLayoutTrackingWidget extends RenderProxyBox { + _RenderBoxLayoutTrackingWidget(this._onLayout); + + final VoidCallback _onLayout; + + @override + void performLayout() { + _onLayout(); + super.performLayout(); + } +} + +/// Widget that reports every time it runs layout as a Sliver. +class _SliverLayoutTrackingWidget extends SingleChildRenderObjectWidget { + const _SliverLayoutTrackingWidget({ + required this.onLayout, + required Widget child, + }) : super(child: child); + + final VoidCallback onLayout; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSliverLayoutTrackingWidget(onLayout); + } +} + +class _RenderSliverLayoutTrackingWidget extends RenderProxySliver { + _RenderSliverLayoutTrackingWidget(this._onLayout); + + final VoidCallback _onLayout; + + @override + void performLayout() { + _onLayout(); + super.performLayout(); + } +} diff --git a/super_editor/test/infrastructure/inline_span_test.dart b/super_editor/test/infrastructure/inline_span_test.dart new file mode 100644 index 0000000000..d0adcf22e4 --- /dev/null +++ b/super_editor/test/infrastructure/inline_span_test.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group('SuperEditor > computeInlineSpan >', () { + testWidgets('computes inlineSpan for text with attributions and a placeholder at the beginning', (tester) async { + // Pump a widget because we need a BuildContext to compute the InlineSpan. + await tester.pumpWidget( + const MaterialApp(), + ); + + // Create an AttributedText with the words "Welcome" and "SuperEditor" in bold and with a leading placeholder. + final text = AttributedText( + 'Welcome to SuperEditor', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 1, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.end), + const SpanMarker(attribution: boldAttribution, offset: 12, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 22, markerType: SpanMarkerType.end), + ], + ), + {0: const _ExamplePlaceholder()}, + ); + + final inlineSpan = text.computeInlineSpan( + find.byType(MaterialApp).evaluate().first as BuildContext, + defaultStyleBuilder, + [_inlineWidgetBuilder], + ); + + final spanList = _flattenInlineSpan(inlineSpan); + expect(spanList.length, equals(5)); + + // Ensure that the first span is an empty TextSpan with the default fontWeight. + expect(spanList[0], isA()); + expect((spanList[0] as TextSpan).text, equals('')); + expect((spanList[0] as TextSpan).style!.fontWeight, isNull); + + // Expect that the second span is the widget rendered using the placeholder. + expect(spanList[1], isA()); + + // Ensure that the third span is a TextSpan with the text "Welcome" in bold. + expect(spanList[2], isA()); + expect((spanList[2] as TextSpan).text, equals('Welcome')); + expect((spanList[2] as TextSpan).style!.fontWeight, equals(FontWeight.bold)); + + // Ensure that the fourth span is a TextSpan with the text " to " with the default fontWeight. + expect(spanList[3], isA()); + expect((spanList[3] as TextSpan).text, equals(' to ')); + expect((spanList[3] as TextSpan).style!.fontWeight, isNull); + + // Ensure that the fifth span is a TextSpan with the text "SuperEditor" in bold. + expect(spanList[4], isA()); + expect((spanList[4] as TextSpan).text, equals('SuperEditor')); + expect((spanList[4] as TextSpan).style!.fontWeight, equals(FontWeight.bold)); + }); + + testWidgets('computes inlineSpan for text with attributions and a placeholder at the middle', (tester) async { + // Pump a widget because we need a BuildContext to compute the InlineSpan. + await tester.pumpWidget( + const MaterialApp(), + ); + + // Create an AttributedText with the words "Welcome" and "SuperEditor" in bold and with a + // placeholder after the word "to". + final text = AttributedText( + 'Welcome to SuperEditor', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 6, markerType: SpanMarkerType.end), + const SpanMarker(attribution: boldAttribution, offset: 12, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 22, markerType: SpanMarkerType.end), + ], + ), + {10: const _ExamplePlaceholder()}, + ); + + final inlineSpan = text.computeInlineSpan( + find.byType(MaterialApp).evaluate().first as BuildContext, + defaultStyleBuilder, + [_inlineWidgetBuilder], + ); + + final spanList = _flattenInlineSpan(inlineSpan); + expect(spanList.length, equals(6)); + + // Ensure that the first span is an empty TextSpan with the default fontWeight. + expect(spanList[0], isA()); + expect((spanList[0] as TextSpan).text, equals('')); + expect((spanList[0] as TextSpan).style!.fontWeight, isNull); + + // Expect that the second span is a TextSpan with the text "Welcome" in bold. + expect(spanList[1], isA()); + expect((spanList[1] as TextSpan).text, equals('Welcome')); + expect((spanList[1] as TextSpan).style!.fontWeight, equals(FontWeight.bold)); + + // Ensure that the third span is a TextSpan with the text " to" with the default fontWeight. + expect(spanList[2], isA()); + expect((spanList[2] as TextSpan).text, equals(' to')); + expect((spanList[2] as TextSpan).style!.fontWeight, isNull); + + // Expect that the fourth span is the widget rendered using the placeholder. + expect(spanList[3], isA()); + + // Ensure that the fifth span is a TextSpan with the text " " with the default fontWeight. + expect(spanList[4], isA()); + expect((spanList[4] as TextSpan).text, equals(' ')); + expect((spanList[4] as TextSpan).style!.fontWeight, isNull); + + // Ensure that the sixth span is a TextSpan with the text "SuperEditor" in bold. + expect(spanList[5], isA()); + expect((spanList[5] as TextSpan).text, equals('SuperEditor')); + expect((spanList[5] as TextSpan).style!.fontWeight, equals(FontWeight.bold)); + }); + + testWidgets('computes inlineSpan for text with attributions and a placeholder at the end', (tester) async { + // Pump a widget because we need a BuildContext to compute the InlineSpan. + await tester.pumpWidget( + const MaterialApp(), + ); + + // Create an AttributedText with the words "Welcome" and "SuperEditor" in bold and a trailing placeholder. + final text = AttributedText( + 'Welcome to SuperEditor', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 6, markerType: SpanMarkerType.end), + const SpanMarker(attribution: boldAttribution, offset: 11, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 21, markerType: SpanMarkerType.end), + ], + ), + {22: const _ExamplePlaceholder()}, + ); + + final inlineSpan = text.computeInlineSpan( + find.byType(MaterialApp).evaluate().first as BuildContext, + defaultStyleBuilder, + [_inlineWidgetBuilder], + ); + + final spanList = _flattenInlineSpan(inlineSpan); + expect(spanList.length, equals(5)); + + // Ensure that the first span is an empty TextSpan with the default fontWeight. + expect(spanList[0], isA()); + expect((spanList[0] as TextSpan).text, equals('')); + expect((spanList[0] as TextSpan).style!.fontWeight, isNull); + + // Ensure that the second span is a TextSpan with the text "Welcome" in bold. + expect(spanList[1], isA()); + expect((spanList[1] as TextSpan).text, equals('Welcome')); + expect((spanList[1] as TextSpan).style!.fontWeight, equals(FontWeight.bold)); + + // Ensure that the third span is a TextSpan with the text " to " with the default fontWeight. + expect(spanList[2], isA()); + expect((spanList[2] as TextSpan).text, equals(' to ')); + expect((spanList[2] as TextSpan).style!.fontWeight, isNull); + + // Ensure that the fourth span is a TextSpan with the text "SuperEditor" in bold. + expect(spanList[3], isA()); + expect((spanList[3] as TextSpan).text, equals('SuperEditor')); + expect((spanList[3] as TextSpan).style!.fontWeight, equals(FontWeight.bold)); + + // Expect that the fifth span is the widget rendered using the placeholder. + expect(spanList[4], isA()); + }); + }); +} + +List _flattenInlineSpan(InlineSpan inlineSpan) { + final flatList = []; + + inlineSpan.visitChildren((child) { + flatList.add(child); + return true; + }); + + return flatList; +} + +class _ExamplePlaceholder { + const _ExamplePlaceholder(); +} + +Widget? _inlineWidgetBuilder(BuildContext context, TextStyle textStyle, Object placeholder) { + return const SizedBox(width: 10); +} diff --git a/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart new file mode 100644 index 0000000000..8fa8624f29 --- /dev/null +++ b/super_editor/test/infrastructure/keyboard_panel_scaffold_test.dart @@ -0,0 +1,1225 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('Keyboard panel scaffold >', () { + group('phones >', () { + testWidgetsOnMobilePhone('does not show toolbar upon initialization when IME is disconnected', (tester) async { + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes(tester); + + // Ensure the toolbar isn't visible. + expect(find.byKey(_aboveKeyboardToolbarKey), findsNothing); + }); + + testWidgetsOnMobilePhone('shows toolbar at the bottom when there is no keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard toolbar. + controller.showToolbar(); + await tester.pump(); + + // Ensure the above-keyboard toolbar sits at the bottom of the screen. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height), + ); + }); + + testWidgetsOnMobilePhone('shows keyboard toolbar above the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the above-keyboard panel sits above the software keyboard. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight), + ); + }); + + testWidgetsOnMobilePhone('shows content above the toolbar and keyboard when at bottom of screen', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the editor sits just above the keyboard + toolbar. + expect( + tester.getBottomLeft(find.byType(SuperEditor)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight - _toolbarHeight), + ); + }); + + testWidgetsOnMobilePhone('shows content above the toolbar and keyboard when above bottom of screen', + (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + // Push the editor up a bit. + widgetBelowEditor: Container( + width: double.infinity, + height: 100, + color: Colors.red, + ), + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the editor sits just above the keyboard + toolbar, and there's + // no extra space caused by the widget below the editor. + expect( + tester.getBottomLeft(find.byType(SuperEditor)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight - _toolbarHeight), + ); + }); + + testWidgetsOnMobilePhone( + 'shows keyboard toolbar above the keyboard when toggling panels and showing the keyboard', + (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Hide both the keyboard panel and the software keyboard. + controller.closeKeyboardAndPanel(); + await tester.pumpAndSettle(); + + // Place the caret at the beginning of the document to show the software keyboard again. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the top panel sits above the keyboard. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight), + ); + }, + ); + + testWidgetsOnMobilePhone('does not show keyboard panel upon keyboard appearance', (tester) async { + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes(tester); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + }); + + testWidgetsOnMobilePhone('shows keyboard panel upon request', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + }); + + testWidgetsOnMobilePhone('displays panel with the same height as the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel has the same size as the software keyboard. + expect( + tester.getSize(find.byKey(_keyboardPanelKey)).height, + equals(_expandedPhoneKeyboardHeight), + ); + + // Ensure the above-keyboard panel sits immediately above the keyboard panel. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight), + ); + }); + + testWidgetsOnMobilePhone('hides the panel when showing the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the toolbar. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + // Hide the keyboard panel and show the software keyboard. + controller.showSoftwareKeyboard(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + + // Ensure the toolbar sits immediately above the keyboard. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height - _expandedPhoneKeyboardHeight), + ); + }); + + testWidgetsOnMobilePhone('hides the panel upon request', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard panel. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + controller.closeKeyboardAndPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + + // Ensure the above-keyboard panel sits at the bottom of the screen. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + equals(tester.getSize(find.byType(MaterialApp)).height), + ); + }); + + testWidgetsOnMobilePhone('hides the panel when IME connection closes', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the keyboard toolbar. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to open the IME connection. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + // Close the IME connection. + softwareKeyboardController.close(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + }); + + testWidgetsOnMobilePhone('shows toolbar at the bottom after closing the panel and the keyboard', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + ); + + // Request to show the above-keyboard toolbar. + controller.showToolbar(); + await tester.pump(); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + // Hide the keyboard panel and the software keyboard. + controller.closeKeyboardAndPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is not visible. + expect(find.byKey(_keyboardPanelKey), findsNothing); + + // Ensure the above-keyboard toolbar sits at the bottom of the screen. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + tester.getSize(find.byType(MaterialApp)).height, + ); + }); + }); + + group('iPad >', () { + testWidgetsOnIPad('shows panel when keyboard is docked', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _expandedIPadKeyboardHeight, + ); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _expandedIPadKeyboardHeight); + }); + + testWidgetsOnIPad('shows and closes panel when keyboard is floating or minimized', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _minimizedIPadKeyboardHeight, + ); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the toolbar is above the minimized keyboard area. + final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _minimizedIPadKeyboardHeight, + ); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible and positioned at the bottom of the screen. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _keyboardPanelHeight); + + expect( + tester.getBottomLeft(find.byKey(_keyboardPanelKey)).dy, + screenHeight, + ); + + // Ensure the toolbar is above the panel. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _keyboardPanelHeight, + ); + + // Request to hide the keyboard panel. + controller.hideKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is gone. + expect(find.byKey(_keyboardPanelKey), findsNothing); + + // Ensure the toolbar is above the minimized keyboard area. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _minimizedIPadKeyboardHeight, + ); + }); + }); + + group('Android tablets >', () { + testWidgetsOnAndroidTablet('shows panel when keyboard is docked', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _expandedAndroidTabletKeyboardHeight, + ); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _expandedAndroidTabletKeyboardHeight); + }); + + testWidgetsOnAndroidTablet('shows panel when keyboard is floating or minimized', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _minimizedAndroidTabletKeyboardHeight, + ); + + // Place the caret at the beginning of the document to show the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the toolbar is above the minimized keyboard area. + final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _minimizedAndroidTabletKeyboardHeight, + ); + + // Request to show the keyboard panel and let the entrance animation run. + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is visible and positioned at the bottom of the screen. + expect(find.byKey(_keyboardPanelKey), findsOneWidget); + + final panelSize = tester.getSize(find.byKey(_keyboardPanelKey)); + expect(panelSize.height, _keyboardPanelHeight); + + expect( + tester.getBottomLeft(find.byKey(_keyboardPanelKey)).dy, + screenHeight, + ); + + // Ensure the toolbar is above the panel. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _keyboardPanelHeight, + ); + + // Request to hide the keyboard panel. + controller.hideKeyboardPanel(); + await tester.pumpAndSettle(); + + // Ensure the keyboard panel is gone. + expect(find.byKey(_keyboardPanelKey), findsNothing); + + // Ensure the toolbar is above the minimized keyboard area. + expect( + tester.getBottomLeft(find.byKey(_aboveKeyboardToolbarKey)).dy, + screenHeight - _minimizedAndroidTabletKeyboardHeight, + ); + }); + }); + + group('safe area >', () { + testWidgetsOnMobilePhone('makes room for keyboard panel (with single scope)', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final keyboardPanelController = KeyboardPanelController(softwareKeyboardController); + final imeConnectionNotifier = ValueNotifier(false); + + await _pumpTestAppWithSingleSafeAreaScope( + tester, + softwareKeyboardController: softwareKeyboardController, + keyboardPanelController: keyboardPanelController, + isImeConnected: imeConnectionNotifier, + ); + + // Record the height of the content when no keyboard or panel is open. + final contentHeightWithNoKeyboard = tester.getSize(find.byKey(_chatPageKey)).height; + + // Show the keyboard. + await tester.placeCaretInParagraph("1", 0); + await tester.pumpAndSettle(); + + // Record the height of the content now that the keyboard is open. + final contentHeightWithKeyboardOpen = tester.getSize(find.byKey(_chatPageKey)).height; + + // Ensure that the content is pushed up above the keyboard + toolbar. + expect( + contentHeightWithNoKeyboard - contentHeightWithKeyboardOpen, + _toolbarHeight + _expandedPhoneKeyboardHeight, + ); + }); + + testWidgetsOnMobilePhone('makes room for keyboard panel (with multiple scopes)', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _expandedPhoneKeyboardHeight, + ); + + // Record the height of the content when no keyboard or panel is open. + final contentHeightWithNoKeyboard = tester.getSize(find.byKey(_chatPageKey)).height; + + // Show a keyboard panel (not the keyboard). + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Record the height of the content now that a keyboard panel is open. + final contentHeightWithKeyboardPanelOpen = tester.getSize(find.byKey(_chatPageKey)).height; + + // Ensure that the content is pushed up above the keyboard panel. + expect(contentHeightWithNoKeyboard - contentHeightWithKeyboardPanelOpen, _toolbarHeight + _keyboardPanelHeight); + }); + + testWidgetsOnMobilePhone('removes bottom insets when focus leaves editor', (tester) async { + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + tester, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _expandedPhoneKeyboardHeight, + ); + + // Record the height of the content when no keyboard or panel is open. + final contentHeightWithNoKeyboard = tester.getSize(find.byKey(_chatPageKey)).height; + + // Show a keyboard panel (not the keyboard). + controller.showKeyboardPanel(_Panel.panel1); + await tester.pumpAndSettle(); + + // Record the height of the content now that a keyboard panel is open. + final contentHeightWithKeyboardPanelOpen = tester.getSize(find.byKey(_chatPageKey)).height; + + // Ensure that the content is pushed up above the keyboard panel. + expect(contentHeightWithNoKeyboard - contentHeightWithKeyboardPanelOpen, _toolbarHeight + _keyboardPanelHeight); + + // Switch to other tab. + await tester.tap(find.byKey(_accountTabKey)); + await tester.pumpAndSettle(); + + // Ensure the chat page is gone. + expect(find.byKey(_chatPageKey), findsNothing); + + // Ensure that the account tab's content is full height (isn't restricted by safe area). + expect(tester.getSize(find.byKey(_accountPageKey)).height, contentHeightWithNoKeyboard); + }); + + testWidgetsOnMobilePhone('does not retain bottom insets when closing keyboard during navigation', (tester) async { + final navigatorKey = GlobalKey(); + final softwareKeyboardController = SoftwareKeyboardController(); + final controller = KeyboardPanelController(softwareKeyboardController); + + await _pumpTestAppWithNavigationScreens( + tester, + navigatorKey: navigatorKey, + controller: controller, + softwareKeyboardController: softwareKeyboardController, + simulatedKeyboardHeight: _expandedPhoneKeyboardHeight, + ); + + // Record the height of the content when no keyboard is open. + final contentHeightWithNoKeyboard = tester.getSize(find.byKey(_chatPageKey)).height; + + // Show the keyboard. Don't show the toolbar because it's irrelevant for this test. + controller.toolbarVisibility = KeyboardToolbarVisibility.hidden; + await tester.placeCaretInParagraph("1", 0); + await tester.pumpAndSettle(); + + // Record the height of the content now that the keyboard is open. + final contentHeightWithKeyboardPanelOpen = tester.getSize(find.byKey(_chatPageKey)).height; + + // Ensure that the content is pushed up above the keyboard. + expect(contentHeightWithNoKeyboard - contentHeightWithKeyboardPanelOpen, _expandedPhoneKeyboardHeight); + + // Navigate to screen 2, while simultaneously closing the keyboard (which is what + // happens when navigating to a new screen without an IME connection). + navigatorKey.currentState!.pushNamed("/second"); + + // CRITICAL: The reason navigation is a problem is because the first pump of a new + // screen happens before the keyboard starts to close (in a real app). Therefore, we + // pump one frame here to create the new screen and THEN we close the keyboard. + await tester.pump(); + + // Close the keyboard now that the new screen is starting to navigate in. + softwareKeyboardController.close(); + + // Pump and settle to let the navigation animation play. + await tester.pumpAndSettle(); + + // Ensure that the second page body takes up all available space. + expect(tester.getSize(find.byKey(_screen2BodyKey)).height, contentHeightWithNoKeyboard); + }); + }); + }); +} + +/// Pumps a tree that displays a two tab UI, one tab has an editor that shows +/// a keyboard panel, and the other tab has no editor at all, which can be +/// used to verify what happens when navigating from an open editor to another +/// tab. +/// +/// The pumped widget tree includes multiple keyboard safe area scopes, which helps +/// to stress test their communication with each other in the widget tree. +/// +/// Simulates the software keyboard appearance and disappearance by animating +/// the `MediaQuery` view insets when the app communicates with the IME to show/hide +/// the software keyboard. +Future _pumpTestAppWithTabsAndMultipleSafeAreaScopes( + WidgetTester tester, { + KeyboardPanelController? controller, + SoftwareKeyboardController? softwareKeyboardController, + ValueNotifier? isImeConnected, + double simulatedKeyboardHeight = _expandedPhoneKeyboardHeight, + // (Optional) widget that's positioned below the chat editor, which pushes + // the chat editor up from the bottom of the screen. + Widget? widgetBelowEditor, +}) async { + final keyboardController = softwareKeyboardController ?? SoftwareKeyboardController(); + final keyboardPanelController = controller ?? KeyboardPanelController(keyboardController); + final imeConnectionNotifier = isImeConnected ?? ValueNotifier(false); + + await tester // + .createDocument() + .withLongDoc() + .withSoftwareKeyboardController(keyboardController) + .withImeConnectionNotifier(imeConnectionNotifier) + .simulateSoftwareKeyboardInsets( + true, + simulatedKeyboardHeight: simulatedKeyboardHeight, + ) + .withCustomWidgetTreeBuilder( + (superEditor) => _TestAppWithTabsAndMultipleSafeAreaScopes( + superEditor: superEditor, + keyboardPanelController: keyboardPanelController, + imeConnectionNotifier: imeConnectionNotifier, + widgetBelowEditor: widgetBelowEditor, + ), + ) + .pump(); +} + +/// An app scaffold with the following structure: +/// +/// MaterialApp +/// |-- Column +/// |-- App bar with tabs +/// |-- Page (chat page or profile page) +class _TestAppWithTabsAndMultipleSafeAreaScopes extends StatelessWidget { + const _TestAppWithTabsAndMultipleSafeAreaScopes({ + required this.superEditor, + required this.keyboardPanelController, + required this.imeConnectionNotifier, + this.widgetBelowEditor, + }); + + final Widget superEditor; + + final KeyboardPanelController keyboardPanelController; + final ValueNotifier imeConnectionNotifier; + final Widget? widgetBelowEditor; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + bottom: const TabBar( + tabs: [ + Tab( + key: _chatTabKey, + icon: Icon(Icons.chat), + ), + Tab( + key: _accountTabKey, + icon: Icon(Icons.account_circle), + ), + ], + ), + ), + resizeToAvoidBottomInset: false, + body: TabBarView(children: [ + // ^ We build a tab view so that we can test what happens when the editor + // has focus and a keyboard panel is up, and then the user navigates to + // another tab, which should remove the bottom safe area when it happens. + _ChatPage( + keyboardPanelController: keyboardPanelController, + imeConnectionNotifier: imeConnectionNotifier, + superEditor: superEditor, + widgetBelowEditor: widgetBelowEditor, + ), + const _AccountPage(), + ]), + ), + ), + ); + } +} + +class _ChatPage extends StatelessWidget { + const _ChatPage({ + required this.superEditor, + required this.keyboardPanelController, + required this.imeConnectionNotifier, + this.widgetBelowEditor, + }); + + final Widget superEditor; + final KeyboardPanelController keyboardPanelController; + final ValueNotifier imeConnectionNotifier; + final Widget? widgetBelowEditor; + + @override + Widget build(BuildContext context) { + return KeyboardScaffoldSafeAreaScope( + debugLabel: "Root", + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + _buildPageContent(), + _buildChatEditor(), + ], + ), + ), + // Arbitrary widget below the page and editor content. Simulates, e.g., + // persistent bottom tabs, chat status, etc. + if (widgetBelowEditor != null) // + widgetBelowEditor!, + ], + ), + ); + } + + Widget _buildPageContent() { + // An area that simulates content that sits underneath + // a bottom mounted chat editor. + return Positioned.fill( + child: KeyboardScaffoldSafeArea( + debugLabel: "content", + child: Container( + key: _chatPageKey, + color: Colors.blue, + ), + ), + ); + } + + Widget _buildChatEditor() { + // An area that simulates a bottom mounted chat editor. + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: KeyboardScaffoldSafeArea( + debugLabel: "editor", + child: Builder(builder: (context) { + return KeyboardPanelScaffold( + controller: keyboardPanelController, + isImeConnected: imeConnectionNotifier, + contentBuilder: (context, isKeyboardPanelVisible) => ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 250), + child: ColoredBox( + color: Colors.yellow, + child: superEditor, + ), + ), + toolbarBuilder: (context, isKeyboardPanelVisible) => Container( + key: _aboveKeyboardToolbarKey, + height: 54, + color: Colors.green, + ), + fallbackPanelHeight: _keyboardPanelHeight, + keyboardPanelBuilder: (context, panel) => const SizedBox.expand( + child: ColoredBox( + key: _keyboardPanelKey, + color: Colors.red, + ), + ), + ); + }), + ), + ); + } +} + +class _AccountPage extends StatelessWidget { + const _AccountPage(); + + @override + Widget build(BuildContext context) { + return ColoredBox( + key: _accountPageKey, + color: Colors.grey.shade100, + child: const Center( + child: Icon(Icons.account_circle), + ), + ); + } +} + +/// Pumps a tree that displays a page of content with an editor above it, at the bottom +/// of the screen. +/// +/// The pumped tree only includes a single safe area scope, which ensures that apps with +/// only a single safe area work as expected. +/// +/// Simulates the software keyboard appearance and disappearance by animating +/// the `MediaQuery` view insets when the app communicates with the IME to show/hide +/// the software keyboard. +Future _pumpTestAppWithSingleSafeAreaScope( + WidgetTester tester, { + KeyboardPanelController? keyboardPanelController, + SoftwareKeyboardController? softwareKeyboardController, + ValueNotifier? isImeConnected, + double simulatedKeyboardHeight = _expandedPhoneKeyboardHeight, + // (Optional) widget that's positioned below the chat editor, which pushes + // the chat editor up from the bottom of the screen. + Widget? widgetBelowEditor, +}) async { + final keyboardController = softwareKeyboardController ?? SoftwareKeyboardController(); + final panelController = keyboardPanelController ?? KeyboardPanelController(keyboardController); + final imeConnectionNotifier = isImeConnected ?? ValueNotifier(false); + + await tester // + .createDocument() + .withLongDoc() + .withSoftwareKeyboardController(keyboardController) + .withImeConnectionNotifier(imeConnectionNotifier) + .simulateSoftwareKeyboardInsets( + true, + simulatedKeyboardHeight: simulatedKeyboardHeight, + ) + .withCustomWidgetTreeBuilder( + (superEditor) => _TestAppWithSingleSafeAreaScope( + superEditor: superEditor, + keyboardPanelController: panelController, + imeConnectionNotifier: imeConnectionNotifier, + widgetBelowEditor: widgetBelowEditor, + ), + ) + .pump(); +} + +class _TestAppWithSingleSafeAreaScope extends StatelessWidget { + const _TestAppWithSingleSafeAreaScope({ + required this.superEditor, + required this.keyboardPanelController, + required this.imeConnectionNotifier, + this.widgetBelowEditor, + }); + + final Widget superEditor; + + final KeyboardPanelController keyboardPanelController; + final ValueNotifier imeConnectionNotifier; + final Widget? widgetBelowEditor; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: KeyboardScaffoldSafeArea( + // ^ This is the one and only safe area scope in this tree. + debugLabel: "Root", + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Container( + key: _chatPageKey, + color: Colors.blue, + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Builder(builder: (context) { + return KeyboardPanelScaffold( + controller: keyboardPanelController, + isImeConnected: imeConnectionNotifier, + contentBuilder: (context, isKeyboardPanelVisible) => ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 250), + child: ColoredBox( + color: Colors.yellow, + child: superEditor, + ), + ), + toolbarBuilder: (context, isKeyboardPanelVisible) => Container( + key: _aboveKeyboardToolbarKey, + height: 54, + color: Colors.green, + ), + fallbackPanelHeight: _keyboardPanelHeight, + keyboardPanelBuilder: (context, panel) => const SizedBox.expand( + child: ColoredBox( + key: _keyboardPanelKey, + color: Colors.red, + ), + ), + ); + }), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +const _chatTabKey = ValueKey("chat_tab_button"); +const _chatPageKey = ValueKey("chat_content"); + +const _accountTabKey = ValueKey("account_tab_button"); +const _accountPageKey = ValueKey("account_content"); + +const _screen2BodyKey = ValueKey("screen_2_body"); + +/// Pumps a tree that can display two screens - the first screen has an editor +/// with a keyboard panel scaffold, the second screen has a keyboard safe area +/// but no editor or keyboard panel scaffold. +/// +/// Simulates the software keyboard appearance and disappearance by animating +/// the `MediaQuery` view insets when the app communicates with the IME to show/hide +/// the software keyboard. +Future _pumpTestAppWithNavigationScreens( + WidgetTester tester, { + required GlobalKey navigatorKey, + KeyboardPanelController? controller, + SoftwareKeyboardController? softwareKeyboardController, + ValueNotifier? isImeConnected, + double simulatedKeyboardHeight = _expandedPhoneKeyboardHeight, + // (Optional) widget that's positioned below the chat editor, which pushes + // the chat editor up from the bottom of the screen. + Widget? widgetBelowEditor, +}) async { + final keyboardController = softwareKeyboardController ?? SoftwareKeyboardController(); + final keyboardPanelController = controller ?? KeyboardPanelController(keyboardController); + final imeConnectionNotifier = isImeConnected ?? ValueNotifier(false); + + await tester // + .createDocument() + .withLongDoc() + .withSoftwareKeyboardController(keyboardController) + .withImeConnectionNotifier(imeConnectionNotifier) + .simulateSoftwareKeyboardInsets( + true, + simulatedKeyboardHeight: simulatedKeyboardHeight, + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + navigatorKey: navigatorKey, + routes: { + '/': (context) { + return _Screen1( + keyboardPanelController: keyboardPanelController, + imeConnectionNotifier: imeConnectionNotifier, + superEditor: superEditor, + widgetBelowEditor: widgetBelowEditor, + ); + }, + '/second': (context) { + return const _Screen2(); + }, + }, + ), + ) + .pump(); +} + +class _Screen1 extends StatelessWidget { + const _Screen1({ + required this.keyboardPanelController, + required this.imeConnectionNotifier, + required this.superEditor, + this.widgetBelowEditor, + }); + + final KeyboardPanelController keyboardPanelController; + final ValueNotifier imeConnectionNotifier; + final Widget superEditor; + final Widget? widgetBelowEditor; + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + body: KeyboardScaffoldSafeAreaScope( + // ^ This safe area is needed to receive the bottom insets from the + // bottom mounted editor, and then make it available to the subtree + // with the content behind the chat. + debugLabel: "_Screen1", + child: _ChatPage( + keyboardPanelController: keyboardPanelController, + imeConnectionNotifier: imeConnectionNotifier, + superEditor: superEditor, + widgetBelowEditor: widgetBelowEditor, + ), + ), + ); + } +} + +class _Screen2 extends StatelessWidget { + const _Screen2(); + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + body: KeyboardScaffoldSafeArea( + debugLabel: "_Screen2", + child: Builder(builder: (context) { + return ListView.builder( + key: _screen2BodyKey, + itemBuilder: (context, index) { + return ListTile( + title: Text("Item $index"), + ); + }, + ); + }), + ), + ); + } +} + +void testWidgetsOnMobilePhone( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnMobile( + description, + (WidgetTester tester) async { + tester.view + ..physicalSize = const Size(1179, 2556) + ..devicePixelRatio = 3.0; + + addTearDown(() { + tester.view.reset(); + }); + + await test(tester); + }, + skip: skip, + variant: variant, + ); +} + +// TODO: we want the iPad and Android tablet to be configurable in some way for +// minimized/floating keyboard vs docked keyboard. + +void testWidgetsOnIPad( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnIos( + description, + (WidgetTester tester) async { + tester.view + // Simulate an iPad Pro 12 + ..physicalSize = const Size(2048, 2732) + ..devicePixelRatio = 2.0; + + addTearDown(() { + tester.view.reset(); + }); + + await test(tester); + }, + skip: skip, + variant: variant, + ); +} + +void testWidgetsOnAndroidTablet( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnAndroid( + description, + (WidgetTester tester) async { + tester.view + // Simulate a Pixel tablet. + ..physicalSize = const Size(1600, 2560) + ..devicePixelRatio = 2.0; + + addTearDown(() { + tester.view.reset(); + }); + + await test(tester); + }, + skip: skip, + variant: variant, + ); +} + +// Height of the toolbar that sits above the keyboard/panel. +const _toolbarHeight = 54.0; + +// Simulated height of a fully visible phone keyboard. We specify this because +// there's no real window in a widget test, and therefore no real keyboard. +const _expandedPhoneKeyboardHeight = 300.0; + +// Arbitrary height used to display keyboard panels in place of the keyboard. These +// panels are controlled entirely by the KeyboardScaffold in the app, so the height can +// be whatever is desired. Ideally, apps would make these the same height as the keyboard, +// but we specify a value that's different from the simulated keyboard so that we can +// verify heights without accidentally confusing the keyboard and panel heights. +const _keyboardPanelHeight = 275.0; + +const _expandedIPadKeyboardHeight = 300.0; +// iPad can show a "minimized" keyboard, which takes up a short area at +// the bottom of the screen, and within that short area is a small +// toolbar that shows spelling suggestions along with a button that +// opens a keyboard options menu. +const _minimizedIPadKeyboardHeight = 69.0; + +const _expandedAndroidTabletKeyboardHeight = 300.0; +// Android tablets can show a "minimized" keyboard, which takes up a +// short area at the bottom of the screen, and within that short area +// is a small toolbar that includes delete, emojis, audio recording, and +// a button to open a menu. +const _minimizedAndroidTabletKeyboardHeight = 62.0; + +const _aboveKeyboardToolbarKey = ValueKey('toolbar'); +const _keyboardPanelKey = ValueKey('keyboardPanel'); + +enum _Panel { + panel1, + panel2; +} diff --git a/super_editor/test/src/infrastructure/multi_tap_gesture_test.dart b/super_editor/test/infrastructure/multi_tap_gesture_test.dart similarity index 80% rename from super_editor/test/src/infrastructure/multi_tap_gesture_test.dart rename to super_editor/test/infrastructure/multi_tap_gesture_test.dart index cc727cf23a..8fb2672477 100644 --- a/super_editor/test/src/infrastructure/multi_tap_gesture_test.dart +++ b/super_editor/test/infrastructure/multi_tap_gesture_test.dart @@ -43,8 +43,7 @@ void main() { ), ); - TestGesture gesture = - await tester.startGesture(tester.getCenter(tapTargetFinder)); + TestGesture gesture = await tester.startGesture(tester.getCenter(tapTargetFinder)); await tester.pump(); expect(tapDownCount, 1); expect(tapCount, 0); @@ -138,8 +137,7 @@ void main() { }), ); - TestGesture gesture = - await tester.startGesture(tester.getCenter(tapTargetFinder)); + TestGesture gesture = await tester.startGesture(tester.getCenter(tapTargetFinder)); await tester.pump(); expect(tapDownCount, 0); expect(tapCount, 0); @@ -215,8 +213,7 @@ void main() { ), ); - TestGesture gesture = - await tester.startGesture(tester.getCenter(tapTargetFinder)); + TestGesture gesture = await tester.startGesture(tester.getCenter(tapTargetFinder)); await tester.pump(); expect(tapDownCount, 0); expect(tapCount, 0); @@ -269,8 +266,7 @@ void main() { expect(timeoutCount, 1); }); - testWidgets("can ignore single tap and double tap gestures", - (tester) async { + testWidgets("can ignore single tap and double tap gestures", (tester) async { final recognizer = TapSequenceGestureRecognizer( supportedDevices: {PointerDeviceKind.touch}, reportPrecedingGestures: false, @@ -311,8 +307,7 @@ void main() { ), ); - TestGesture gesture = - await tester.startGesture(tester.getCenter(tapTargetFinder)); + TestGesture gesture = await tester.startGesture(tester.getCenter(tapTargetFinder)); await tester.pump(); expect(tapDownCount, 0); expect(tapCount, 0); @@ -374,6 +369,75 @@ void main() { await tester.pumpAndSettle(); }); + + testWidgets("cancels tap if another recognizer wins after tap down", (tester) async { + int tapDownCount = 0; + int tapCancelCount = 0; + int tapUpCount = 0; + int dragUpdateCount = 0; + + // Pump a tree with a tap recognizer and a drag recognizer to check if dragging + // after onTapDown was called causes the tap to be cancelled. + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RawGestureDetector( + gestures: { + HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => HorizontalDragGestureRecognizer(), + (HorizontalDragGestureRecognizer recognizer) { + recognizer.onUpdate = (_) { + dragUpdateCount += 1; + }; + }, + ), + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapSequenceGestureRecognizer(), + (TapSequenceGestureRecognizer recognizer) { + recognizer + ..onTapDown = (_) { + tapDownCount += 1; + } + ..onTapUp = (_) { + tapUpCount += 1; + } + ..onTapCancel = () { + tapCancelCount += 1; + }; + }, + ), + }, + child: Container( + key: const ValueKey('tap-target'), + width: 48, + height: 48, + color: Colors.red, + ), + ), + ), + ), + ); + + // Start the gesture, this should fire onTapDown. + final gesture = await tester.startGesture(tester.getCenter(tapTargetFinder)); + await tester.pump(kTapMinTime); + + // Trigger a horizontal drag. + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + + // Release the gesture. + await gesture.up(); + await tester.pump(); + + // Ensure that onTapCancel was called and onTapUp was not. + expect(tapDownCount, 1); + expect(tapCancelCount, 1); + expect(tapUpCount, 0); + expect(dragUpdateCount, 1); + }); }); } @@ -391,8 +455,7 @@ Widget _buildGestureScaffold( home: Center( child: RawGestureDetector( gestures: { - TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers< - TapSequenceGestureRecognizer>( + TapSequenceGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => recognizer, (TapSequenceGestureRecognizer recognizer) { recognizer diff --git a/super_editor/test/infrastructure/paste_test.dart b/super_editor/test/infrastructure/paste_test.dart new file mode 100644 index 0000000000..0d5ed53b49 --- /dev/null +++ b/super_editor/test/infrastructure/paste_test.dart @@ -0,0 +1,359 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_test_tools.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group("Paste >", () { + group("multi-node content >", () { + test("paste single paragraph into empty paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument( + nodes: [ + ParagraphNode(id: "2", text: AttributedText("Hello, World!")), + ], + ), + pastePosition: const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), + ), + ]); + + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("Hello, World!")), + ], + ), + ), + ); + expect( + editor.composer.selection, + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 13)), + ), + ); + }); + + test("paste single paragraph into middle of paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefghi"))], + ), + ); + + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument( + nodes: [ + ParagraphNode(id: "2", text: AttributedText("Hello, World!")), + ], + ), + pastePosition: const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 4)), + ), + ]); + + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcdHello, World!efghi")), + ], + ), + ), + ); + expect( + editor.composer.selection, + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 17)), + ), + ); + }); + + test("paste table in empty paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste a table. + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument(nodes: [ + _table, + ]), + pastePosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ]); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + _table, + ParagraphNode(id: editor.document.last.id, text: AttributedText()), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + test("paste table in middle of paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefgh"))], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste a table. + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument(nodes: [ + _table, + ]), + pastePosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + ]); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcd")), + _table, + ParagraphNode(id: editor.document.last.id, text: AttributedText("efgh")), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + test("paste multiple nodes into empty paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument( + nodes: [ + ParagraphNode(id: "2", text: AttributedText("One")), + ParagraphNode(id: "3", text: AttributedText("Two")), + ParagraphNode(id: "4", text: AttributedText("Three")), + ], + ), + pastePosition: const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), + ), + ]); + + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("One")), + ParagraphNode(id: "3", text: AttributedText("Two")), + ParagraphNode(id: "4", text: AttributedText("Three")), + ], + ), + ), + ); + expect( + editor.composer.selection, + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: "4", nodePosition: TextNodePosition(offset: 5)), + ), + ); + }); + + test("paste multiple nodes into middle of paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefghi"))], + ), + ); + + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument( + nodes: [ + ParagraphNode(id: "2", text: AttributedText("One")), + ParagraphNode(id: "3", text: AttributedText("Two")), + ParagraphNode(id: "4", text: AttributedText("Three")), + ], + ), + pastePosition: const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 4)), + ), + ]); + + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcdOne")), + ParagraphNode(id: "3", text: AttributedText("Two")), + ParagraphNode(id: "4", text: AttributedText("Threeefghi")), + ], + ), + ), + ); + + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: + DocumentPosition(nodeId: editor.document.last.id, nodePosition: const TextNodePosition(offset: 5)), + ), + ); + }); + }); + }); +} + +final _table = TableBlockNode(id: "table", cells: [ + [ + TextNode( + id: "1.1", + text: AttributedText("BMI Category"), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + }, + ), + TextNode( + id: "1.2", + text: AttributedText("BMI Range (kg/m²)"), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + }, + ), + ], + [ + TextNode( + id: "2.1", + text: AttributedText("Underweight"), + ), + TextNode( + id: "2.2", + text: AttributedText("< 18.5"), + ), + ], + [ + TextNode( + id: "3.1", + text: AttributedText("Normal weight"), + ), + TextNode( + id: "3.2", + text: AttributedText("18.5 – 24.9"), + ), + ], + [ + TextNode( + id: "4.1", + text: AttributedText("Overweight"), + ), + TextNode( + id: "4.2", + text: AttributedText("25.0 - 29.9"), + ), + ], + [ + TextNode( + id: "5.1", + text: AttributedText("Obesity (Class I)"), + ), + TextNode( + id: "5.2", + text: AttributedText("30.0 - 34.9"), + ), + ], + [ + TextNode( + id: "6.1", + text: AttributedText("Obesity (Class II)"), + ), + TextNode( + id: "6.2", + text: AttributedText("35.0 - 39.9"), + ), + ], + [ + TextNode( + id: "7.1", + text: AttributedText("Obesity (Class III)"), + ), + TextNode( + id: "7.2", + text: AttributedText("≥ 40.0"), + ), + ], +]); diff --git a/super_editor/test/infrastructure/selectable_list_test.dart b/super_editor/test/infrastructure/selectable_list_test.dart new file mode 100644 index 0000000000..4ca2cc131d --- /dev/null +++ b/super_editor/test/infrastructure/selectable_list_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/selectable_list.dart'; + +void main() { + group('ItemSelectionList', () { + testWidgetsOnAllPlatforms('changes active item down with DOWN ARROW', (tester) async { + String? activeItem; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => {}, + onItemActivated: (s) => activeItem = s, + ); + + // Ensure the popover is displayed without any active item. + expect(activeItem, isNull); + + // Press DOWN ARROW to activate the first item. + await tester.pressDownArrow(); + expect(activeItem, 'Item1'); + + // Press DOWN ARROW to activate the second item. + await tester.pressDownArrow(); + expect(activeItem, 'Item2'); + + // Press DOWN ARROW to activate the third item. + await tester.pressDownArrow(); + expect(activeItem, 'Item3'); + + // Press DOWN ARROW to activate the first item again. + await tester.pressDownArrow(); + expect(activeItem, 'Item1'); + }); + + testWidgetsOnAllPlatforms('changes active item up with UP ARROW', (tester) async { + String? activeItem; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => {}, + onItemActivated: (s) => activeItem = s, + ); + + // Ensure the popover is displayed without any activate item. + expect(activeItem, isNull); + + // Press UP ARROW to activate the last item. + await tester.pressUpArrow(); + expect(activeItem, 'Item3'); + + // Press UP ARROW to activate the second item. + await tester.pressUpArrow(); + expect(activeItem, 'Item2'); + + // Press UP ARROW to activate the first item. + await tester.pressUpArrow(); + expect(activeItem, 'Item1'); + + // Press UP ARROW to activate the last item again. + await tester.pressUpArrow(); + expect(activeItem, 'Item3'); + }); + + testWidgetsOnAllPlatforms('selects the active item on ENTER', (tester) async { + String? selectedValue; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => selectedValue = s, + ); + + // Press ARROW DOWN to activate the first item. + await tester.pressDownArrow(); + + // Press ENTER to select the active item. + await tester.pressEnter(); + await tester.pump(); + + // Ensure the first item was selected. + expect(selectedValue, 'Item1'); + }); + + testWidgetsOnAllPlatforms('clears selected item on ENTER without an active item', (tester) async { + String? selectedValue = ''; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => selectedValue = s, + ); + + // Press ENTER without an active item. + await tester.pressEnter(); + await tester.pump(); + + // Ensure the selected item was set to null. + expect(selectedValue, isNull); + }); + + testWidgetsOnAllPlatforms('calls onCancel on ESC', (tester) async { + String? selectedValue; + bool isCanceled = false; + + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) => selectedValue = s, + onCancel: () => isCanceled = true, + ); + + // Press ARROW DOWN to activate the first item. + await tester.pressDownArrow(); + + // Press ESC to cancel. + await tester.pressEscape(); + await tester.pump(); + + // Ensure onCancel was called and no item was selected. + expect(isCanceled, true); + expect(selectedValue, isNull); + }); + + testWidgetsOnAllPlatforms('isn\'t scrollable if all items fit on screen', (tester) async { + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) {}, + ); + + // Ensure the list isn't scrollable. + final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); + expect(dropdownButonState.scrollController.position.maxScrollExtent, 0.0); + }); + + testWidgetsOnAllPlatforms('is scrollable if items don\'t fit on screen', (tester) async { + await _pumpItemSelectionListTestApp( + tester, + onItemSelected: (s) {}, + constraints: const BoxConstraints(maxHeight: 50), + ); + + // Ensure the list is scrollable. + final dropdownButonState = tester.state>(find.byType(ItemSelectionList)); + expect(dropdownButonState.scrollController.position.maxScrollExtent, greaterThan(0.0)); + }); + }); +} + +/// Pumps a widget tree with a [ItemSelectionList] containing three items and +/// immediately requests focus to it. +Future _pumpItemSelectionListTestApp( + WidgetTester tester, { + required void Function(String? value) onItemSelected, + void Function(String? value)? onItemActivated, + VoidCallback? onCancel, + BoxConstraints? constraints, +}) async { + final focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: constraints ?? const BoxConstraints(), + child: ItemSelectionList( + focusNode: focusNode, + value: null, + items: const ['Item1', 'Item2', 'Item3'], + onItemSelected: onItemSelected, + onItemActivated: onItemActivated, + onCancel: onCancel, + itemBuilder: (context, item, isActive, onTap) => TextButton( + onPressed: onTap, + child: Text(item), + ), + ), + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); +} diff --git a/super_editor/test/infrastructure/serialization/html/document_to_html_test.dart b/super_editor/test/infrastructure/serialization/html/document_to_html_test.dart new file mode 100644 index 0000000000..dffd822e46 --- /dev/null +++ b/super_editor/test/infrastructure/serialization/html/document_to_html_test.dart @@ -0,0 +1,998 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group("Super Editor > HTML serialization >", () { + test("whole document", () { + expect( + _createDiverseDocument().toHtml(), + _getDiverseDocumentHtmlGolden(), + ); + }); + + test("partial document from middle to end", () { + expect( + _createDiverseDocument().toHtml( + selection: const DocumentSelection( + base: DocumentPosition( + nodeId: "8", + nodePosition: TextNodePosition(offset: 15), + ), + extent: DocumentPosition( + nodeId: "14", + nodePosition: TextNodePosition(offset: 28), + ), + ), + ), + [ + '

separates an unordered list from an ordered list.

', + '
    ', + '
  1. This is ordered list item 1
  2. ', + '
  3. This is ordered list item 2
  4. ', + '
  5. This is ordered list item 3
  6. ', + '
', + '
This is a blockquote
', + '
This is a code block
', + '

This is the final paragraph.

', + ].join(), + ); + }); + + test("partial document from beginning to middle", () { + expect( + _createDiverseDocument().toHtml( + selection: const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: "8", + nodePosition: TextNodePosition(offset: 15), + ), + ), + ), + [ + '

This is a header 1

', + '

This is a regular paragraph of text.

', + '', + '

This is another regular paragraph.

', + '
    ', + '
  • This is unordered list item 1
  • ', + '
  • This is unordered list item 2
  • ', + '
  • This is unordered list item 3
  • ', + '
', + '

This paragraph

', + ].join(), + ); + }); + + test("partial document from middle to middle", () { + expect( + _createDiverseDocument().toHtml( + selection: const DocumentSelection( + base: DocumentPosition( + nodeId: "6", + nodePosition: TextNodePosition(offset: 8), + ), + extent: DocumentPosition( + nodeId: "13", + nodePosition: TextNodePosition(offset: 14), + ), + ), + ), + [ + '
  • unordered list item 2
  • ', + '
  • This is unordered list item 3
  • ', + '', + '

    This paragraph separates an unordered list from an ordered list.

    ', + '
      ', + '
    1. This is ordered list item 1
    2. ', + '
    3. This is ordered list item 2
    4. ', + '
    5. This is ordered list item 3
    6. ', + '
    ', + '
    This is a blockquote
    ', + '
    This is a code
    ', + ].join(), + ); + }); + + group("start or end of block >", () { + test("starting at end of paragraph", () { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("This is paragraph 1")), + ParagraphNode(id: "2", text: AttributedText("This is paragraph 2")), + ], + ); + + expect( + document.toHtml( + selection: const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 19), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 19), + ), + ), + ), + "

    This is paragraph 2

    ", + ); + }); + + test("ending at beginning of paragraph", () { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("This is paragraph 1")), + ParagraphNode(id: "2", text: AttributedText("This is paragraph 2")), + ], + ); + + expect( + document.toHtml( + selection: const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + "

    This is paragraph 1

    ", + ); + }); + + test("from caret on downstream edge of image to paragraph", () { + final document = MutableDocument( + nodes: [ + ImageNode(id: "1", imageUrl: "https://doesnotexist.com/image.png"), + ParagraphNode(id: "2", text: AttributedText("This is paragraph 1")), + ], + ); + + expect( + document.toHtml( + selection: const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 19), + ), + ), + ), + "

    This is paragraph 1

    ", + ); + }); + + test("from paragraph to caret on upstream edge of image", () { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("This is paragraph 1")), + ImageNode(id: "2", imageUrl: "https://doesnotexist.com/image.png"), + ], + ); + + expect( + document.toHtml( + selection: const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ), + ), + "

    This is paragraph 1

    ", + ); + }); + }); + + group("collapsed selections >", () { + test("end of paragraph to beginning of paragraph", () { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("This is a paragraph 1")), + ParagraphNode(id: "2", text: AttributedText("This is a paragraph 2")), + ], + ); + + expect( + document.toHtml( + selection: const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + "", + ); + }); + + test("caret in paragraph", () { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("This is a paragraph of text")), + ], + ); + + expect( + document.toHtml( + selection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 3), + ), + ), + ), + "", + ); + }); + + test("caret in image", () { + final document = MutableDocument( + nodes: [ + ImageNode(id: "1", imageUrl: "https://doesnotexist.com/image.png"), + ], + ); + + expect( + document.toHtml( + selection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ), + ), + "", + ); + }); + }); + + test("inline text styles", () { + expect( + deserializeMarkdownToDocument( + "This paragraph contains many inline styles: **bold**, *italics*, ¬underline¬, ~strikethrough~, [link](https://someplace.com).", + ).toHtml(), + '

    This paragraph contains many inline styles: bold, italics, underline, strikethrough, link.

    ', + ); + }); + + group('tables >', () { + test("with header and body", () { + final document = MutableDocument( + nodes: [ + TableBlockNode(id: "1", cells: [ + [ + TextNode( + id: "1.0.1", + text: AttributedText("Column 1"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "1.0.2", + text: AttributedText("Column 2"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "1.0.3", + text: AttributedText("Column 3"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + ], + [ + TextNode(id: "1.1.1", text: AttributedText("Value 1.1")), + TextNode(id: "1.1.2", text: AttributedText("Value 1.2")), + TextNode(id: "1.1.3", text: AttributedText("Value 1.3")), + ], + [ + TextNode(id: "1.2.1", text: AttributedText("Value 2.1")), + TextNode(id: "1.2.2", text: AttributedText("Value 2.2")), + TextNode(id: "1.2.3", text: AttributedText("Value 2.3")), + ], + ]), + ], + ); + + expect( + document.toHtml(), + [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    Column 1Column 2Column 3
    Value 1.1Value 1.2Value 1.3
    Value 2.1Value 2.2Value 2.3
    ', + ].join(), + ); + }); + + test("with multiple header rows", () { + final document = MutableDocument( + nodes: [ + TableBlockNode( + id: "1", + cells: [ + [ + TextNode( + id: "1.0.1", + text: AttributedText("Column 1"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "1.0.2", + text: AttributedText("Column 2"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + ], + [ + TextNode( + id: "2.0.1", + text: AttributedText("Another Column 1"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "2.0.2", + text: AttributedText("Another Column 2"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + ], + [ + TextNode( + id: "1.1.1", + text: AttributedText("Value 1.1"), + ), + TextNode( + id: "1.1.2", + text: AttributedText("Value 1.2"), + ), + ], + ], + ), + ], + ); + + expect( + document.toHtml(), + [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    Column 1Column 2
    Another Column 1Another Column 2
    Value 1.1Value 1.2
    ', + ].join(), + ); + }); + + test("rows after the first data row are serialized inside ", () { + final document = MutableDocument( + nodes: [ + TableBlockNode( + id: "1", + cells: [ + [ + TextNode( + id: "1.1", + text: AttributedText("Column 1"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "1.2", + text: AttributedText("Column 2"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + ], + [ + TextNode( + id: "2.1", + text: AttributedText("Value 1"), + ), + TextNode( + id: "2.2", + text: AttributedText("Value 2"), + ), + ], + [ + TextNode( + id: "3.1", + text: AttributedText("Value 3"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "3.2", + text: AttributedText("Value 4"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + ], + ], + ), + ], + ); + + expect( + document.toHtml(), + [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    Column 1Column 2
    Value 1Value 2
    Value 3Value 4
    ', + ].join(), + ); + }); + + test("rows containing data cells are serialized inside ", () { + final document = MutableDocument( + nodes: [ + TableBlockNode( + id: "1", + cells: [ + [ + TextNode( + id: "1.1", + text: AttributedText("Column 1"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "1.2", + text: AttributedText("Column 2"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + ], + [ + TextNode( + id: "2.1", + text: AttributedText("Value 1"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "2.2", + text: AttributedText("Value 2"), + ), + ], + [ + TextNode( + id: "3.1", + text: AttributedText("Value 3"), + ), + TextNode( + id: "3.2", + text: AttributedText("Value 4"), + ), + ], + ], + ), + ], + ); + + expect( + document.toHtml(), + [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    Column 1Column 2
    Value 1Value 2
    Value 3Value 4
    ', + ].join(), + ); + }); + + test("without body", () { + final document = MutableDocument( + nodes: [ + TableBlockNode(id: "1", cells: [ + [ + TextNode( + id: "1.0.1", + text: AttributedText("Column 1"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "1.0.2", + text: AttributedText("Column 2"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "1.0.3", + text: AttributedText("Column 3"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + ], + ]), + ], + ); + + expect( + document.toHtml(), + [ + '', + '', + '', + '', + '', + '', + '', + '', + '
    Column 1Column 2Column 3
    ', + ].join(), + ); + }); + + test("with header cell inside data row", () { + final document = MutableDocument( + nodes: [ + TableBlockNode( + id: "1", + cells: [ + [ + TextNode( + id: "1.1", + text: AttributedText("Value 1.1"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "1.2", + text: AttributedText("Value 1.2"), + ), + ], + [ + TextNode( + id: "2.1", + text: AttributedText("Value 2.1"), + ), + TextNode( + id: "2.2", + text: AttributedText("Value 2.2"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + ], + ], + ), + ], + ); + + expect( + document.toHtml(), + [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    Value 1.1Value 1.2
    Value 2.1Value 2.2
    ', + ].join(), + ); + }); + + test("with alignment", () { + final document = MutableDocument( + nodes: [ + TableBlockNode( + id: "1", + cells: [ + [ + TextNode( + id: "1.0.1", + text: AttributedText("Column 1"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "1.0.2", + text: AttributedText("Column 2"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + ], + [ + TextNode( + id: "1.1.1", + text: AttributedText("Value 1.1"), + metadata: const {"textAlign": TextAlign.right}, + ), + TextNode( + id: "1.1.2", + text: AttributedText("Value 1.2"), + metadata: const {"textAlign": TextAlign.center}, + ), + ], + ], + ), + ], + ); + + expect( + document.toHtml(), + [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    Column 1Column 2
    Value 1.1Value 1.2
    ', + ].join(), + ); + }); + + test("with inline styles", () { + final document = MutableDocument( + nodes: [ + TableBlockNode( + id: "1", + cells: [ + [ + TextNode( + id: "1.0.1", + text: AttributedText("Column 1"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + TextNode( + id: "1.0.2", + text: AttributedText("Column 2"), + metadata: const {NodeMetadata.blockType: tableHeaderAttribution}, + ), + ], + [ + TextNode( + id: "1.1.1", + text: AttributedText( + "Value 1.1", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), + ], + ), + ), + ), + TextNode( + id: "1.1.2", + text: AttributedText( + "Value 1.2", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: italicsAttribution, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: italicsAttribution, offset: 8, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ], + ], + ), + ], + ); + + expect( + document.toHtml(), + [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    Column 1Column 2
    Value 1.1Value 1.2
    ', + ].join(), + ); + }); + }); + + group("custom serialization >", () { + test("custom node type", () { + expect( + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("Custom Nodes"), + metadata: const { + NodeMetadata.blockType: header1Attribution, + }, + ), + ParagraphNode( + id: "2", + text: AttributedText("Below this is a custom table node serialization."), + ), + _TableNode(id: "3"), + ], + ).toHtml( + nodeSerializers: [ + _tableHtmlSerializer, + ...defaultNodeHtmlSerializerChain, + ], + ), + [ + '

    Custom Nodes

    ', + '

    Below this is a custom table node serialization.

    ', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    Column 1Column 2
    Value 1Value 2
    ', + ].join(''), + ); + }); + + test("custom inline text style", () { + expect( + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This paragraph contains custom styling.", + AttributedSpans( + attributions: [ + const SpanMarker(attribution: _customStyle, offset: 24, markerType: SpanMarkerType.start), + const SpanMarker(attribution: _customStyle, offset: 37, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ], + ).toHtml( + inlineSerializers: [ + (Attribution attribution, TagType tagType) { + if (attribution != _customStyle) { + return null; + } + + return switch (tagType) { + TagType.opening => '', + TagType.closing => '', + }; + }, + ...defaultInlineHtmlSerializers, + ], + ), + "

    This paragraph contains custom styling.

    ", + ); + }); + }); + }); +} + +Document _createDiverseDocument() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("This is a header 1"), + metadata: const { + NodeMetadata.blockType: header1Attribution, + }, + ), + ParagraphNode( + id: "2", + text: AttributedText("This is a regular paragraph of text."), + ), + ImageNode(id: "3", imageUrl: "https://doesnotexist.com/image.png"), + ParagraphNode( + id: "4", + text: AttributedText("This is another regular paragraph."), + ), + ListItemNode.unordered( + id: "5", + text: AttributedText("This is unordered list item 1"), + ), + ListItemNode.unordered( + id: "6", + text: AttributedText("This is unordered list item 2"), + ), + ListItemNode.unordered( + id: "7", + text: AttributedText("This is unordered list item 3"), + ), + ParagraphNode( + id: "8", + text: AttributedText("This paragraph separates an unordered list from an ordered list."), + ), + ListItemNode.ordered( + id: "9", + text: AttributedText("This is ordered list item 1"), + ), + ListItemNode.ordered( + id: "10", + text: AttributedText("This is ordered list item 2"), + ), + ListItemNode.ordered( + id: "11", + text: AttributedText("This is ordered list item 3"), + ), + ParagraphNode( + id: "12", + text: AttributedText("This is a blockquote"), + metadata: const { + NodeMetadata.blockType: blockquoteAttribution, + }, + ), + ParagraphNode( + id: "13", + text: AttributedText("This is a code block"), + metadata: const { + NodeMetadata.blockType: codeAttribution, + }, + ), + ParagraphNode( + id: "14", + text: AttributedText("This is the final paragraph."), + ), + ], + ); + +String _getDiverseDocumentHtmlGolden() => [ + '

    This is a header 1

    ', + '

    This is a regular paragraph of text.

    ', + '', + '

    This is another regular paragraph.

    ', + '
      ', + '
    • This is unordered list item 1
    • ', + '
    • This is unordered list item 2
    • ', + '
    • This is unordered list item 3
    • ', + '
    ', + '

    This paragraph separates an unordered list from an ordered list.

    ', + '
      ', + '
    1. This is ordered list item 1
    2. ', + '
    3. This is ordered list item 2
    4. ', + '
    5. This is ordered list item 3
    6. ', + '
    ', + '
    This is a blockquote
    ', + '
    This is a code block
    ', + '

    This is the final paragraph.

    ', + ].join(); + +const _customStyle = NamedAttribution("custom_style"); + +String? _tableHtmlSerializer( + Document document, + DocumentNode node, + NodeSelection? selection, + InlineHtmlSerializerChain inlineSerializers, +) { + if (node is! _TableNode) { + return null; + } + if (selection != null) { + if (selection is! UpstreamDownstreamNodeSelection) { + // We don't know how to handle this selection type. + return null; + } + if (selection.isCollapsed) { + // This selection doesn't include the image - it's a collapsed selection + // either on the upstream or downstream edge. + return null; + } + } + + return [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    Column 1Column 2
    Value 1Value 2
    ', + ].join(''); +} + +class _TableNode extends BlockNode { + _TableNode({ + required this.id, + }); + + @override + final String id; + + @override + DocumentNode copyWithAddedMetadata(Map newProperties) { + throw UnimplementedError(); + } + + @override + DocumentNode copyAndReplaceMetadata(Map newMetadata) { + throw UnimplementedError(); + } + + @override + String? copyContent(NodeSelection selection) { + throw UnimplementedError(); + } +} diff --git a/super_editor/test/infrastructure/serialization/markdown/attributed_text_markdown_test.dart b/super_editor/test/infrastructure/serialization/markdown/attributed_text_markdown_test.dart new file mode 100644 index 0000000000..b5bcf3d898 --- /dev/null +++ b/super_editor/test/infrastructure/serialization/markdown/attributed_text_markdown_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group("AttributedText markdown serializes", () { + test("un-styled text", () { + expect( + AttributedText("This is unstyled text.").toMarkdown(), + "This is unstyled text.", + ); + }); + + test("single character styles", () { + expect( + attributedTextFromMarkdown( + "This is **s**ingle characte*r* styles.", + ).toMarkdown(), + "This is **s**ingle characte*r* styles.", + ); + }); + + test("bold text", () { + expect( + attributedTextFromMarkdown( + "This is **bold** text.", + ).toMarkdown(), + "This is **bold** text.", + ); + }); + + test("italics text", () { + expect( + attributedTextFromMarkdown( + "This is *italics* text.", + ).toMarkdown(), + "This is *italics* text.", + ); + }); + + test("multiple styles across the same span", () { + expect( + attributedTextFromMarkdown( + "This is ***multiple styled*** text.", + ).toMarkdown(), + "This is ***multiple styled*** text.", + ); + }); + + test("partially overlapping styles", () { + // This test needs to manually configure attributed spans because it + // turns out that Markdown doesn't know how to parse overlapping styles, + // so we can't parse this text from Markdown, but we can still test our + // ability to serialize overlapping styles. + expect( + AttributedText( + "This is overlapping styles.", + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 13, markerType: SpanMarkerType.end), + const SpanMarker(attribution: italicsAttribution, offset: 11, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 18, markerType: SpanMarkerType.end), + ], + ), + ).toMarkdown(), + "This is **ove*rla**pping* styles.", + ); + }); + }); +} diff --git a/super_editor_markdown/test/custom_block_parser_test.dart b/super_editor/test/infrastructure/serialization/markdown/custom_block_parser_test.dart similarity index 72% rename from super_editor_markdown/test/custom_block_parser_test.dart rename to super_editor/test/infrastructure/serialization/markdown/custom_block_parser_test.dart index f3575cb020..6dfd9b0dcc 100644 --- a/super_editor_markdown/test/custom_block_parser_test.dart +++ b/super_editor/test/infrastructure/serialization/markdown/custom_block_parser_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/super_editor.dart'; -import 'package:super_editor_markdown/super_editor_markdown.dart'; import 'custom_parsers/callout_block.dart'; import 'custom_parsers/upsell_block.dart'; @@ -15,25 +14,25 @@ This is a normal paragraph. @@@ upsell This is another normal paragraph.''', - customBlockSyntax: [UpsellBlockSyntax()], + customBlockSyntax: [const UpsellBlockSyntax()], customElementToNodeConverters: [UpsellElementToNodeConverter()], ); - expect(document.nodes.length, 4); + expect(document.nodeCount, 4); expect( - document.nodes[0], + document.getNodeAt(0)!, isA(), ); expect( - document.nodes[1], + document.getNodeAt(1)!, isA(), ); expect( - document.nodes[2], + document.getNodeAt(2)!, isA(), ); expect( - document.nodes[3], + document.getNodeAt(3)!, isA(), ); }); @@ -48,29 +47,29 @@ This is a **callout**! @@@ This is another normal paragraph.''', - customBlockSyntax: [CalloutBlockSyntax()], + customBlockSyntax: [const CalloutBlockSyntax()], customElementToNodeConverters: [CalloutElementToNodeConverter()], ); - expect(document.nodes.length, 4); + expect(document.nodeCount, 4); expect( - document.nodes[0], + document.getNodeAt(0)!, isA(), ); expect( - document.nodes[1], + document.getNodeAt(1)!, isA(), ); expect( - document.nodes[2], + document.getNodeAt(2)!, isA(), ); expect( - (document.nodes[2] as ParagraphNode).metadata["blockType"], + (document.getNodeAt(2)! as ParagraphNode).metadata["blockType"], const NamedAttribution("callout"), ); expect( - document.nodes[3], + document.getNodeAt(3)!, isA(), ); }); diff --git a/super_editor/test/infrastructure/serialization/markdown/custom_block_serializer_test.dart b/super_editor/test/infrastructure/serialization/markdown/custom_block_serializer_test.dart new file mode 100644 index 0000000000..b93e697428 --- /dev/null +++ b/super_editor/test/infrastructure/serialization/markdown/custom_block_serializer_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +import 'custom_parsers/callout_block.dart'; +import 'custom_parsers/upsell_block.dart'; + +void main() { + group("Markdown serialization", () { + test("handles custom placeholder block node", () { + final markdown = serializeDocumentToMarkdown( + MutableDocument( + nodes: [ + ParagraphNode(id: Editor.createNodeId(), text: AttributedText("Paragraph 1")), + UpsellNode(Editor.createNodeId()), + ParagraphNode(id: Editor.createNodeId(), text: AttributedText("Paragraph 2")), + ], + ), + customNodeSerializers: [UpsellSerializer()], + ); + + expect( + markdown, + '''Paragraph 1 + +@@@ upsell + +Paragraph 2''', + ); + }); + + test("handles custom text node", () { + final markdown = serializeDocumentToMarkdown( + MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText("Paragraph 1"), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: attributedTextFromMarkdown("This is a **callout!**"), + metadata: const { + "blockType": NamedAttribution("callout"), + }, + ), + ParagraphNode(id: Editor.createNodeId(), text: AttributedText("Paragraph 2")), + ], + ), + customNodeSerializers: [CalloutSerializer()], + ); + + expect( + markdown, + '''Paragraph 1 + +@@@ callout +This is a **callout!** +@@@ + +Paragraph 2''', + ); + }); + }); +} diff --git a/super_editor_markdown/test/custom_parsers/callout_block.dart b/super_editor/test/infrastructure/serialization/markdown/custom_parsers/callout_block.dart similarity index 83% rename from super_editor_markdown/test/custom_parsers/callout_block.dart rename to super_editor/test/infrastructure/serialization/markdown/custom_parsers/callout_block.dart index 915b73f679..839d9e20f5 100644 --- a/super_editor_markdown/test/custom_parsers/callout_block.dart +++ b/super_editor/test/infrastructure/serialization/markdown/custom_parsers/callout_block.dart @@ -1,6 +1,5 @@ import 'package:markdown/markdown.dart' as md; import 'package:super_editor/super_editor.dart'; -import 'package:super_editor_markdown/super_editor_markdown.dart'; /// Markdown block-parser for callouts. /// @@ -22,26 +21,26 @@ class CalloutBlockSyntax extends md.BlockSyntax { // This method was adapted from the standard Blockquote parser, and // the standard code fence block parser. @override - List parseChildLines(md.BlockParser parser) { + List parseChildLines(md.BlockParser parser) { // Grab all of the lines that form the custom block, stripping off the // first line, e.g., "@@@ customBlock", and the last line, e.g., "@@@". var childLines = []; while (!parser.isDone) { - final openingLine = pattern.firstMatch(parser.current); + final openingLine = pattern.firstMatch(parser.current.content); if (openingLine != null) { // This is the first line. Ignore it. parser.advance(); continue; } - final closingLine = _endLinePattern.firstMatch(parser.current); + final closingLine = _endLinePattern.firstMatch(parser.current.content); if (closingLine != null) { // This is the closing line. Ignore it. parser.advance(); // If we're followed by a blank line, skip it, so that we don't end // up with an extra paragraph for that blank line. - if (parser.current.trim().isEmpty) { + if (parser.current.content.trim().isEmpty) { parser.advance(); } @@ -49,11 +48,11 @@ class CalloutBlockSyntax extends md.BlockSyntax { break; } - childLines.add(parser.current); + childLines.add(parser.current.content); parser.advance(); } - return childLines; + return childLines.map((l) => md.Line(l)).toList(); } // This method was adapted from the standard Blockquote parser, and @@ -76,10 +75,10 @@ class CalloutElementToNodeConverter implements ElementToNodeConverter { } return ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: _parseInlineText(element), - metadata: { - 'blockType': const NamedAttribution("callout"), + metadata: const { + 'blockType': NamedAttribution("callout"), }, ); } @@ -95,6 +94,7 @@ _InlineMarkdownToDocument _parseInline(md.Element element) { element.textContent, md.Document( inlineSyntaxes: [ + SingleStrikethroughSyntax(), // this needs to be before md.StrikethroughSyntax to be recognized md.StrikethroughSyntax(), UnderlineSyntax(), ], @@ -115,7 +115,7 @@ class _InlineMarkdownToDocument implements md.NodeVisitor { // if we find an image without any text, we're parsing an image. // Otherwise, if there is any text, then we're parsing a paragraph // and we ignore the image. - bool get isImage => _imageUrl != null && attributedText.text.isEmpty; + bool get isImage => _imageUrl != null && attributedText.isEmpty; String? _imageUrl; String? get imageUrl => _imageUrl; @@ -144,7 +144,7 @@ class _InlineMarkdownToDocument implements md.NodeVisitor { @override void visitText(md.Text text) { final attributedText = _textStack.removeLast(); - _textStack.add(attributedText.copyAndAppend(AttributedText(text: text.text))); + _textStack.add(attributedText.copyAndAppend(AttributedText(text.text))); } @override @@ -156,42 +156,27 @@ class _InlineMarkdownToDocument implements md.NodeVisitor { if (element.tag == 'strong') { styledText.addAttribution( boldAttribution, - SpanRange( - start: 0, - end: styledText.text.length - 1, - ), + SpanRange(0, styledText.length - 1), ); } else if (element.tag == 'em') { styledText.addAttribution( italicsAttribution, - SpanRange( - start: 0, - end: styledText.text.length - 1, - ), + SpanRange(0, styledText.length - 1), ); } else if (element.tag == "del") { styledText.addAttribution( strikethroughAttribution, - SpanRange( - start: 0, - end: styledText.text.length - 1, - ), + SpanRange(0, styledText.length - 1), ); } else if (element.tag == "u") { styledText.addAttribution( underlineAttribution, - SpanRange( - start: 0, - end: styledText.text.length - 1, - ), + SpanRange(0, styledText.length - 1), ); } else if (element.tag == 'a') { styledText.addAttribution( - LinkAttribution(url: Uri.parse(element.attributes['href']!)), - SpanRange( - start: 0, - end: styledText.text.length - 1, - ), + LinkAttribution.fromUri(Uri.parse(element.attributes['href']!)), + SpanRange(0, styledText.length - 1), ); } @@ -206,7 +191,7 @@ class _InlineMarkdownToDocument implements md.NodeVisitor { class CalloutSerializer implements DocumentNodeMarkdownSerializer { @override - String? serialize(Document document, DocumentNode node) { + String? serialize(Document document, DocumentNode node, {NodeSelection? selection}) { if (node is! ParagraphNode) { return null; } diff --git a/super_editor_markdown/test/custom_parsers/upsell_block.dart b/super_editor/test/infrastructure/serialization/markdown/custom_parsers/upsell_block.dart similarity index 78% rename from super_editor_markdown/test/custom_parsers/upsell_block.dart rename to super_editor/test/infrastructure/serialization/markdown/custom_parsers/upsell_block.dart index 433c0722dd..8102c55dd3 100644 --- a/super_editor_markdown/test/custom_parsers/upsell_block.dart +++ b/super_editor/test/infrastructure/serialization/markdown/custom_parsers/upsell_block.dart @@ -1,7 +1,5 @@ -import 'package:flutter/foundation.dart'; import 'package:markdown/markdown.dart' as md; import 'package:super_editor/super_editor.dart'; -import 'package:super_editor_markdown/super_editor_markdown.dart'; /// A [DocumentNode] that represents a placeholder for an "upsell message". /// @@ -13,16 +11,30 @@ import 'package:super_editor_markdown/super_editor_markdown.dart'; /// change on all blog posts over time. As a result, this node, and its component /// in the [SuperReader] are just placeholders for content that will be chosen /// when rendered. -class UpsellNode extends BlockNode with ChangeNotifier { +class UpsellNode extends BlockNode { UpsellNode(this.id); @override final String id; + @override + UpsellNode copyAndReplaceMetadata(Map newMetadata) { + return UpsellNode(id); + } + + @override + UpsellNode copyWithAddedMetadata(Map newProperties) { + return UpsellNode(id); + } + @override String? copyContent(NodeSelection selection) { return null; } + + DocumentNode copy() { + return UpsellNode(id); + } } /// Markdown block-parser for upsell messages. @@ -50,13 +62,13 @@ class UpsellElementToNodeConverter implements ElementToNodeConverter { return null; } - return UpsellNode(DocumentEditor.createNodeId()); + return UpsellNode(Editor.createNodeId()); } } class UpsellSerializer extends NodeTypedDocumentNodeMarkdownSerializer { @override - String doSerialization(Document document, UpsellNode node) { + String doSerialization(Document document, UpsellNode node, {NodeSelection? selection}) { return "@@@ upsell\n"; } } diff --git a/super_editor/test/infrastructure/serialization/markdown/markdown_inline_upstream_plugin_test.dart b/super_editor/test/infrastructure/serialization/markdown/markdown_inline_upstream_plugin_test.dart new file mode 100644 index 0000000000..43c0f4e470 --- /dev/null +++ b/super_editor/test/infrastructure/serialization/markdown/markdown_inline_upstream_plugin_test.dart @@ -0,0 +1,562 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group("Super Editor upstream Markdown reaction >", () { + group("at beginning of paragraph >", () { + testWidgets("bold", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + await tester.typeImeText("**bold**"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "bold"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 3, markerType: SpanMarkerType.end), + ]); + }); + + testWidgets("italics", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + await tester.typeImeText("*italics*"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "italics"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: italicsAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 6, markerType: SpanMarkerType.end), + ]); + }); + + testWidgets("strikethrough", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + await tester.typeImeText("~strikethrough"); + + // Simulate an insertion containing a composing region. + await tester.ime.sendDeltas([ + const TextEditingDeltaInsertion( + oldText: '. ~strikethrough', + textInserted: '~', + insertionOffset: 16, + selection: TextSelection.collapsed(offset: 16), + composing: TextRange.collapsed(16), + ) + ], getter: imeClientGetter); + await tester.pump(); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "strikethrough"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: strikethroughAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: strikethroughAttribution, offset: 12, markerType: SpanMarkerType.end), + ]); + }); + + testWidgets("code", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + await tester.typeImeText("`code"); + + // Simulate an insertion containing a composing region. + await tester.ime.sendDeltas([ + const TextEditingDeltaInsertion( + oldText: '. `code', + textInserted: '`', + insertionOffset: 7, + selection: TextSelection.collapsed(offset: 7), + composing: TextRange.collapsed(7), + ) + ], getter: imeClientGetter); + await tester.pump(); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "code"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: codeAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: codeAttribution, offset: 3, markerType: SpanMarkerType.end), + ]); + }); + + group("unbalanced >", () { + testWidgets("bold then italics", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + await tester.typeImeText("**token*"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "**token*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers, isEmpty); + }); + + testWidgets("italics then bold", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + await tester.typeImeText("*token**"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "token*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: italicsAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 4, markerType: SpanMarkerType.end), + ]); + }); + }); + }); + + group("in middle of paragraph >", () { + testWidgets("bold", (tester) async { + final (document, _) = await _pumpScaffold(tester, "Hello"); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 5); + + await tester.typeImeText(" **bold**"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello bold"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: boldAttribution, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 9, markerType: SpanMarkerType.end), + ]); + }); + + testWidgets("italics", (tester) async { + final (document, _) = await _pumpScaffold(tester, "Hello"); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 5); + + await tester.typeImeText(" *italics*"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello italics"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: italicsAttribution, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 12, markerType: SpanMarkerType.end), + ]); + }); + + testWidgets("strikethrough", (tester) async { + final (document, _) = await _pumpScaffold(tester, "Hello"); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 5); + + await tester.typeImeText(" ~strikethrough~"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello strikethrough"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: strikethroughAttribution, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: strikethroughAttribution, offset: 18, markerType: SpanMarkerType.end), + ]); + }); + + group("unbalanced >", () { + testWidgets("bold then italics", (tester) async { + final (document, _) = await _pumpScaffold(tester, "Hello"); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 5); + await tester.typeImeText(" **token*"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello **token*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers, isEmpty); + }); + + testWidgets("italics then bold", (tester) async { + final (document, _) = await _pumpScaffold(tester, "Hello"); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 5); + await tester.typeImeText(" *token**"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello token*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: italicsAttribution, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 10, markerType: SpanMarkerType.end), + ]); + }); + }); + }); + + group("prevented deserializations >", () { + testWidgets("unbalanced italics", (tester) async { + final (document, _) = await _pumpScaffold(tester, ""); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + await tester.typeImeText("**noitalics*"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "**noitalics*"); + expect((document.first as ParagraphNode).text.spans.markers.isEmpty, isTrue); + }); + }); + + testWidgets("multiple styles", (tester) async { + final (document, _) = await _pumpScaffold(tester, "Hello"); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 5); + + // Italics + await tester.typeImeText(" *italics*"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello italics"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: italicsAttribution, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 12, markerType: SpanMarkerType.end), + ]); + + // Bold + await tester.typeImeText(" and **bold**"); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello italics and bold"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: italicsAttribution, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 12, markerType: SpanMarkerType.end), + const SpanMarker(attribution: boldAttribution, offset: 18, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 21, markerType: SpanMarkerType.end), + ]); + + // Strikethrough + await tester.typeImeText(" and ~strikethrough~"); + expect( + SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello italics and bold and strikethrough"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: italicsAttribution, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 12, markerType: SpanMarkerType.end), + const SpanMarker(attribution: boldAttribution, offset: 18, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 21, markerType: SpanMarkerType.end), + const SpanMarker(attribution: strikethroughAttribution, offset: 27, markerType: SpanMarkerType.start), + const SpanMarker(attribution: strikethroughAttribution, offset: 39, markerType: SpanMarkerType.end), + ]); + }); + + testWidgets("preserves non-Markdown attributions", (tester) async { + final (document, editor) = await _pumpScaffold(tester, "Hello *italics"); + + final nodeId = document.first.id; + + // Add a non-Markdown attribution to the text. + const colorAttribution = ColorAttribution(Color(0xFFFF0000)); + editor.execute([ + AddTextAttributionsRequest( + // Attribution applied to: "He[llo *ital]ics", which is start: 2, end: 11, + // because the end is exclusive. + documentRange: DocumentRange( + start: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 2), + ), + end: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 11), + ), + ), + attributions: { + colorAttribution, + }, + ), + ]); + + // Add a "*" to add italics attribution through Markdown. + await tester.placeCaretInParagraph(nodeId, 14); + await tester.typeImeText("*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello italics"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: colorAttribution, offset: 2, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: colorAttribution, offset: 9, markerType: SpanMarkerType.end), + const SpanMarker(attribution: italicsAttribution, offset: 12, markerType: SpanMarkerType.end), + ]); + }); + + testWidgets("replicates same ambiguity behaviors as other products", (tester) async { + // This test verifies that the reaction does the same thing as Notion and Linear + // when given a specific ambiguous input. + final (document, _) = await _pumpScaffold(tester, "Hello"); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 5); + + // "**this*" should do nothing because the downstream syntax doesn't have a + // balancing upstream syntax. We don't peel a single "*" out of the upstream "**". + await tester.typeImeText(" **this*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello **this*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), isEmpty); + + // Type " and *" which results in a segment of "* and *". This segment shouldn't be + // applied as Markdown because we ignore situations where the downstream syntax + // immediately follows a space. + await tester.typeImeText(" and *"); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello **this* and *"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), isEmpty); + + // Surround "that" with italics "*". This should be found and applied. + await tester.typeImeText("that*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello **this* and that"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: italicsAttribution, offset: 18, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 21, markerType: SpanMarkerType.end), + ]); + + await tester.typeImeText("*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello **this* and that*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: italicsAttribution, offset: 18, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 21, markerType: SpanMarkerType.end), + ]); + + // Surround "this* and that" with bold "**" on both side. This should be found and applied. + await tester.typeImeText("*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello this* and that"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), [ + const SpanMarker(attribution: boldAttribution, offset: 6, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 16, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 19, markerType: SpanMarkerType.end), + const SpanMarker(attribution: boldAttribution, offset: 19, markerType: SpanMarkerType.end), + ]); + }); + + group("does not parse upstream syntax creation >", () { + testWidgets("italics", (tester) async { + final (document, _) = await _pumpScaffold(tester, "Hello italics*"); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 6); + + await tester.typeImeText("*"); + + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello *italics*"); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition(nodeId: nodeId, nodePosition: const TextNodePosition(offset: 7)), + ), + ); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), isEmpty); + }); + + testWidgets("bold", (tester) async { + final (document, _) = await _pumpScaffold(tester, "Hello bold**"); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 6); + + await tester.typeImeText("*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello *bold**"); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition(nodeId: nodeId, nodePosition: const TextNodePosition(offset: 7)), + ), + ); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), isEmpty); + + await tester.typeImeText("*"); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello **bold**"); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition(nodeId: nodeId, nodePosition: const TextNodePosition(offset: 8)), + ), + ); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers.toList(), isEmpty); + }); + }); + + group("does not parse syntax with empty content >", () { + testWidgets("bold", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the trigger characters, without any content between them. + await tester.typeImeText("****"); + + // Ensure we didn't try to parse the trigger characters. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "****"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers, isEmpty); + }); + + testWidgets("italics > single trigger > star", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the trigger characters, without any content between them. + await tester.typeImeText("**"); + + // Ensure we didn't try to parse the trigger characters. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "**"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers, isEmpty); + }); + + testWidgets("italics > tripple trigger > star", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the trigger characters, without any content between them. + await tester.typeImeText("******"); + + // Ensure we didn't try to parse the trigger characters. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "******"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers, isEmpty); + }); + + testWidgets("italics > single trigger > underscore", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the trigger characters, without any content between them. + await tester.typeImeText("__"); + + // Ensure we didn't try to parse the trigger characters. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "__"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers, isEmpty); + }); + + testWidgets("italics > tripple trigger > underscore", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the trigger characters, without any content between them. + await tester.typeImeText("______"); + + // Ensure we didn't try to parse the trigger characters. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "______"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers, isEmpty); + }); + + testWidgets("strikethrough", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the trigger characters, without any content between them. + await tester.typeImeText("~~"); + + // Ensure we didn't try to parse the trigger characters. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "~~"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers, isEmpty); + }); + + testWidgets("code", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the trigger characters, without any content between them. + await tester.typeImeText("``"); + + // Ensure we didn't try to parse the trigger characters. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "``"); + expect(SuperEditorInspector.findTextInComponent(nodeId).spans.markers, isEmpty); + }); + }); + + group("parses Markdown link >", () { + testWidgets("but only when a space follows the syntax", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Enter a link syntax, but no characters after it. + await tester.typeImeText("[google](www.google.com)"); + + // Ensure the syntax wasn't linkified. + var text = SuperEditorInspector.findTextInComponent(nodeId); + expect(text.toPlainText(), "[google](www.google.com)"); + expect(text.getAttributionSpansByFilter((a) => true), isEmpty); + + // Enter a non-space character. + await tester.typeImeText("a"); + + // Ensure we still haven't linkified + text = SuperEditorInspector.findTextInComponent(nodeId); + expect(text.toPlainText(), "[google](www.google.com)a"); + expect(text.getAttributionSpansByFilter((a) => true), isEmpty); + + // Enter a space after the non-space character. + await tester.typeImeText(" "); + + // Ensure we still haven't linkified + text = SuperEditorInspector.findTextInComponent(nodeId); + expect(text.toPlainText(), "[google](www.google.com)a "); + expect(text.getAttributionSpansByFilter((a) => true), isEmpty); + }); + + testWidgets("parses Markdown link syntax and plays nice with built-in linkification reaction", (tester) async { + final (document, _) = await _pumpScaffold(tester); + + final nodeId = document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + await tester.typeImeText("[google](www.google.com) "); + + // Ensure that the Markdown was parsed and replaced with a link. + final text = SuperEditorInspector.findTextInComponent(nodeId); + expect(text.toPlainText(), "google "); + expect(text.getAttributionSpansByFilter((a) => true), { + const AttributionSpan( + attribution: LinkAttribution("www.google.com"), + start: 0, + end: 5, + ), + }); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 7), + ), + ), + ); + }); + }); + }); +} + +Future<(Document, Editor)> _pumpScaffold(WidgetTester tester, [String initialMarkdown = ""]) async { + final document = deserializeMarkdownToDocument(initialMarkdown); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + plugins: { + MarkdownInlineUpstreamSyntaxPlugin(), + }, + ), + ), + ), + ); + + return (document, editor); +} diff --git a/super_editor/test/infrastructure/serialization/markdown/super_editor_markdown_pasting_test.dart b/super_editor/test/infrastructure/serialization/markdown/super_editor_markdown_pasting_test.dart new file mode 100644 index 0000000000..e0fc83fcfd --- /dev/null +++ b/super_editor/test/infrastructure/serialization/markdown/super_editor_markdown_pasting_test.dart @@ -0,0 +1,450 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group("SuperEditor > pasting markdown >", () { + testWidgetsOnArbitraryDesktop("can paste into an empty document", (tester) async { + final document = MutableDocument.empty("1"); + final composer = MutableDocumentComposer(); + final editor = Editor( + editables: { + Editor.documentKey: document, + Editor.composerKey: composer, + }, + requestHandlers: [ + (editor, request) => request is PasteStructuredContentEditorRequest + ? PasteStructuredContentEditorCommand( + content: request.content, + pastePosition: request.pastePosition, + ) + : null, + ...defaultRequestHandlers, + ], + reactionPipeline: List.from(defaultEditorReactions), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + keyboardActions: [ + pasteMarkdownOnCmdAndCtrlV, + ...defaultKeyboardActions, + ], + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + // Place the caret in the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Ensure that the document has the caret. + expect(composer.selection, isNotNull); + + // Simulate the user copying a full markdown document + tester + ..simulateClipboard() + ..setSimulatedClipboardContent(_fullDocumentMarkdown); + + // Paste the markdown content into the empty document. + await tester.pressCmdV(); + + // The editor should now contain a full document that was deserialized + // from pasted markdown. To verify this, re-serialize the document's + // content and compare it to the Markdown that we pasted. + final documentMarkdown = serializeDocumentToMarkdown(document); + + expect(documentMarkdown, _fullDocumentMarkdown); + }); + + testWidgetsOnArbitraryDesktop("can paste at the beginning of a document (without merging text)", (tester) async { + final (editor, document, composer) = await _pumpSuperEditor( + tester, + deserializeMarkdownToDocument(''' +# Primary document + +This is the document that exists before Markdown is pasted. + '''), + ); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph(document.first.id, 0); + + // Ensure that the document has the caret. + expect(composer.selection, isNotNull); + + // Simulate the user copying a markdown snippet. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent(_markdownHeaderSnippet); + + // Paste the markdown content into the empty document. + await tester.pressCmdV(); + + // The editor should now contain the markdown snippet, followed by the + // primary document content. + // + // To verify this, re-serialize the document's content and compare it + // to a Markdown representation of the expected document content. + final documentMarkdown = serializeDocumentToMarkdown(document); + + expect( + documentMarkdown, + '''# A Markdown snippet + +--- +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget. + +# Primary document + +This is the document that exists before Markdown is pasted.''', + ); + }); + + testWidgetsOnArbitraryDesktop("can paste at the beginning of a document (with merging text)", (tester) async { + final (_, document, composer) = await _pumpSuperEditor( + tester, + deserializeMarkdownToDocument(''' +Primary document + +This is the document that exists before Markdown is pasted. + '''), + ); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph(document.first.id, 0); + + // Ensure that the document has the caret. + expect(composer.selection, isNotNull); + + // Simulate the user copying a markdown snippet. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent(_markdownPlainTextSnippet); + + // Paste the markdown content into the empty document. + await tester.pressCmdV(); + + // The editor should now contain the markdown snippet, followed by the + // primary document content. + // + // To verify this, re-serialize the document's content and compare it + // to a Markdown representation of the expected document content. + final documentMarkdown = serializeDocumentToMarkdown(document); + + expect( + documentMarkdown, + '''Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +Phasellus sed sagittis urna. + +Aenean mattis ante justo, quis sollicitudin metus interdum id.Primary document + +This is the document that exists before Markdown is pasted.''', + ); + }); + + testWidgetsOnArbitraryDesktop("can paste in the middle of a document and merge both sides of text", (tester) async { + final (_, document, composer) = await _pumpSuperEditor( + tester, + deserializeMarkdownToDocument('''This is a paragraph that will split >< here and continue.'''), + ); + + // Place the caret between chevrons ">|<" + final lastParagraph = document.last as TextNode; + await tester.placeCaretInParagraph(lastParagraph.id, 37); + + // Ensure that the document has the caret. + expect(composer.selection, isNotNull); + + // Simulate the user copying a markdown snippet. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent(_markdownPlainTextSnippet); + + // Paste the markdown content into the empty document. + await tester.pressCmdV(); + + // Ensure that the pasted text split the existing paragraph and then merged + // the starting and ending text of the pasted Markdown. + final documentMarkdown = serializeDocumentToMarkdown(document); + + expect( + documentMarkdown, + '''This is a paragraph that will split >Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +Phasellus sed sagittis urna. + +Aenean mattis ante justo, quis sollicitudin metus interdum id.< here and continue.''', + ); + }); + + testWidgetsOnArbitraryDesktop("can paste a text snippet within a single paragraph", (tester) async { + final (_, document, composer) = await _pumpSuperEditor( + tester, + deserializeMarkdownToDocument('''This is a paragraph that will add text >< here and continue.'''), + ); + + // Place the caret between chevrons ">|<". + final lastParagraph = document.last as TextNode; + await tester.placeCaretInParagraph(lastParagraph.id, 40); + + // Ensure that the document has the caret. + expect(composer.selection, isNotNull); + + // Simulate the user copying a markdown snippet. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent("this is a snippet of plain text"); + + // Paste the markdown content into the empty document. + await tester.pressCmdV(); + + // Ensure that the pasted text split the existing paragraph and then merged + // the starting and ending text of the pasted Markdown. + final documentMarkdown = serializeDocumentToMarkdown(document); + + expect( + documentMarkdown, + '''This is a paragraph that will add text >this is a snippet of plain text< here and continue.''', + ); + }); + + testWidgetsOnArbitraryDesktop("can paste an image in the middle of a paragraph", (tester) async { + final (_, document, composer) = await _pumpSuperEditor( + tester, + deserializeMarkdownToDocument( + '''This is a paragraph that will split here >< and show an image between paragraphs.''', + ), + ); + + // Place the caret between chevrons ">|<". + final lastParagraph = document.last as TextNode; + await tester.placeCaretInParagraph(lastParagraph.id, 42); + + // Ensure that the document has the caret. + expect(composer.selection, isNotNull); + + // Simulate the user copying a markdown snippet. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent("![A Fake Test Image](https://flutter.dev/logo.png)"); + + // Paste the markdown content into the empty document. + await tester.pressCmdV(); + + // Ensure that the pasted text split the existing paragraph and then inserted + // an image in between. + final documentMarkdown = serializeDocumentToMarkdown(document); + + expect( + documentMarkdown, + '''This is a paragraph that will split here > + +![A Fake Test Image](https://flutter.dev/logo.png) +< and show an image between paragraphs.''', + ); + }); + + testWidgetsOnArbitraryDesktop("can paste at the end of a document (without merging text)", (tester) async { + final (_, document, composer) = await _pumpSuperEditor( + tester, + deserializeMarkdownToDocument(''' +# Primary document + +This is the document that exists before Markdown is pasted. + '''), + ); + + // Place the caret at the end of the existing document. + final lastParagraph = document.last as TextNode; + await tester.placeCaretInParagraph(lastParagraph.id, lastParagraph.endPosition.offset); + + // Ensure that the document has the caret. + expect(composer.selection, isNotNull); + + // Simulate the user copying a markdown snippet. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent(_markdownHeaderSnippet); + + // Paste the markdown content into the empty document. + await tester.pressCmdV(); + + // The editor should now contain the primary document content, followed by + // the Markdown snippet. + // + // To verify this, re-serialize the document's content and compare it + // to a Markdown representation of the expected document content. + final documentMarkdown = serializeDocumentToMarkdown(document); + + expect( + documentMarkdown, + '''# Primary document + +This is the document that exists before Markdown is pasted. + +# A Markdown snippet + +--- +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.''', + ); + }); + + testWidgetsOnArbitraryDesktop("can paste at the end of a document (with merging text)", (tester) async { + final (_, document, composer) = await _pumpSuperEditor( + tester, + deserializeMarkdownToDocument(''' +# Primary document + +This is the document that exists before Markdown is pasted. + '''), + ); + + // Place the caret at the end of the existing document. + final lastParagraph = document.last as TextNode; + await tester.placeCaretInParagraph(lastParagraph.id, lastParagraph.endPosition.offset); + + // Ensure that the document has the caret. + expect(composer.selection, isNotNull); + + // Simulate the user copying a markdown snippet. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent(_markdownPlainTextSnippet); + + // Paste the markdown content into the empty document. + await tester.pressCmdV(); + + // The editor should now contain the primary document content, followed by + // the Markdown snippet. + // + // To verify this, re-serialize the document's content and compare it + // to a Markdown representation of the expected document content. + final documentMarkdown = serializeDocumentToMarkdown(document); + + expect( + documentMarkdown, + '''# Primary document + +This is the document that exists before Markdown is pasted.Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +Phasellus sed sagittis urna. + +Aenean mattis ante justo, quis sollicitudin metus interdum id.''', + ); + }); + + testWidgetsOnMac("can paste a link", (tester) async { + final (_, document, _) = await _pumpSuperEditor( + tester, + deserializeMarkdownToDocument(""), + ); + + // Place the caret in empty paragraph. + final paragraph = document.first as TextNode; + await tester.placeCaretInParagraph(paragraph.id, 0); + + // Simulate the user copying a markdown snippet. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent("Hello [link](www.google.com)"); + + // Paste the markdown content into the empty document. + await tester.pressCmdV(); + + // Ensure that the Markdown link was linkified. + expect(SuperEditorInspector.findTextInComponent(paragraph.id).toPlainText(), "Hello link"); + const expectedAttribution = LinkAttribution("www.google.com"); + expect(SuperEditorInspector.findTextInComponent(paragraph.id).getAttributionSpansByFilter((a) => true), { + const AttributionSpan(attribution: expectedAttribution, start: 6, end: 9), + }); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraph.id, + nodePosition: const TextNodePosition(offset: 10), + ), + ), + ); + }); + }); +} + +/// Pumps a [SuperEditor], which displays the given [document], including typical Markdown +/// extensions, and extensions to paste Markdown. +Future<(Editor, MutableDocument, MutableDocumentComposer)> _pumpSuperEditor( + WidgetTester tester, MutableDocument document) async { + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + keyboardActions: [ + pasteMarkdownOnCmdAndCtrlV, + ...defaultKeyboardActions, + ], + componentBuilders: [ + TaskComponentBuilder(editor), + const FakeImageComponentBuilder(size: Size(800, 400)), // Size doesn't matter. + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + return (editor, document, composer); +} + +const _markdownHeaderSnippet = ''' +# A Markdown snippet + +--- +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget. +'''; + +const _markdownPlainTextSnippet = ''' +Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +Phasellus sed sagittis urna. + +Aenean mattis ante justo, quis sollicitudin metus interdum id. +'''; + +const _fullDocumentMarkdown = ''' +# Example Document + +--- +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget. + + * This is an unordered list item + * This is another list item + * This is a 3rd list item, with [a link](https://flutter.dev) + +Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris. + + 1. First thing to do + 1. Second thing to do + 1. Third thing to do + +Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio. + +Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo. + +- [ ] This is an incomplete task +- [x] This is a completed task'''; diff --git a/super_editor/test/infrastructure/serialization/markdown/super_editor_markdown_test.dart b/super_editor/test/infrastructure/serialization/markdown/super_editor_markdown_test.dart new file mode 100644 index 0000000000..24cfb74426 --- /dev/null +++ b/super_editor/test/infrastructure/serialization/markdown/super_editor_markdown_test.dart @@ -0,0 +1,1839 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group('Markdown', () { + group('serialization', () { + test('headers', () { + final paragraph = ParagraphNode( + id: '1', + text: AttributedText('My Header'), + ); + + expect( + serializeDocumentToMarkdown( + MutableDocument( + nodes: [ + paragraph.copyParagraphWith( + metadata: const { + "blockType": header1Attribution, + }, + ), + ], + ), + ), + '# My Header', + ); + + expect( + serializeDocumentToMarkdown( + MutableDocument( + nodes: [ + paragraph.copyParagraphWith( + metadata: const { + "blockType": header2Attribution, + }, + ), + ], + ), + ), + '## My Header', + ); + + expect( + serializeDocumentToMarkdown( + MutableDocument( + nodes: [ + paragraph.copyParagraphWith( + metadata: const { + "blockType": header3Attribution, + }, + ), + ], + ), + ), + '### My Header', + ); + + expect( + serializeDocumentToMarkdown( + MutableDocument( + nodes: [ + paragraph.copyParagraphWith( + metadata: const { + "blockType": header4Attribution, + }, + ), + ], + ), + ), + '#### My Header', + ); + + expect( + serializeDocumentToMarkdown( + MutableDocument( + nodes: [ + paragraph.copyParagraphWith( + metadata: const { + "blockType": header5Attribution, + }, + ), + ], + ), + ), + '##### My Header', + ); + + expect( + serializeDocumentToMarkdown( + MutableDocument( + nodes: [ + paragraph.copyParagraphWith( + metadata: const { + "blockType": header6Attribution, + }, + ), + ], + ), + ), + '###### My Header', + ); + }); + + test('header with left alignment', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Header1'), + metadata: const { + 'textAlign': 'left', + 'blockType': header1Attribution, + }, + ), + ]); + // Even when using superEditor markdown syntax, which has support + // for text alignment, we don't add an alignment token when + // the paragraph is left-aligned. + // Paragraphs are left-aligned by default, so it isn't necessary + // to serialize the alignment token. + expect(serializeDocumentToMarkdown(doc), '# Header1'); + }); + + test('header with center alignment', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Header1'), + metadata: const { + 'textAlign': 'center', + 'blockType': header1Attribution, + }, + ), + ]); + expect(serializeDocumentToMarkdown(doc), ':---:\n# Header1'); + }); + + test('header with right alignment', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Header1'), + metadata: const { + 'textAlign': 'right', + 'blockType': header1Attribution, + }, + ), + ]); + expect(serializeDocumentToMarkdown(doc), '---:\n# Header1'); + }); + + test('header with justify alignment', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Header1'), + metadata: const { + 'textAlign': 'justify', + 'blockType': header1Attribution, + }, + ), + ]); + expect(serializeDocumentToMarkdown(doc), '-::-\n# Header1'); + }); + + test('header with styles', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown("My **Header**"), + metadata: const {'blockType': header1Attribution}, + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '# My **Header**'); + }); + + test('blockquote', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is a blockquote'), + metadata: const {'blockType': blockquoteAttribution}, + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '> This is a blockquote'); + }); + + test('blockquote with styles', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('This is a **blockquote**'), + metadata: const {'blockType': blockquoteAttribution}, + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '> This is a **blockquote**'); + }); + + test('code', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is some code'), + metadata: const {'blockType': codeAttribution}, + ), + ]); + + expect( + serializeDocumentToMarkdown(doc), + ''' +``` +This is some code +```''', + ); + }); + + test('paragraph', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is a paragraph.'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), 'This is a paragraph.'); + }); + + test('paragraph with one inline style', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('This **is a** paragraph.'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), 'This **is a** paragraph.'); + }); + + test('paragraph with overlapping bold and italics', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('This ***is a*** paragraph.'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), 'This ***is a*** paragraph.'); + }); + + test('paragraph with non-overlapping bold and italics', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('**This is** *a paragraph.*'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '**This is** *a paragraph.*'); + }); + + test('paragraph with intersecting bold and italics', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('This ***is a** paragraph*.'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), 'This ***is a** paragraph*.'); + }); + + test('paragraph with overlapping code and bold', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + // TODO: get code syntax to work + // text: attributedTextFromMarkdown('This `**is a**` paragraph.'), + text: AttributedText( + 'This is a paragraph.', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 5, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), + const SpanMarker(attribution: codeAttribution, offset: 5, markerType: SpanMarkerType.start), + const SpanMarker(attribution: codeAttribution, offset: 8, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), 'This `**is a**` paragraph.'); + }); + + test('paragraph with link', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('This is a [paragraph](https://example.org).'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), 'This is a [paragraph](https://example.org).'); + }); + + test('paragraph with link overlapping style', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('This is a [**paragraph**](https://example.org).'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), 'This is a [**paragraph**](https://example.org).'); + }); + + test('paragraph with link intersecting style', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('[This **is a** paragraph](https://example.org).'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '[This **is a** paragraph](https://example.org).'); + }); + + test('paragraph with underline', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('This is a ¬paragraph¬.'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), 'This is a ¬paragraph¬.'); + }); + + test('paragraph with strikethrough', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('This is a ~paragraph~.'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), 'This is a ~paragraph~.'); + }); + + test('paragraph with consecutive links', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: attributedTextFromMarkdown('[First Link](https://example.org)[Second Link](https://github.com)'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '[First Link](https://example.org)[Second Link](https://github.com)'); + }); + + test('paragraph with left alignment', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Paragraph1'), + metadata: const { + 'textAlign': 'left', + }, + ), + ]); + + // Even when using superEditor markdown syntax, which has support + // for text alignment, we don't add an alignment token when + // the paragraph is left-aligned. + // Paragraphs are left-aligned by default, so it isn't necessary + // to serialize the alignment token. + expect(serializeDocumentToMarkdown(doc), 'Paragraph1'); + }); + + test('paragraph with center alignment', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Paragraph1'), + metadata: const { + 'textAlign': 'center', + }, + ), + ]); + + expect(serializeDocumentToMarkdown(doc), ':---:\nParagraph1'); + }); + + test('paragraph with right alignment', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Paragraph1'), + metadata: const { + 'textAlign': 'right', + }, + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '---:\nParagraph1'); + }); + + test('paragraph with justify alignment', () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Paragraph1'), + metadata: const { + 'textAlign': 'justify', + }, + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '-::-\nParagraph1'); + }); + + test("doesn't serialize text alignment when not using supereditor syntax", () { + final doc = MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Paragraph1'), + metadata: const { + 'textAlign': 'center', + }, + ), + ]); + + expect(serializeDocumentToMarkdown(doc, syntax: MarkdownSyntax.normal), 'Paragraph1'); + }); + + test('empty paragraph', () { + final serialized = serializeDocumentToMarkdown( + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('Paragraph1')), + ParagraphNode(id: '2', text: AttributedText('')), + ParagraphNode(id: '3', text: AttributedText('Paragraph3')), + ]), + ); + + expect(serialized, """Paragraph1 + + + +Paragraph3"""); + }); + + test('removes all text attributions when serializing an empty paragraph', () { + final serialized = serializeDocumentToMarkdown( + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('Paragraph1')), + ParagraphNode( + id: '2', + text: AttributedText( + '', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ParagraphNode( + id: '3', + text: AttributedText( + '', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ]), + ); + + // Ensure the attributions were ignored for the empty paragraphs. + expect(serialized, """Paragraph1 + + + +"""); + }); + + test('separates multiple paragraphs with blank lines', () { + final serialized = serializeDocumentToMarkdown( + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('Paragraph1')), + ParagraphNode(id: '2', text: AttributedText('Paragraph2')), + ParagraphNode(id: '3', text: AttributedText('Paragraph3')), + ]), + ); + + expect(serialized, """Paragraph1 + +Paragraph2 + +Paragraph3"""); + }); + + test('separates paragraph from other blocks with blank lines', () { + final serialized = serializeDocumentToMarkdown( + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('First Paragraph')), + HorizontalRuleNode(id: '2'), + ]), + ); + + expect(serialized, 'First Paragraph\n\n---'); + }); + + test('preserves linebreaks at the end of a paragraph', () { + final serialized = serializeDocumentToMarkdown( + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('Paragraph1\n\n')), + ParagraphNode(id: '2', text: AttributedText('Paragraph2')), + ]), + ); + + expect(serialized, 'Paragraph1 \n \n\n\nParagraph2'); + }); + + test('preserves linebreaks within a paragraph', () { + final serialized = serializeDocumentToMarkdown( + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('Line1\n\nLine2')), + ]), + ); + + expect(serialized, 'Line1 \n \nLine2'); + }); + + test('preserves linebreaks at the beginning of a paragraph', () { + final serialized = serializeDocumentToMarkdown( + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('\n\nParagraph1')), + ParagraphNode(id: '2', text: AttributedText('Paragraph2')), + ]), + ); + + expect(serialized, ' \n \nParagraph1\n\nParagraph2'); + }); + + test('image', () { + final doc = MutableDocument(nodes: [ + ImageNode( + id: '1', + imageUrl: 'https://someimage.com/the/image.png', + altText: 'some alt text', + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '![some alt text](https://someimage.com/the/image.png)'); + }); + + test('image with size', () { + final doc = MutableDocument(nodes: [ + ImageNode( + id: '1', + imageUrl: 'https://someimage.com/the/image.png', + altText: 'some alt text', + expectedBitmapSize: const ExpectedSize(500, 400), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '![some alt text](https://someimage.com/the/image.png =500x400)'); + }); + + test('image with width', () { + final doc = MutableDocument(nodes: [ + ImageNode( + id: '1', + imageUrl: 'https://someimage.com/the/image.png', + altText: 'some alt text', + expectedBitmapSize: const ExpectedSize(300, null), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '![some alt text](https://someimage.com/the/image.png =300x)'); + }); + + test('image with height', () { + final doc = MutableDocument(nodes: [ + ImageNode( + id: '1', + imageUrl: 'https://someimage.com/the/image.png', + altText: 'some alt text', + expectedBitmapSize: const ExpectedSize(null, 200), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '![some alt text](https://someimage.com/the/image.png =x200)'); + }); + + test('horizontal rule', () { + final doc = MutableDocument(nodes: [ + HorizontalRuleNode( + id: '1', + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '---'); + }); + + test('unordered list items', () { + final doc = MutableDocument(nodes: [ + ListItemNode( + id: '1', + itemType: ListItemType.unordered, + text: AttributedText('Unordered 1'), + ), + ListItemNode( + id: '2', + itemType: ListItemType.unordered, + text: AttributedText('Unordered 2'), + ), + ListItemNode( + id: '3', + itemType: ListItemType.unordered, + indent: 1, + text: AttributedText('Unordered 2.1'), + ), + ListItemNode( + id: '4', + itemType: ListItemType.unordered, + indent: 1, + text: AttributedText('Unordered 2.2'), + ), + ListItemNode( + id: '5', + itemType: ListItemType.unordered, + text: AttributedText('Unordered 3'), + ), + ]); + + expect( + serializeDocumentToMarkdown(doc), + ''' + * Unordered 1 + * Unordered 2 + * Unordered 2.1 + * Unordered 2.2 + * Unordered 3''', + ); + }); + + test('unordered list item with styles', () { + final doc = MutableDocument(nodes: [ + ListItemNode( + id: '1', + itemType: ListItemType.unordered, + text: attributedTextFromMarkdown('**Unordered** 1'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), ' * **Unordered** 1'); + }); + + test('ordered list items', () { + final doc = MutableDocument(nodes: [ + ListItemNode( + id: '1', + itemType: ListItemType.ordered, + text: AttributedText('Ordered 1'), + ), + ListItemNode( + id: '2', + itemType: ListItemType.ordered, + text: AttributedText('Ordered 2'), + ), + ListItemNode( + id: '3', + itemType: ListItemType.ordered, + indent: 1, + text: AttributedText('Ordered 2.1'), + ), + ListItemNode( + id: '4', + itemType: ListItemType.ordered, + indent: 1, + text: AttributedText('Ordered 2.2'), + ), + ListItemNode( + id: '5', + itemType: ListItemType.ordered, + text: AttributedText('Ordered 3'), + ), + ]); + + expect( + serializeDocumentToMarkdown(doc), + ''' + 1. Ordered 1 + 1. Ordered 2 + 1. Ordered 2.1 + 1. Ordered 2.2 + 1. Ordered 3''', + ); + }); + + test('ordered list item with styles', () { + final doc = MutableDocument(nodes: [ + ListItemNode( + id: '1', + itemType: ListItemType.ordered, + text: attributedTextFromMarkdown('**Ordered** 1'), + ), + ]); + + expect(serializeDocumentToMarkdown(doc), ' 1. **Ordered** 1'); + }); + + test('tasks', () { + final doc = MutableDocument( + nodes: [ + TaskNode( + id: '1', + text: AttributedText('Task 1'), + isComplete: true, + ), + TaskNode( + id: '2', + text: AttributedText('Task 2\nwith multiple lines'), + isComplete: false, + ), + TaskNode( + id: '3', + text: AttributedText('Task 3'), + isComplete: false, + ), + TaskNode( + id: '4', + text: AttributedText('Task 4'), + isComplete: true, + ), + ], + ); + + expect( + serializeDocumentToMarkdown(doc), + ''' +- [x] Task 1 +- [ ] Task 2 +with multiple lines +- [ ] Task 3 +- [x] Task 4''', + ); + }); + + test('task with styles', () { + final doc = MutableDocument(nodes: [ + TaskNode( + id: '1', + text: attributedTextFromMarkdown('**Task** 1'), + isComplete: false, + ), + ]); + + expect(serializeDocumentToMarkdown(doc), '- [ ] **Task** 1'); + }); + + test('example doc', () { + final doc = MutableDocument(nodes: [ + ImageNode( + id: Editor.createNodeId(), + imageUrl: 'https://someimage.com/the/image.png', + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Example Doc'), + metadata: const {'blockType': header1Attribution}, + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Example Doc With Left Alignment'), + metadata: const {'blockType': header1Attribution, 'textAlign': 'left'}, + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Example Doc With Center Alignment'), + metadata: const {'blockType': header1Attribution, 'textAlign': 'center'}, + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Example Doc With Right Alignment'), + metadata: const {'blockType': header1Attribution, 'textAlign': 'right'}, + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Example Doc With Justify Alignment'), + metadata: const {'blockType': header1Attribution, 'textAlign': 'justify'}, + ), + HorizontalRuleNode(id: Editor.createNodeId()), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Unordered list:'), + ), + ListItemNode( + id: Editor.createNodeId(), + itemType: ListItemType.unordered, + text: AttributedText('Unordered 1'), + ), + ListItemNode( + id: Editor.createNodeId(), + itemType: ListItemType.unordered, + text: AttributedText('Unordered 2'), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Ordered list:'), + ), + ListItemNode( + id: Editor.createNodeId(), + itemType: ListItemType.ordered, + text: AttributedText('Ordered 1'), + ), + ListItemNode( + id: Editor.createNodeId(), + itemType: ListItemType.ordered, + text: AttributedText('Ordered 2'), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('A blockquote:'), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('This is a blockquote.'), + metadata: const {'blockType': blockquoteAttribution}, + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Some code:'), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('{\n // This is some code.\n}'), + metadata: const {'blockType': codeAttribution}, + ), + TaskNode( + id: Editor.createNodeId(), + text: AttributedText('Task 1'), + isComplete: true, + ), + TaskNode( + id: Editor.createNodeId(), + text: AttributedText('Task 2\nwith multiple lines'), + isComplete: false, + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('A paragraph between tasks'), + ), + TaskNode( + id: Editor.createNodeId(), + text: AttributedText('Task 3'), + isComplete: false, + ), + TaskNode( + id: Editor.createNodeId(), + text: AttributedText('Task 4\nwith multiple lines'), + isComplete: true, + ), + ]); + + // Ensure that the document serializes. We don't bother with + // validating the output because other tests should validate + // the per-node serializations. + + // ignore: unused_local_variable + final markdown = serializeDocumentToMarkdown(doc); + }); + + test("doesn't add empty lines at the end of the document", () { + final serialized = serializeDocumentToMarkdown( + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('Paragraph1')), + ]), + ); + + expect(serialized, 'Paragraph1'); + }); + }); + + group('deserialization', () { + test('headers', () { + final header1Doc = deserializeMarkdownToDocument('# Header 1'); + expect((header1Doc.first as ParagraphNode).getMetadataValue('blockType'), header1Attribution); + + final header2Doc = deserializeMarkdownToDocument('## Header 2'); + expect((header2Doc.first as ParagraphNode).getMetadataValue('blockType'), header2Attribution); + + final header3Doc = deserializeMarkdownToDocument('### Header 3'); + expect((header3Doc.first as ParagraphNode).getMetadataValue('blockType'), header3Attribution); + + final header4Doc = deserializeMarkdownToDocument('#### Header 4'); + expect((header4Doc.first as ParagraphNode).getMetadataValue('blockType'), header4Attribution); + + final header5Doc = deserializeMarkdownToDocument('##### Header 5'); + expect((header5Doc.first as ParagraphNode).getMetadataValue('blockType'), header5Attribution); + + final header6Doc = deserializeMarkdownToDocument('###### Header 6'); + expect((header6Doc.first as ParagraphNode).getMetadataValue('blockType'), header6Attribution); + }); + + test('header with left alignment', () { + final headerLeftAlignment1 = deserializeMarkdownToDocument(':---\n# Header 1'); + final header = headerLeftAlignment1.first as ParagraphNode; + expect(header.getMetadataValue('blockType'), header1Attribution); + expect(header.getMetadataValue('textAlign'), 'left'); + expect(header.text.toPlainText(), 'Header 1'); + }); + + test('header with center alignment', () { + final headerLeftAlignment1 = deserializeMarkdownToDocument(':---:\n# Header 1'); + final header = headerLeftAlignment1.first as ParagraphNode; + expect(header.getMetadataValue('blockType'), header1Attribution); + expect(header.getMetadataValue('textAlign'), 'center'); + expect(header.text.toPlainText(), 'Header 1'); + }); + + test('header with right alignment', () { + final headerLeftAlignment1 = deserializeMarkdownToDocument('---:\n# Header 1'); + final header = headerLeftAlignment1.first as ParagraphNode; + expect(header.getMetadataValue('blockType'), header1Attribution); + expect(header.getMetadataValue('textAlign'), 'right'); + expect(header.text.toPlainText(), 'Header 1'); + }); + + test('header with justify alignment', () { + final headerLeftAlignment1 = deserializeMarkdownToDocument('-::-\n# Header 1'); + final header = headerLeftAlignment1.first as ParagraphNode; + expect(header.getMetadataValue('blockType'), header1Attribution); + expect(header.getMetadataValue('textAlign'), 'justify'); + expect(header.text.toPlainText(), 'Header 1'); + }); + + test('blockquote', () { + final blockquoteDoc = deserializeMarkdownToDocument('> This is a blockquote'); + + final blockquote = blockquoteDoc.first as ParagraphNode; + expect(blockquote.getMetadataValue('blockType'), blockquoteAttribution); + expect(blockquote.text.toPlainText(), 'This is a blockquote'); + }); + + test('code block', () { + final codeBlockDoc = deserializeMarkdownToDocument(''' +``` +This is some code +```'''); + + final code = codeBlockDoc.first as ParagraphNode; + expect(code.getMetadataValue('blockType'), codeAttribution); + expect(code.text.toPlainText(), 'This is some code\n'); + }); + + test('image', () { + final codeBlockDoc = deserializeMarkdownToDocument('![Image alt text](https://images.com/some/image.png)'); + + final image = codeBlockDoc.first as ImageNode; + expect(image.imageUrl, 'https://images.com/some/image.png'); + expect(image.altText, 'Image alt text'); + expect(image.expectedBitmapSize, isNull); + }); + + test('image with size', () { + final codeBlockDoc = + deserializeMarkdownToDocument('![Image alt text](https://images.com/some/image.png =500x200)'); + + final image = codeBlockDoc.first as ImageNode; + expect(image.imageUrl, 'https://images.com/some/image.png'); + expect(image.altText, 'Image alt text'); + expect(image.expectedBitmapSize?.width, 500.0); + expect(image.expectedBitmapSize?.height, 200.0); + }); + + test('image with size and title', () { + final codeBlockDoc = deserializeMarkdownToDocument( + '![Image alt text](https://images.com/some/image.png =500x200 "image title")'); + + final image = codeBlockDoc.first as ImageNode; + expect(image.imageUrl, 'https://images.com/some/image.png'); + expect(image.altText, 'Image alt text'); + expect(image.expectedBitmapSize?.width, 500.0); + expect(image.expectedBitmapSize?.height, 200.0); + }); + + test('image with width', () { + final codeBlockDoc = + deserializeMarkdownToDocument('![Image alt text](https://images.com/some/image.png =500x)'); + + final image = codeBlockDoc.first as ImageNode; + expect(image.imageUrl, 'https://images.com/some/image.png'); + expect(image.altText, 'Image alt text'); + expect(image.expectedBitmapSize?.width, 500.0); + expect(image.expectedBitmapSize?.height, isNull); + }); + + test('image with height', () { + final codeBlockDoc = + deserializeMarkdownToDocument('![Image alt text](https://images.com/some/image.png =x200)'); + + final image = codeBlockDoc.first as ImageNode; + expect(image.imageUrl, 'https://images.com/some/image.png'); + expect(image.altText, 'Image alt text'); + expect(image.expectedBitmapSize?.width, isNull); + expect(image.expectedBitmapSize?.height, 200.0); + }); + + test('image with size notation without width and height', () { + final codeBlockDoc = deserializeMarkdownToDocument('![Image alt text](https://images.com/some/image.png =x)'); + + final image = codeBlockDoc.first as ImageNode; + expect(image.imageUrl, 'https://images.com/some/image.png'); + expect(image.altText, 'Image alt text'); + expect(image.expectedBitmapSize?.width, isNull); + expect(image.expectedBitmapSize?.height, isNull); + }); + + test('image with incomplete size notation', () { + final codeBlockDoc = deserializeMarkdownToDocument('![Image alt text](https://images.com/some/image.png =)'); + + final image = codeBlockDoc.first as ImageNode; + expect(image.imageUrl, 'https://images.com/some/image.png'); + expect(image.altText, 'Image alt text'); + expect(image.expectedBitmapSize?.width, isNull); + expect(image.expectedBitmapSize?.height, isNull); + }); + + test('single unstyled paragraph', () { + const markdown = 'This is some unstyled text to parse as markdown'; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + + final paragraph = document.first as ParagraphNode; + expect(paragraph.text.toPlainText(), 'This is some unstyled text to parse as markdown'); + }); + + test('single styled paragraph', () { + const markdown = 'This is **some *styled*** text to parse as [markdown](https://example.org)'; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + + final paragraph = document.first as ParagraphNode; + final styledText = paragraph.text; + expect(styledText.toPlainText(), 'This is some styled text to parse as markdown'); + + expect(styledText.getAllAttributionsAt(0).isEmpty, true); + expect(styledText.getAllAttributionsAt(8).contains(boldAttribution), true); + expect(styledText.getAllAttributionsAt(13).containsAll([boldAttribution, italicsAttribution]), true); + expect(styledText.getAllAttributionsAt(19).isEmpty, true); + expect(styledText.getAllAttributionsAt(40).single, LinkAttribution.fromUri(Uri.https('example.org', ''))); + }); + + test('paragraph with special HTML symbols keeps the symbols by default', () { + const markdown = 'Preserves symbols like &, <, and >, rather than use HTML escape codes.'; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + + final paragraph = document.first as ParagraphNode; + final styledText = paragraph.text; + expect(styledText.toPlainText(), 'Preserves symbols like &, <, and >, rather than use HTML escape codes.'); + }); + + test('paragraph with special HTML symbols can escape them', () { + const markdown = 'Escapes HTML symbols like &, <, and >, when requested.'; + + final document = deserializeMarkdownToDocument(markdown, encodeHtml: true); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + + final paragraph = document.first as ParagraphNode; + final styledText = paragraph.text; + expect(styledText.toPlainText(), 'Escapes HTML symbols like &, <, and >, when requested.'); + }); + + test('link within multiple styles', () { + const markdown = 'This is **some *styled [link](https://example.org) text***'; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + + final paragraph = document.first as ParagraphNode; + final styledText = paragraph.text; + expect(styledText.toPlainText(), 'This is some styled link text'); + + expect(styledText.getAllAttributionsAt(0).isEmpty, true); + expect(styledText.getAllAttributionsAt(8).contains(boldAttribution), true); + expect(styledText.getAllAttributionsAt(13).containsAll([boldAttribution, italicsAttribution]), true); + expect( + styledText.getAllAttributionsAt(20).containsAll( + [boldAttribution, italicsAttribution, LinkAttribution.fromUri(Uri.https('example.org', ''))]), + true); + expect(styledText.getAllAttributionsAt(25).containsAll([boldAttribution, italicsAttribution]), true); + }); + + test('completely overlapping link and style', () { + const markdown = 'This is **[a test](https://example.org)**'; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + + final paragraph = document.first as ParagraphNode; + final styledText = paragraph.text; + expect(styledText.toPlainText(), 'This is a test'); + + expect(styledText.getAllAttributionsAt(0).isEmpty, true); + expect(styledText.getAllAttributionsAt(8).contains(boldAttribution), true); + expect( + styledText + .getAllAttributionsAt(13) + .containsAll([boldAttribution, LinkAttribution.fromUri(Uri.https('example.org', ''))]), + true); + }); + + test('single style intersecting link', () { + // This isn't necessarily the behavior that you would expect, but it has been tested against multiple Markdown + // renderers (such as VS Code) and it matches their behaviour. + const markdown = 'This **is [a** link](https://example.org) test'; + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + + final paragraph = document.first as ParagraphNode; + final styledText = paragraph.text; + expect(styledText.toPlainText(), 'This **is a** link test'); + + expect(styledText.getAllAttributionsAt(9).isEmpty, true); + expect(styledText.getAllAttributionsAt(12).single, LinkAttribution.fromUri(Uri.https('example.org', ''))); + }); + + test('empty link', () { + // This isn't necessarily the behavior that you would expect, but it has been tested against multiple Markdown + // renderers (such as VS Code) and it matches their behaviour. + const markdown = 'This is [a link]() test'; + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + + final paragraph = document.first as ParagraphNode; + final styledText = paragraph.text; + expect(styledText.toPlainText(), 'This is a link test'); + + expect(styledText.getAllAttributionsAt(12).single, LinkAttribution.fromUri(Uri.parse(''))); + }); + + test('unordered list', () { + const markdown = ''' + * list item 1 + * list item 2 + * list item 2.1 + * list item 2.2 + * list item 3'''; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 5); + for (final node in document) { + expect(node, isA()); + expect((node as ListItemNode).type, ListItemType.unordered); + } + + expect((document.getNodeAt(0)! as ListItemNode).indent, 0); + expect((document.getNodeAt(0)! as ListItemNode).text.toPlainText(), 'list item 1'); + + expect((document.getNodeAt(1)! as ListItemNode).indent, 0); + expect((document.getNodeAt(1)! as ListItemNode).text.toPlainText(), 'list item 2'); + + expect((document.getNodeAt(2)! as ListItemNode).indent, 1); + expect((document.getNodeAt(2)! as ListItemNode).text.toPlainText(), 'list item 2.1'); + + expect((document.getNodeAt(3)! as ListItemNode).indent, 1); + expect((document.getNodeAt(3)! as ListItemNode).text.toPlainText(), 'list item 2.2'); + + expect((document.getNodeAt(4)! as ListItemNode).indent, 0); + expect((document.getNodeAt(4)! as ListItemNode).text.toPlainText(), 'list item 3'); + }); + + test('empty unordered list item', () { + const markdown = '* '; + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect((document.first as ListItemNode).type, ListItemType.unordered); + expect((document.first as ListItemNode).text.toPlainText(), isEmpty); + }); + + test('unordered list followed by empty list item', () { + const markdown = """- list item 1 +- """; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 2); + + expect(document.getNodeAt(0)!, isA()); + expect((document.getNodeAt(0)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(0)! as ListItemNode).text.toPlainText(), 'list item 1'); + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(1)! as ListItemNode).text.toPlainText(), ''); + }); + + test('parses mixed unordered and ordered items', () { + const markdown = """ +1. Ordered 1 + - Unordered 1 + - Unordered 2 + +2. Ordered 2 + - Unordered 1 + - Unordered 2 + +3. Ordered 3 + - Unordered 1 + - Unordered 2"""; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 9); + for (final node in document) { + expect(node, isA()); + } + + expect((document.getNodeAt(0)! as ListItemNode).type, ListItemType.ordered); + expect((document.getNodeAt(0)! as ListItemNode).text.toPlainText(), 'Ordered 1'); + + expect((document.getNodeAt(1)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(1)! as ListItemNode).text.toPlainText(), 'Unordered 1'); + + expect((document.getNodeAt(2)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(2)! as ListItemNode).text.toPlainText(), 'Unordered 2'); + + expect((document.getNodeAt(3)! as ListItemNode).type, ListItemType.ordered); + expect((document.getNodeAt(3)! as ListItemNode).text.toPlainText(), 'Ordered 2'); + + expect((document.getNodeAt(4)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(4)! as ListItemNode).text.toPlainText(), 'Unordered 1'); + + expect((document.getNodeAt(5)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(5)! as ListItemNode).text.toPlainText(), 'Unordered 2'); + + expect((document.getNodeAt(6)! as ListItemNode).type, ListItemType.ordered); + expect((document.getNodeAt(6)! as ListItemNode).text.toPlainText(), 'Ordered 3'); + + expect((document.getNodeAt(7)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(7)! as ListItemNode).text.toPlainText(), 'Unordered 1'); + + expect((document.getNodeAt(8)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(8)! as ListItemNode).text.toPlainText(), 'Unordered 2'); + }); + + test('unordered list with empty lines between items', () { + const markdown = ''' + * list item 1 + + * list item 2 + + * list item 3'''; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 3); + for (final node in document) { + expect(node, isA()); + expect((node as ListItemNode).type, ListItemType.unordered); + } + + expect((document.getNodeAt(0)! as ListItemNode).text.toPlainText(), 'list item 1'); + expect((document.getNodeAt(1)! as ListItemNode).text.toPlainText(), 'list item 2'); + expect((document.getNodeAt(2)! as ListItemNode).text.toPlainText(), 'list item 3'); + }); + + test('unordered list items mixed with task items', () { + const markdown = ''' +- list item node +- [ ] task node +- [x] completed task node +- second list item node +- [ ] another task node +- third list item node +- fourth list item node +'''; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 7); + expect(document.getNodeAt(0)!, isA()); + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1) as TaskNode).text.toPlainText(), 'task node'); + expect((document.getNodeAt(1) as TaskNode).isComplete, isFalse); + expect(document.getNodeAt(2)!, isA()); + expect((document.getNodeAt(2) as TaskNode).text.toPlainText(), 'completed task node'); + expect((document.getNodeAt(2) as TaskNode).isComplete, isTrue); + expect(document.getNodeAt(3)!, isA()); + expect(document.getNodeAt(4)!, isA()); + expect((document.getNodeAt(4) as TaskNode).text.toPlainText(), 'another task node'); + expect((document.getNodeAt(4) as TaskNode).isComplete, isFalse); + expect(document.getNodeAt(5)!, isA()); + expect(document.getNodeAt(6)!, isA()); + }); + + test('ordered list', () { + const markdown = ''' + 1. list item 1 + 1. list item 2 + 1. list item 2.1 + 1. list item 2.2 + 1. list item 3'''; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 5); + for (final node in document) { + expect(node, isA()); + expect((node as ListItemNode).type, ListItemType.ordered); + } + + expect((document.getNodeAt(0)! as ListItemNode).indent, 0); + expect((document.getNodeAt(0)! as ListItemNode).text.toPlainText(), 'list item 1'); + + expect((document.getNodeAt(1)! as ListItemNode).indent, 0); + expect((document.getNodeAt(1)! as ListItemNode).text.toPlainText(), 'list item 2'); + + expect((document.getNodeAt(2)! as ListItemNode).indent, 1); + expect((document.getNodeAt(2)! as ListItemNode).text.toPlainText(), 'list item 2.1'); + + expect((document.getNodeAt(3)! as ListItemNode).indent, 1); + expect((document.getNodeAt(3)! as ListItemNode).text.toPlainText(), 'list item 2.2'); + + expect((document.getNodeAt(4)! as ListItemNode).indent, 0); + expect((document.getNodeAt(4)! as ListItemNode).text.toPlainText(), 'list item 3'); + }); + + test('empty ordered list item', () { + const markdown = '1. '; + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect((document.first as ListItemNode).type, ListItemType.ordered); + expect((document.first as ListItemNode).text.toPlainText(), isEmpty); + }); + + test('ordered list with empty lines between items', () { + const markdown = ''' + 1. list item 1 + + 2. list item 2 + + 3. list item 3'''; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 3); + for (final node in document) { + expect(node, isA()); + expect((node as ListItemNode).type, ListItemType.ordered); + } + + expect((document.getNodeAt(0)! as ListItemNode).text.toPlainText(), 'list item 1'); + expect((document.getNodeAt(1)! as ListItemNode).text.toPlainText(), 'list item 2'); + expect((document.getNodeAt(2)! as ListItemNode).text.toPlainText(), 'list item 3'); + }); + + test('mixing multiple levels of ordered and unordered lists', () { + const markdown = ''' +- Level 1 + 1. Level 2 + 1. Level 3 + - Sublevel 1 + - Sublevel 2 + 2. Level 3 again + 2. Level 2 returning +2. Level 1 once more + - Bullet list + - Another bullet +- Main bullet list + - Sub bullet list + - Subsub bullet list +'''; + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 13); + + expect((document.getNodeAt(0)! as ListItemNode).indent, 0); + expect((document.getNodeAt(0)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(0)! as ListItemNode).text.toPlainText(), 'Level 1'); + + expect((document.getNodeAt(1)! as ListItemNode).indent, 1); + expect((document.getNodeAt(1)! as ListItemNode).type, ListItemType.ordered); + expect((document.getNodeAt(1)! as ListItemNode).text.toPlainText(), 'Level 2'); + + expect((document.getNodeAt(2)! as ListItemNode).indent, 2); + expect((document.getNodeAt(2)! as ListItemNode).type, ListItemType.ordered); + expect((document.getNodeAt(2)! as ListItemNode).text.toPlainText(), 'Level 3'); + + expect((document.getNodeAt(3)! as ListItemNode).indent, 3); + expect((document.getNodeAt(3)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(3)! as ListItemNode).text.toPlainText(), 'Sublevel 1'); + + expect((document.getNodeAt(4)! as ListItemNode).indent, 3); + expect((document.getNodeAt(4)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(4)! as ListItemNode).text.toPlainText(), 'Sublevel 2'); + + expect((document.getNodeAt(5)! as ListItemNode).indent, 2); + expect((document.getNodeAt(5)! as ListItemNode).type, ListItemType.ordered); + expect((document.getNodeAt(5)! as ListItemNode).text.toPlainText(), 'Level 3 again'); + + expect((document.getNodeAt(6)! as ListItemNode).indent, 1); + expect((document.getNodeAt(6)! as ListItemNode).type, ListItemType.ordered); + expect((document.getNodeAt(6)! as ListItemNode).text.toPlainText(), 'Level 2 returning'); + + expect((document.getNodeAt(7)! as ListItemNode).indent, 0); + expect((document.getNodeAt(7)! as ListItemNode).type, ListItemType.ordered); + expect((document.getNodeAt(7)! as ListItemNode).text.toPlainText(), 'Level 1 once more'); + + expect((document.getNodeAt(8)! as ListItemNode).indent, 1); + expect((document.getNodeAt(8)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(8)! as ListItemNode).text.toPlainText(), 'Bullet list'); + + expect((document.getNodeAt(9)! as ListItemNode).indent, 2); + expect((document.getNodeAt(9)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(9)! as ListItemNode).text.toPlainText(), 'Another bullet'); + + expect((document.getNodeAt(10)! as ListItemNode).indent, 0); + expect((document.getNodeAt(10)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(10)! as ListItemNode).text.toPlainText(), 'Main bullet list'); + + expect((document.getNodeAt(11)! as ListItemNode).indent, 1); + expect((document.getNodeAt(11)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(11)! as ListItemNode).text.toPlainText(), 'Sub bullet list'); + + expect((document.getNodeAt(12)! as ListItemNode).indent, 2); + expect((document.getNodeAt(12)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(12)! as ListItemNode).text.toPlainText(), 'Subsub bullet list'); + }); + + test('tasks', () { + const markdown = ''' +- [x] Task 1 +- [ ] Task 2 +- [ ] Task 3 +with multiple lines +- [x] Task 4'''; + + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, 4); + + expect(document.getNodeAt(0)!, isA()); + expect(document.getNodeAt(1)!, isA()); + expect(document.getNodeAt(2)!, isA()); + expect(document.getNodeAt(3)!, isA()); + + expect((document.getNodeAt(0)! as TaskNode).text.toPlainText(), 'Task 1'); + expect((document.getNodeAt(0)! as TaskNode).isComplete, isTrue); + + expect((document.getNodeAt(1)! as TaskNode).text.toPlainText(), 'Task 2'); + expect((document.getNodeAt(1)! as TaskNode).isComplete, isFalse); + + expect((document.getNodeAt(2)! as TaskNode).text.toPlainText(), 'Task 3\nwith multiple lines'); + expect((document.getNodeAt(2)! as TaskNode).isComplete, isFalse); + + expect((document.getNodeAt(3)! as TaskNode).text.toPlainText(), 'Task 4'); + expect((document.getNodeAt(3)! as TaskNode).isComplete, isTrue); + }); + + test('example doc 1', () { + final document = deserializeMarkdownToDocument(exampleMarkdownDoc1); + + expect(document.nodeCount, 26); + + expect(document.getNodeAt(0)!, isA()); + expect((document.getNodeAt(0)! as ParagraphNode).getMetadataValue('blockType'), header1Attribution); + + expect(document.getNodeAt(1)!, isA()); + + expect(document.getNodeAt(2)!, isA()); + + expect(document.getNodeAt(3)!, isA()); + + for (int i = 4; i < 9; ++i) { + expect(document.getNodeAt(i)!, isA()); + } + + expect(document.getNodeAt(9)!, isA()); + + for (int i = 10; i < 15; ++i) { + expect(document.getNodeAt(i)!, isA()); + } + + expect(document.getNodeAt(15)!, isA()); + + expect(document.getNodeAt(16)!, isA()); + + expect(document.getNodeAt(17)!, isA()); + + expect(document.getNodeAt(18)!, isA()); + + expect(document.getNodeAt(19)!, isA()); + + expect(document.getNodeAt(20)!, isA()); + + expect(document.getNodeAt(21)!, isA()); + expect((document.getNodeAt(21)! as ParagraphNode).getMetadataValue('blockType'), header1Attribution); + expect((document.getNodeAt(21)! as ParagraphNode).getMetadataValue('textAlign'), 'left'); + + expect(document.getNodeAt(22)!, isA()); + expect((document.getNodeAt(22)! as ParagraphNode).getMetadataValue('blockType'), header1Attribution); + expect((document.getNodeAt(22)! as ParagraphNode).getMetadataValue('textAlign'), 'center'); + + expect(document.getNodeAt(23)!, isA()); + expect((document.getNodeAt(23)! as ParagraphNode).getMetadataValue('blockType'), header1Attribution); + expect((document.getNodeAt(23)! as ParagraphNode).getMetadataValue('textAlign'), 'right'); + + expect(document.getNodeAt(24)!, isA()); + expect((document.getNodeAt(24)! as ParagraphNode).getMetadataValue('blockType'), header1Attribution); + expect((document.getNodeAt(24)! as ParagraphNode).getMetadataValue('textAlign'), 'justify'); + + expect(document.getNodeAt(25)!, isA()); + }); + + test('paragraph with single strikethrough', () { + final doc = deserializeMarkdownToDocument('~This is~ a paragraph.'); + final styledText = (doc.getNodeAt(0)! as ParagraphNode).text; + + // Ensure text within the range is attributed. + expect(styledText.getAllAttributionsAt(0).contains(strikethroughAttribution), true); + expect(styledText.getAllAttributionsAt(6).contains(strikethroughAttribution), true); + + // Ensure text outside the range isn't attributed. + expect(styledText.getAllAttributionsAt(7).contains(strikethroughAttribution), false); + }); + + test('paragraph with double strikethrough', () { + final doc = deserializeMarkdownToDocument('~~This is~~ a paragraph.'); + final styledText = (doc.getNodeAt(0)! as ParagraphNode).text; + + // Ensure text within the range is attributed. + expect(styledText.getAllAttributionsAt(0).contains(strikethroughAttribution), true); + expect(styledText.getAllAttributionsAt(6).contains(strikethroughAttribution), true); + + // Ensure text outside the range isn't attributed. + expect(styledText.getAllAttributionsAt(7).contains(strikethroughAttribution), false); + }); + + test('paragraph with underline', () { + final doc = deserializeMarkdownToDocument('¬This is¬ a paragraph.'); + final styledText = (doc.getNodeAt(0)! as ParagraphNode).text; + + // Ensure text within the range is attributed. + expect(styledText.getAllAttributionsAt(0).contains(underlineAttribution), true); + expect(styledText.getAllAttributionsAt(6).contains(underlineAttribution), true); + + // Ensure text outside the range isn't attributed. + expect(styledText.getAllAttributionsAt(7).contains(underlineAttribution), false); + }); + + test('paragraph with inline code', () { + final doc = deserializeMarkdownToDocument('`This is` a paragraph.'); + final styledText = (doc.getNodeAt(0)! as ParagraphNode).text; + + // Ensure text within the range is attributed. + expect(styledText.getAllAttributionsAt(0).contains(codeAttribution), true); + expect(styledText.getAllAttributionsAt(6).contains(codeAttribution), true); + + // Ensure text outside the range isn't attributed. + expect(styledText.getAllAttributionsAt(7).contains(codeAttribution), false); + }); + + test('paragraph with left alignment', () { + final doc = deserializeMarkdownToDocument(':---\nParagraph1'); + + final paragraph = doc.first as ParagraphNode; + expect(paragraph.getMetadataValue('textAlign'), 'left'); + expect(paragraph.text.toPlainText(), 'Paragraph1'); + }); + + test('paragraph with center alignment', () { + final doc = deserializeMarkdownToDocument(':---:\nParagraph1'); + + final paragraph = doc.first as ParagraphNode; + expect(paragraph.getMetadataValue('textAlign'), 'center'); + expect(paragraph.text.toPlainText(), 'Paragraph1'); + }); + + test('paragraph with right alignment', () { + final doc = deserializeMarkdownToDocument('---:\nParagraph1'); + + final paragraph = doc.first as ParagraphNode; + expect(paragraph.getMetadataValue('textAlign'), 'right'); + expect(paragraph.text.toPlainText(), 'Paragraph1'); + }); + + test('paragraph with justify alignment', () { + final doc = deserializeMarkdownToDocument('-::-\nParagraph1'); + + final paragraph = doc.first as ParagraphNode; + expect(paragraph.getMetadataValue('textAlign'), 'justify'); + expect(paragraph.text.toPlainText(), 'Paragraph1'); + }); + + test('treats alignment token as text at the end of the document', () { + final doc = deserializeMarkdownToDocument('---:'); + + final paragraph = doc.first as ParagraphNode; + expect(paragraph.getMetadataValue('textAlign'), isNull); + expect(paragraph.text.toPlainText(), '---:'); + }); + + test('treats alignment token as text when not followed by a paragraph', () { + final doc = deserializeMarkdownToDocument('---:\n - - -'); + + final paragraph = doc.first as ParagraphNode; + expect(paragraph.getMetadataValue('textAlign'), isNull); + expect(paragraph.text.toPlainText(), '---:'); + + // Ensure the horizontal rule is parsed. + expect(doc.getNodeAt(1)!, isA()); + }); + + test('treats alignment token as text when not using supereditor syntax', () { + final doc = deserializeMarkdownToDocument(':---\nParagraph1', syntax: MarkdownSyntax.normal); + + final paragraph = doc.first as ParagraphNode; + expect(paragraph.getMetadataValue('textAlign'), isNull); + expect(paragraph.text.toPlainText(), ':---\nParagraph1'); + }); + + test('multiple paragraphs', () { + const input = """Paragraph1 + +Paragraph2"""; + final doc = deserializeMarkdownToDocument(input); + + expect(doc.nodeCount, 2); + expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'Paragraph1'); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), 'Paragraph2'); + }); + + test('empty paragraph between paragraphs', () { + const input = """Paragraph1 + + + +Paragraph3"""; + final doc = deserializeMarkdownToDocument(input); + + expect(doc.nodeCount, 3); + expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'Paragraph1'); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ''); + expect((doc.getNodeAt(2)! as ParagraphNode).text.toPlainText(), 'Paragraph3'); + }); + + test('every 2 newlines after a list are a paragraph', () { + const input = ''' +1. First item +2. Second item +3. Third item + + + + +'''; + final doc = deserializeMarkdownToDocument(input); + + expect(doc.nodeCount, 5); + expect((doc.getNodeAt(0)! as ListItemNode).text.toPlainText(), 'First item'); + expect((doc.getNodeAt(1)! as ListItemNode).text.toPlainText(), 'Second item'); + expect((doc.getNodeAt(2)! as ListItemNode).text.toPlainText(), 'Third item'); + // super_editor tests expect empty newlines after a list to be retained + expect((doc.getNodeAt(3)! as ParagraphNode).text.toPlainText(), ''); + expect((doc.getNodeAt(4)! as ParagraphNode).text.toPlainText(), ''); + }); + + test('multiple empty paragraph between paragraphs', () { + const input = """Paragraph1 + + + + + +Paragraph4"""; + final doc = deserializeMarkdownToDocument(input); + + expect(doc.nodeCount, 4); + expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'Paragraph1'); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ''); + expect((doc.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ''); + expect((doc.getNodeAt(3)! as ParagraphNode).text.toPlainText(), 'Paragraph4'); + }); + + test('paragraph ending with one blank line', () { + final doc = deserializeMarkdownToDocument('First Paragraph. \n\n\nSecond Paragraph'); + expect(doc.nodeCount, 2); + + expect(doc.first, isA()); + expect((doc.first as ParagraphNode).text.toPlainText(), 'First Paragraph.\n'); + + expect(doc.last, isA()); + expect((doc.last as ParagraphNode).text.toPlainText(), 'Second Paragraph'); + }); + + test('paragraph ending with multiple blank lines', () { + final doc = deserializeMarkdownToDocument('First Paragraph. \n \n \n\n\nSecond Paragraph'); + + expect(doc.nodeCount, 2); + + expect(doc.first, isA()); + expect((doc.first as ParagraphNode).text.toPlainText(), 'First Paragraph.\n\n\n'); + + expect(doc.last, isA()); + expect((doc.last as ParagraphNode).text.toPlainText(), 'Second Paragraph'); + }); + + test('paragraph with multiple blank lines at the middle', () { + final doc = + deserializeMarkdownToDocument('First Paragraph. \n \n \nStill First Paragraph\n\nSecond Paragraph'); + + expect(doc.nodeCount, 2); + + expect(doc.first, isA()); + expect((doc.first as ParagraphNode).text.toPlainText(), 'First Paragraph.\n\n\nStill First Paragraph'); + + expect(doc.last, isA()); + expect((doc.last as ParagraphNode).text.toPlainText(), 'Second Paragraph'); + }); + + test('paragraph beginning with multiple blank lines', () { + final doc = deserializeMarkdownToDocument(' \n \nFirst Paragraph.\n\nSecond Paragraph'); + + expect(doc.nodeCount, 2); + + expect(doc.first, isA()); + expect((doc.first as ParagraphNode).text.toPlainText(), '\n\nFirst Paragraph.'); + + expect(doc.last, isA()); + expect((doc.last as ParagraphNode).text.toPlainText(), 'Second Paragraph'); + }); + + test('document ending with an empty paragraph', () { + final doc = deserializeMarkdownToDocument(""" +First Paragraph. + + +"""); + + expect(doc.nodeCount, 2); + + expect(doc.first, isA()); + expect((doc.first as ParagraphNode).text.toPlainText(), 'First Paragraph.'); + + expect(doc.last, isA()); + expect((doc.last as ParagraphNode).text.toPlainText(), ''); + }); + + test('empty markdown produces an empty paragraph', () { + final doc = deserializeMarkdownToDocument(''); + + expect(doc.nodeCount, 1); + + expect(doc.first, isA()); + expect((doc.first as ParagraphNode).text.toPlainText(), ''); + }); + }); + }); +} + +const exampleMarkdownDoc1 = ''' +# Example 1 +--- +This is an example doc that has various types of nodes, like [links](https://example.org). + +It includes multiple paragraphs, ordered list items, unordered list items, images, and HRs. + + * unordered item 1 + * unordered item 2 + * unordered item 2.1 + * unordered item 2.2 + * unordered item 3 + +--- + + 1. ordered item 1 + 2. ordered item 2 + 1. ordered item 2.1 + 2. ordered item 2.2 + 3. ordered item 3 + +--- + +![Image alt text](https://images.com/some/image.png) + +- [ ] Pending task +with multiple lines + +Another paragraph + +- [x] Completed task + +--- + +:--- +# Example 1 With Left Alignment +:---: +# Example 1 With Center Alignment +---: +# Example 1 With Right Alignment +-::- +# Example 1 With Justify Alignment + +The end! +'''; diff --git a/super_editor/test/infrastructure/serialization/markdown/table_parser_test.dart b/super_editor/test/infrastructure/serialization/markdown/table_parser_test.dart new file mode 100644 index 0000000000..ed7138824e --- /dev/null +++ b/super_editor/test/infrastructure/serialization/markdown/table_parser_test.dart @@ -0,0 +1,858 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:text_table/text_table.dart'; + +void main() { + group('Markdown > deserialization > tables >', () { + test('table with single column', () { + expect( + _parseMarkdownTable('''| header 1 | +|---| +| data 1 |'''), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ) + ], + [TextNode(id: '1-2', text: AttributedText('data 1'))], + ], + )), + ); + }); + + test('table with two columns', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 | +|---|---| +| data 1 | data 2 |''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('data 2')), + ], + ], + )), + ); + }); + + test('table with multiple rows', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 | +|---|---| +| data 1 | data 2 | +| data 3 | data 4 | +| data 5 | data 6 |''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ) + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('data 2')), + ], + [ + TextNode(id: '3-1', text: AttributedText('data 3')), + TextNode(id: '3-2', text: AttributedText('data 4')), + ], + [ + TextNode(id: '4-1', text: AttributedText('data 5')), + TextNode(id: '4-2', text: AttributedText('data 6')), + ], + ], + )), + ); + }); + + test('table with alignment', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 | header 3 | +|:---|:---:|---:| +| data 1 | data 2 | data 3 |''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-3', + text: AttributedText('header 3'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode( + id: '2-2', + text: AttributedText('data 2'), + metadata: const { + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '2-3', + text: AttributedText('data 3'), + metadata: const { + TextNodeMetadata.textAlign: TextAlign.right, + }, + ), + ], + ], + )), + ); + }); + + test('table with inline markdown', () { + expect( + _parseMarkdownTable( + '''| **header 1** | +|---| +| [link](https://example.org) |''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText( + 'header 1', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: boldAttribution, + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 7, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ) + ], + [ + TextNode( + id: '2-1', + text: AttributedText( + 'link', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: LinkAttribution('https://example.org'), + offset: 0, + markerType: SpanMarkerType.start), + const SpanMarker( + attribution: LinkAttribution('https://example.org'), + offset: 3, + markerType: SpanMarkerType.end), + ], + ), + ), + ) + ] + ], + )), + ); + }); + + test('table without body', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 | +|---|---|''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + ], + )), + ); + }); + + test('table with text containing pipe', () { + expect( + _parseMarkdownTable( + '''| header\\|1 | +|---| +| data\\|1 |''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header|1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data|1')), + ], + ], + )), + ); + }); + + test('table with inline markdown containing pipe', () { + expect( + _parseMarkdownTable( + '''| header **\\|1** | +|---| +| data 1 |''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText( + 'header |1', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: boldAttribution, + offset: 7, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 8, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [TextNode(id: '2-1', text: AttributedText('data 1'))], + ], + )), + ); + }); + + test('table with delimiter with more than three hyphens', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 | +|---|----------| +| data 1 | data 2 |''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('data 2')), + ], + ], + )), + ); + }); + + test('table without leading pipe', () { + expect( + _parseMarkdownTable( + '''header 1 | header 2 | +---|---| + data 1 | data 2 |''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('data 2')), + ], + ], + )), + ); + }); + + test('table without trailing pipe', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 +|---|--- +| data 1 | data 2 ''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('data 2')), + ], + ], + )), + ); + }); + + test('table without leading and trailing pipe', () { + expect( + _parseMarkdownTable( + '''header 1 | header 2 +---|--- +data 1 | data 2 ''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('data 2')), + ], + ], + )), + ); + }); + + test('table with row with missing column', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 | +|---|---| +| data 1''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('')), + ], + ], + )), + ); + }); + + test('table with row with extra column', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 | +|---|---| +| data 1 | data 2 | extra data |''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('data 2')), + ], + ], + )), + ); + }); + + test('table with tabs between columns', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 | header 3 | +|--- |--- |--- | +| data 1 | data 2 | data 3 |''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-3', + text: AttributedText('header 3'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('data 2')), + TextNode(id: '2-3', text: AttributedText('data 3')), + ], + ], + )), + ); + }); + + test('table broken by block level element', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 | +|---|---| +| data 1 | data 2 | +> blockquote''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('data 2')), + ], + ], + )), + ); + }); + + test('table broken by empty line', () { + expect( + _parseMarkdownTable( + '''| header 1 | header 2 | +|---|---| +| data 1 | data 2 | +data 3 + +paragraph''', + ), + _matchesTableContent(TableBlockNode( + id: '1', + cells: [ + [ + TextNode( + id: '1-1', + text: AttributedText('header 1'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + TextNode( + id: '1-2', + text: AttributedText('header 2'), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + TextNodeMetadata.textAlign: TextAlign.center, + }, + ), + ], + [ + TextNode(id: '2-1', text: AttributedText('data 1')), + TextNode(id: '2-2', text: AttributedText('data 2')), + ], + [ + TextNode(id: '3-1', text: AttributedText('data 3')), + TextNode(id: '3-2', text: AttributedText('')), + ] + ], + )), + ); + }); + + test('does not parse table with delimiter with mismatch cell count', () { + final document = deserializeMarkdownToDocument('''| header 1 | header 2 | +|---| +| data 1 | data 2 |'''); + + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect( + (document.first as ParagraphNode).text.toPlainText(includePlaceholders: false), + '''| header 1 | header 2 | +|---| +| data 1 | data 2 |''', + ); + }); + }); +} + +/// Parses the given [markdown] string and attempts to extract a table +/// from the first element. +/// +/// All subsequent elements are ignored. +TableBlockNode _parseMarkdownTable(String markdown) { + final document = deserializeMarkdownToDocument(markdown); + + expect(document.nodeCount, greaterThanOrEqualTo(1)); + expect(document.first, isA()); + + return document.first as TableBlockNode; +} + +/// Checks whether a [TableBlockNode] has equivalent content to an expected [TableBlockNode]. +/// +/// We cannot use the default equality operator because it would compare +/// the node ids, which are generated randomly. +/// +/// This matcher checks that the number of rows and columns are the same, +/// and that the text and metadata of each cell are the same. +_TableBlockNodeMatcher _matchesTableContent( + TableBlockNode expected, +) { + return _TableBlockNodeMatcher(expected); +} + +class _TableBlockNodeMatcher extends Matcher { + const _TableBlockNodeMatcher(this._expected); + + final TableBlockNode _expected; + + @override + Description describe(Description description) { + return description.add("given TableBlockNode has equivalent content to expected TableBlockNode"); + } + + @override + bool matches(covariant Object target, Map matchState) { + return _calculateMismatchReason(target, matchState) == null; + } + + @override + Description describeMismatch( + covariant Object target, + Description mismatchDescription, + Map matchState, + bool verbose, + ) { + final mismatchReason = _calculateMismatchReason(target, matchState); + if (mismatchReason != null) { + mismatchDescription.add(mismatchReason); + } + return mismatchDescription; + } + + String? _calculateMismatchReason( + Object target, + Map matchState, + ) { + if (target is! TableBlockNode) { + return 'Expected a TableBlockNode, but got ${target.runtimeType}'; + } + + final messages = []; + bool rowCountMismatch = false; + bool rowContentMismatch = false; + + if (_expected.rowCount != target.rowCount) { + messages.add("expected ${_expected.rowCount} rows but found ${target.rowCount}"); + rowCountMismatch = true; + } else { + messages.add("table have the same number of rows"); + } + + final maxRowCount = max(_expected.rowCount, target.rowCount); + final rowComparisons = List.generate(maxRowCount, (index) => ["", "", " "]); + for (int i = 0; i < maxRowCount; i += 1) { + if (i < _expected.rowCount && i < target.rowCount) { + rowComparisons[i][0] = _tableRowToString(_expected.getRow(i)); + rowComparisons[i][1] = _tableRowToString(target.getRow(i)); + + bool columnCountMismatch = _expected.getRow(i).length != target.getRow(i).length; + if (columnCountMismatch) { + rowComparisons[i][2] = "Column count mismatch"; + rowContentMismatch = true; + } else { + rowComparisons[i][2] = "Same number of columns"; + + for (int j = 0; j < _expected.getRow(i).length; j++) { + final expectedCell = _expected.getRow(i)[j]; + final targetCell = target.getRow(i)[j]; + + if (expectedCell.text != targetCell.text) { + rowComparisons[i][2] = "Content mismatch in row $i column $j"; + rowContentMismatch = true; + + // No need to check further columns in this row. + continue; + } + + final expectedMetadata = expectedCell.metadata; + final targetMetadata = targetCell.metadata; + + if (expectedMetadata.length != targetMetadata.length) { + rowComparisons[i][0] = expectedMetadata.entries.map((e) => '${e.key}: ${e.value}').join(', '); + rowComparisons[i][1] = targetMetadata.entries.map((e) => '${e.key}: ${e.value}').join(', '); + rowComparisons[i][2] = "Metadata length mismatch in row $i column $j"; + rowContentMismatch = true; + continue; + } + + for (final key in expectedMetadata.keys) { + if (!targetMetadata.containsKey(key)) { + rowComparisons[i][2] = "Metadata key '$key' missing in row $i column $j"; + rowContentMismatch = true; + break; + } + if (expectedMetadata[key] != targetMetadata[key]) { + rowComparisons[i][0] = expectedMetadata[key].toString(); + rowComparisons[i][1] = targetMetadata[key].toString(); + rowComparisons[i][2] = "Metadata value mismatch for key '$key' in row $i column $j"; + rowContentMismatch = true; + break; + } + } + } + } + } else if (i < _expected.rowCount) { + rowComparisons[i][0] = _tableRowToString(_expected.getRow(i)); + rowComparisons[i][1] = "NA"; + rowComparisons[i][2] = "Missing Row"; + } else if (i < target.rowCount) { + rowComparisons[i][0] = "NA"; + rowComparisons[i][1] = _tableRowToString(target.getRow(i)); + rowComparisons[i][2] = "Missing Row"; + } + } + + if (rowCountMismatch || rowContentMismatch) { + String messagesList = messages.join(", "); + messagesList += "\n"; + messagesList += const TableRenderer().render(rowComparisons, columns: ["Expected", "Actual", "Difference"]); + return messagesList; + } + + return null; + } + + String _tableRowToString(List row) { + return '| ${row.map((e) => e.text.toPlainText(includePlaceholders: false)).join(" | ")} |'; + } +} diff --git a/super_editor/test/infrastructure/serialization/markdown/test_tools.dart b/super_editor/test/infrastructure/serialization/markdown/test_tools.dart new file mode 100644 index 0000000000..60c9214347 --- /dev/null +++ b/super_editor/test/infrastructure/serialization/markdown/test_tools.dart @@ -0,0 +1,36 @@ +import 'package:flutter/widgets.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A [ComponentBuilder] which builds an [ImageComponent] that always renders +/// images as a [SizedBox] with the given [size]. +class FakeImageComponentBuilder implements ComponentBuilder { + const FakeImageComponentBuilder({ + required this.size, + }); + + final Size size; + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + return null; + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! ImageComponentViewModel) { + return null; + } + + return ImageComponent( + componentKey: componentContext.componentKey, + imageUrl: componentViewModel.imageUrl, + selection: componentViewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection?, + selectionColor: componentViewModel.selectionColor, + imageBuilder: (context, imageUrl) => SizedBox( + height: size.height, + width: size.width, + ), + ); + } +} diff --git a/super_editor/test/infrastructure/serialization/quill/attributed_text.dart b/super_editor/test/infrastructure/serialization/quill/attributed_text.dart new file mode 100644 index 0000000000..163b6caed1 --- /dev/null +++ b/super_editor/test/infrastructure/serialization/quill/attributed_text.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + test("Attributed text split", () { + final text = AttributedText( + "Line one\nLine two\nLine three", + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: boldAttribution, + offset: 14, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 21, + markerType: SpanMarkerType.end, + ), + ], + ), + ); + final textByLine = text.split("\n"); + + expect(textByLine.length, 3); + expect(textByLine[0], AttributedText("Line one")); + expect( + textByLine[1], + AttributedText( + "Line two", + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: boldAttribution, + offset: 5, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 7, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ); + expect( + textByLine[2], + AttributedText( + "Line three", + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: boldAttribution, + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 3, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ); + }); +} diff --git a/super_editor/test/infrastructure/serialization/quill/parsing/multiline_parsing_test.dart b/super_editor/test/infrastructure/serialization/quill/parsing/multiline_parsing_test.dart new file mode 100644 index 0000000000..3dca9d2c67 --- /dev/null +++ b/super_editor/test/infrastructure/serialization/quill/parsing/multiline_parsing_test.dart @@ -0,0 +1,183 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group("Delta document parsing > test > multiline >", () { + test("parses inline newline characters into multiple document nodes", () { + final document = parseQuillDeltaDocument( + { + "ops": [ + {"insert": "\nLine one\nLine two\nLine three\nLine four\n\n"} + ], + }, + ); + + expect((document.getNodeAt(0)! as TextNode).text.toPlainText(), ""); + expect((document.getNodeAt(1)! as TextNode).text.toPlainText(), "Line one"); + expect((document.getNodeAt(2)! as TextNode).text.toPlainText(), "Line two"); + expect((document.getNodeAt(3)! as TextNode).text.toPlainText(), "Line three"); + expect((document.getNodeAt(4)! as TextNode).text.toPlainText(), "Line four"); + expect((document.getNodeAt(5)! as TextNode).text.toPlainText(), ""); + expect((document.getNodeAt(6)! as TextNode).text.toPlainText(), ""); + + // A note on the length of the document. If this document is placed in a + // Quill editor, there will only be 6 lines the user can edit. This seems + // like it's probably a part of Quill specified behavior. I think that + // because every Quill document must always end with a newline, the final + // newline of a document is ignored by a Quill editor. But in our case + // we parse every newline, including the trailing newline, so the length + // of this document is 7. If that's a problem, we can make the parser + // more intelligent about this later. + expect(document.nodeCount, 7); + }); + + group("block merging >", () { + group("default merging >", () { + test("merges consecutive blockquote", () { + final document = parseQuillDeltaDocument( + { + "ops": [ + {"insert": "This is a blockquote"}, + { + "attributes": { + "blockquote": {"blockquote": true} + }, + "insert": "\n" + }, + {"insert": "This is line two"}, + { + "attributes": { + "blockquote": {"blockquote": true} + }, + "insert": "\n" + }, + ], + }, + ); + + expect( + (document.getNodeAt(0)! as ParagraphNode).text.toPlainText(), + "This is a blockquote\nThis is line two", + ); + expect((document.getNodeAt(0)! as ParagraphNode).getMetadataValue("blockType"), blockquoteAttribution); + expect((document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ""); + expect(document.nodeCount, 2); + }); + + test("does not merge blockquotes separated by an unstyled insert", () { + final document = parseQuillDeltaDocument( + { + "ops": [ + {"insert": "This is a blockquote"}, + { + "attributes": { + "blockquote": {"blockquote": true} + }, + "insert": "\n" + }, + {"insert": "\n"}, + {"insert": "This is line two"}, + { + "attributes": { + "blockquote": {"blockquote": true} + }, + "insert": "\n" + }, + ], + }, + ); + + expect( + (document.getNodeAt(0)! as ParagraphNode).text.toPlainText(), + "This is a blockquote", + ); + expect( + (document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), + "", + ); + expect( + (document.getNodeAt(2)! as ParagraphNode).text.toPlainText(), + "This is line two", + ); + expect((document.getNodeAt(0)! as ParagraphNode).getMetadataValue("blockType"), blockquoteAttribution); + expect((document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ""); + expect((document.getNodeAt(2)! as ParagraphNode).getMetadataValue("blockType"), blockquoteAttribution); + expect(document.nodeCount, 4); + }); + + test("merges consecutive code blocks", () { + // Notice that Delta encodes each line of a code block as a separate attributed + // delta. But when a Quill editor renders the code block, it's rendered as one + // block. This test ensures that Super Editor accumulates back-to-back code + // lines into a single code node. + final document = parseQuillDeltaDocument( + { + "ops": [ + {"insert": "This is a code block"}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n" + }, + {"insert": "This is line two"}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n" + }, + {"insert": "This is line three"}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n" + }, + ], + }, + ); + + expect( + (document.getNodeAt(0)! as ParagraphNode).text.toPlainText(), + "This is a code block\nThis is line two\nThis is line three", + ); + expect((document.getNodeAt(0)! as ParagraphNode).getMetadataValue("blockType"), codeAttribution); + expect((document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ""); + expect(document.nodeCount, 2); + }); + + test("does not merge code blocks separated by an unstyled insert", () { + final document = parseQuillDeltaDocument( + { + "ops": [ + {"insert": "This is a code block"}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n" + }, + {"insert": "\n"}, + {"insert": "This is line two"}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n" + }, + ], + }, + ); + + expect( + (document.getNodeAt(0)! as ParagraphNode).text.toPlainText(), + "This is a code block", + ); + expect( + (document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), + "", + ); + expect( + (document.getNodeAt(2)! as ParagraphNode).text.toPlainText(), + "This is line two", + ); + expect((document.getNodeAt(0)! as ParagraphNode).getMetadataValue("blockType"), codeAttribution); + expect((document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ""); + expect((document.getNodeAt(2)! as ParagraphNode).getMetadataValue("blockType"), codeAttribution); + expect(document.nodeCount, 4); + }); + }); + }); + }); +} diff --git a/super_editor/test/infrastructure/serialization/quill/parsing/parsing_test.dart b/super_editor/test/infrastructure/serialization/quill/parsing/parsing_test.dart new file mode 100644 index 0000000000..c74a0bf539 --- /dev/null +++ b/super_editor/test/infrastructure/serialization/quill/parsing/parsing_test.dart @@ -0,0 +1,824 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../test_documents.dart'; + +// Useful links: +// - create a Delta document in the browser: https://quilljs.com/playground/snow +// - list of supported formats (bold, italics, header, etc): https://quilljs.com/docs/formats + +void main() { + group("Delta document parsing >", () { + group("text >", () { + test("plain text followed by block format", () { + final document = parseQuillDeltaDocument( + { + "ops": [ + {"insert": "This is regular text\nThis is a code block"}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n" + }, + ], + }, + ); + + expect( + (document.getNodeAt(0)! as ParagraphNode).text.toPlainText(), + "This is regular text", + ); + expect((document.getNodeAt(0)! as ParagraphNode).getMetadataValue("blockType"), paragraphAttribution); + expect( + (document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), + "This is a code block", + ); + expect((document.getNodeAt(1)! as ParagraphNode).getMetadataValue("blockType"), codeAttribution); + expect((document.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ""); + expect(document.nodeCount, 3); + }); + + test("all text blocks and styles", () { + final document = parseQuillDeltaOps(allTextStylesDeltaDocument); + + final nodes = document.iterator..moveNext(); + DocumentNode? node = nodes.current; + + // Check the header. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "All Text Styles"); + + nodes.moveNext(); + node = nodes.current; + + // Check the paragraph with various formatting. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), + "Samples of styles: bold, italics, underline, strikethrough, text color, background color, font change, link"); + expect( + node.text.getAttributionSpansByFilter((a) => true), + { + const AttributionSpan(attribution: boldAttribution, start: 19, end: 22), + const AttributionSpan(attribution: italicsAttribution, start: 25, end: 31), + const AttributionSpan(attribution: underlineAttribution, start: 34, end: 42), + const AttributionSpan(attribution: strikethroughAttribution, start: 45, end: 57), + const AttributionSpan(attribution: ColorAttribution(Color(0xFFe60000)), start: 60, end: 69), + const AttributionSpan(attribution: BackgroundColorAttribution(Color(0xFFe60000)), start: 72, end: 87), + const AttributionSpan(attribution: FontFamilyAttribution("serif"), start: 90, end: 100), + const AttributionSpan(attribution: LinkAttribution("google.com"), start: 103, end: 106), + }, + ); + + nodes.moveNext(); + node = nodes.current; + + // Blank line. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), ""); + + nodes.moveNext(); + node = nodes.current; + + // Paragraph - left aligned. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "Left aligned"); + expect(node.metadata["textAlign"], isNull); + + nodes.moveNext(); + node = nodes.current; + + // Paragraph - center aligned. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "Center aligned"); + expect(node.metadata["textAlign"], "center"); + + nodes.moveNext(); + node = nodes.current; + + // Paragraph - right aligned. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "Right aligned"); + expect(node.metadata["textAlign"], "right"); + + nodes.moveNext(); + node = nodes.current; + + // Paragraph - justified. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "Justified"); + expect(node.metadata["textAlign"], "justify"); + + nodes.moveNext(); + node = nodes.current; + + // Blank line. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), ""); + + nodes.moveNext(); + node = nodes.current; + + // Ordered list items. + expect(node, isA()); + expect((node as ListItemNode).text.toPlainText(), "Ordered item 1"); + expect(node.type, ListItemType.ordered); + expect(node.indent, 0); + + nodes.moveNext(); + node = nodes.current; + + expect(node, isA()); + expect((node as ListItemNode).text.toPlainText(), "Ordered item 2"); + expect(node.type, ListItemType.ordered); + expect(node.indent, 0); + + nodes.moveNext(); + node = nodes.current; + + // Blank line. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), ""); + + nodes.moveNext(); + node = nodes.current; + + // Unordered list items. + expect(node, isA()); + expect((node as ListItemNode).text.toPlainText(), "Unordered item 1"); + expect(node.type, ListItemType.unordered); + expect(node.indent, 0); + + nodes.moveNext(); + node = nodes.current; + + expect(node, isA()); + expect((node as ListItemNode).text.toPlainText(), "Unordered item 2"); + expect(node.type, ListItemType.unordered); + expect(node.indent, 0); + + nodes.moveNext(); + node = nodes.current; + + // Blank line. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), ""); + + nodes.moveNext(); + node = nodes.current; + + // Tasks. + expect(node, isA()); + expect((node as TaskNode).text.toPlainText(), "I'm a task that's incomplete"); + expect(node.isComplete, isFalse); + + nodes.moveNext(); + node = nodes.current; + + expect(node, isA()); + expect((node as TaskNode).text.toPlainText(), "I'm a task that's complete"); + expect(node.isComplete, isTrue); + + nodes.moveNext(); + node = nodes.current; + + // Blank line. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), ""); + + nodes.moveNext(); + node = nodes.current; + + // Indented paragraphs. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "I'm an indented paragraph at level 1"); + expect((node as ParagraphNode).indent, 1); + + nodes.moveNext(); + node = nodes.current; + + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "I'm a paragraph indented at level 2"); + expect((node as ParagraphNode).indent, 2); + + nodes.moveNext(); + node = nodes.current; + + // Blank line. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), ""); + + nodes.moveNext(); + node = nodes.current; + + // Superscript and subscript. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "Some contentThis is a subscript"); + expect( + node.text.getAttributionSpansByFilter((a) => true), + { + const AttributionSpan(attribution: subscriptAttribution, start: 12, end: 30), + }, + ); + + nodes.moveNext(); + node = nodes.current; + + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "Some contentThis is a superscript"); + expect( + node.text.getAttributionSpansByFilter((a) => true), + { + const AttributionSpan(attribution: superscriptAttribution, start: 12, end: 32), + }, + ); + + nodes.moveNext(); + node = nodes.current; + + // Blank line. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), ""); + + nodes.moveNext(); + node = nodes.current; + + // Text sizes. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "HUGE"); + expect( + node.text.getAttributionSpansByFilter((a) => true), + { + const AttributionSpan(attribution: NamedFontSizeAttribution("huge"), start: 0, end: 3), + }, + ); + + nodes.moveNext(); + node = nodes.current; + + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "Large"); + expect( + node.text.getAttributionSpansByFilter((a) => true), + { + const AttributionSpan(attribution: NamedFontSizeAttribution("large"), start: 0, end: 4), + }, + ); + + nodes.moveNext(); + node = nodes.current; + + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "small"); + expect( + node.text.getAttributionSpansByFilter((a) => true), + { + const AttributionSpan(attribution: NamedFontSizeAttribution("small"), start: 0, end: 4), + }, + ); + + nodes.moveNext(); + node = nodes.current; + + // Blank line. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), ""); + + nodes.moveNext(); + node = nodes.current; + + // Blockquote. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "This is a blockquote"); + expect(node.metadata["blockType"], blockquoteAttribution); + + nodes.moveNext(); + node = nodes.current; + + // Blank line. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), ""); + + nodes.moveNext(); + node = nodes.current; + + // Code block - with multiple lines. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), "This is a code block\nThat spans two lines."); + expect(node.metadata["blockType"], codeAttribution); + + nodes.moveNext(); + node = nodes.current; + + // Final newline node. + expect(node, isA()); + expect((node as TextNode).text.toPlainText(), ""); + + // No more nodes. + expect(nodes.moveNext(), isFalse); + }); + + test("overlapping styles", () { + final document = parseQuillDeltaDocument( + { + "ops": [ + {"insert": "This "}, + { + "attributes": {"bold": true}, + "insert": "paragraph ", + }, + { + "attributes": {"italic": true, "bold": true}, + "insert": "has ", + }, + { + "attributes": {"underline": true, "italic": true, "bold": true}, + "insert": "some", + }, + { + "attributes": {"underline": true, "italic": true}, + "insert": " overlapping", + }, + { + "attributes": {"underline": true}, + "insert": " styles", + }, + {"insert": ".\n"}, + ], + }, + ); + + final paragraph = document.first as ParagraphNode; + expect(paragraph.text.toPlainText(), "This paragraph has some overlapping styles."); + expect( + paragraph.text.getAttributionSpansByFilter((a) => true), + { + const AttributionSpan(attribution: boldAttribution, start: 5, end: 22), + const AttributionSpan(attribution: italicsAttribution, start: 15, end: 34), + const AttributionSpan(attribution: underlineAttribution, start: 19, end: 41), + }, + ); + }); + + test("gracefully handles unknown inline text format", () { + final document = parseQuillDeltaOps([ + {"insert": "Paragraph "}, + { + // A non-existent inline format. + "attributes": {"unknown": true}, + "insert": "one" + }, + {"insert": "\nParagraph two\n"}, + ]); + + expect(document.nodeCount, 3); + + expect(document.getNodeAt(0)!, isA()); + expect((document.getNodeAt(0)! as ParagraphNode).text.toPlainText(), "Paragraph one"); + expect( + (document.getNodeAt(0)! as ParagraphNode).text.getAttributionSpansByFilter((a) => true), + const {}, + ); + + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), "Paragraph two"); + + expect(document.getNodeAt(2)!, isA()); + expect((document.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ""); + }); + + test("gracefully handles unknown text block format", () { + final document = parseQuillDeltaOps([ + {"insert": "Paragraph one"}, + { + "attributes": {"unknown-name": "unknown-value"}, + "insert": "\n" + }, + {"insert": "Paragraph two\n"}, + ]); + + expect(document.nodeCount, 3); + + expect(document.getNodeAt(0)!, isA()); + expect((document.getNodeAt(0)! as ParagraphNode).text.toPlainText(), "Paragraph one"); + expect((document.getNodeAt(0)! as ParagraphNode).metadata["blockType"], paragraphAttribution); + expect( + (document.getNodeAt(0)! as ParagraphNode).text.getAttributionSpansByFilter((a) => true), + const {}, + ); + + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), "Paragraph two"); + + expect(document.getNodeAt(2)!, isA()); + expect((document.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ""); + }); + }); + + group("media >", () { + test("an image", () { + final document = parseQuillDeltaOps([ + {"insert": "Paragraph one\n"}, + { + "insert": { + "image": "https://quilljs.com/assets/images/icon.png", + }, + }, + {"insert": "Paragraph two\n"}, + ]); + + final image = document.getNodeAt(1)!; + expect(image, isA()); + image as ImageNode; + expect(image.imageUrl, "https://quilljs.com/assets/images/icon.png"); + }); + + // TODO: make it possible to linkify an image (needs added support in SuperEditor). + test("an image with a link", () { + final document = parseQuillDeltaOps([ + {"insert": "Paragraph one\n"}, + { + "insert": { + "image": "https://quilljs.com/assets/images/icon.png", + }, + "attributes": { + "link": "https://quilljs.com", + }, + }, + {"insert": "Paragraph two\n"}, + ]); + + final image = document.getNodeAt(1)!; + expect(image, isA()); + image as ImageNode; + expect(image.imageUrl, "https://quilljs.com/assets/images/icon.png"); + }, skip: true); + + test("a video", () { + final document = parseQuillDeltaOps([ + {"insert": "Paragraph one\n"}, + { + "insert": { + "video": "https://quilljs.com/assets/media/video.mp4", + }, + }, + {"insert": "Paragraph two\n"}, + ]); + + final video = document.getNodeAt(1)!; + expect(video, isA()); + video as VideoNode; + expect(video.url, "https://quilljs.com/assets/media/video.mp4"); + }); + + test("audio", () { + final document = parseQuillDeltaOps([ + {"insert": "Paragraph one\n"}, + { + "insert": { + "audio": "https://quilljs.com/assets/media/audio.mp3", + }, + }, + {"insert": "Paragraph two\n"}, + ]); + + final audio = document.getNodeAt(1)!; + expect(audio, isA()); + audio as AudioNode; + expect(audio.url, "https://quilljs.com/assets/media/audio.mp3"); + }); + + test("a file", () { + final document = parseQuillDeltaOps([ + {"insert": "Paragraph one\n"}, + { + "insert": { + "file": "https://quilljs.com/assets/media/file.pdf", + }, + }, + {"insert": "Paragraph two\n"}, + ]); + + final file = document.getNodeAt(1)!; + expect(file, isA()); + file as FileNode; + expect(file.url, "https://quilljs.com/assets/media/file.pdf"); + }); + + test("gracefully handles unknown media block", () { + final document = parseQuillDeltaOps([ + {"insert": "Paragraph one\n"}, + { + "insert": { + "unknown": "this block type doesn't exist", + }, + }, + {"insert": "Paragraph two\n"}, + ]); + + expect(document.nodeCount, 3); + + expect(document.getNodeAt(0)!, isA()); + expect((document.getNodeAt(0)! as ParagraphNode).text.toPlainText(), "Paragraph one"); + + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), "Paragraph two"); + + expect(document.getNodeAt(2)!, isA()); + expect((document.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ""); + }); + }); + + group("custom parsers >", () { + test("works with ambiguous formats", () { + // Consider the standard Delta list item format: + // { + // "list": "ordered" + // } + // + // We want to ensure that an ambiguous custom format is respected + // without issue, e.g., + // { + // "list": { + // "list": "ordered" + // } + // } + parseQuillDeltaOps([ + {"insert": "Paragraph one"}, + { + "attributes": { + "header": { + "header": 1, + }, + }, + "insert": "\n" + }, + {"insert": "Paragraph two"}, + { + "attributes": { + "blockquote": { + "blockquote": true, + }, + }, + "insert": "\n" + }, + {"insert": "Paragraph three"}, + { + "attributes": { + "code-block": { + "code-block": "dart", + }, + }, + "insert": "\n" + }, + {"insert": "Paragraph four"}, + { + "attributes": { + "list": { + "list": "ordered", + }, + }, + "insert": "\n" + }, + {"insert": "Paragraph five"}, + { + "attributes": { + "align": { + "align": "left", + }, + }, + "insert": "\n" + }, + {"insert": "Paragraph six\n"}, + ]); + + // If we get here without an exception, the test passes. This means that + // the standard + }); + + test("defers to higher priority ambiguous format", () { + // Goal of test: when a custom parser has a format that's ambiguous as compared to + // a standard format, the custom parser is used instead of the standard parser, + // when the custom parser is higher in the parser list. + final document = parseQuillDeltaOps([ + {"insert": "Paragraph one"}, + { + "attributes": { + "list": { + "list": "ordered", + }, + }, + "insert": "\n" + }, + {"insert": "Paragraph two\n"}, + ], blockFormats: [ + const _CustomListItemBlockFormat(), + ...defaultBlockFormats, + ]); + + expect(document.first, isA()); + }); + + test("can parse inline embeds", () { + final document = parseQuillDeltaOps([ + {"insert": "Have you heard about inline embeds, "}, + { + "insert": { + "tag": { + "type": "user", + "userId": "123456", + "text": "@John Smith", + }, + }, + }, + {"insert": "?\n"}, + ], inlineEmbedFormats: [ + const _UserTagEmbedParser(), + ]); + + final text = (document.first as ParagraphNode).text; + expect(text.toPlainText(), "Have you heard about inline embeds, @John Smith?"); + expect( + text.spans, + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: _UserTagAttribution("123456"), offset: 36, markerType: SpanMarkerType.start), + const SpanMarker(attribution: _UserTagAttribution("123456"), offset: 46, markerType: SpanMarkerType.end), + ], + ), + ); + }); + + test("plain text followed by custom block format", () { + final document = parseQuillDeltaDocument( + { + "ops": [ + {"insert": "This is regular text\nThis is a banner"}, + { + "attributes": { + "banner-color": "red", + }, + "insert": "\n" + }, + ], + }, + blockFormats: [ + const _BannerBlockFormat(), + ...defaultBlockFormats, + ], + ); + + expect( + (document.getNodeAt(0)! as ParagraphNode).text.toPlainText(), + "This is regular text", + ); + expect((document.getNodeAt(0)! as ParagraphNode).getMetadataValue("blockType"), paragraphAttribution); + expect( + (document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), + "This is a banner", + ); + expect( + (document.getNodeAt(1)! as ParagraphNode).getMetadataValue("blockType"), const _BannerAttribution("red")); + expect(document.nodeCount, 3); + }); + }); + }); +} + +class _CustomListItemBlockFormat extends FilterByNameBlockDeltaFormat { + const _CustomListItemBlockFormat() : super("list"); + + @override + List? doApplyFormat(Editor editor, Object value) { + if (value is! Map) { + return null; + } + + final composer = editor.context.find(Editor.composerKey); + return [ + ConvertParagraphToTaskRequest( + nodeId: composer.selection!.extent.nodeId, + ), + ]; + } +} + +class _BannerBlockFormat extends FilterByNameBlockDeltaFormat { + const _BannerBlockFormat() : super("banner-color"); + + @override + List? doApplyFormat(Editor editor, Object value) { + if (value is! String) { + return null; + } + + final composer = editor.context.find(Editor.composerKey); + return [ + ChangeParagraphBlockTypeRequest( + nodeId: composer.selection!.extent.nodeId, + blockType: _BannerAttribution(value), + ), + ]; + } +} + +class _BannerAttribution implements Attribution { + const _BannerAttribution(this.color); + + @override + String get id => "banner-$color"; + + final String color; + + @override + bool canMergeWith(Attribution other) { + if (other is! _BannerAttribution) { + return false; + } + + return color == other.color; + } + + @override + bool operator ==(Object other) => + identical(this, other) || other is _BannerAttribution && runtimeType == other.runtimeType && color == other.color; + + @override + int get hashCode => color.hashCode; +} + +class _UserTagEmbedParser implements InlineEmbedFormat { + const _UserTagEmbedParser(); + + @override + bool insert(Editor editor, DocumentComposer composer, Map embed) { + if (embed + case { + "tag": { + "type": String type, + "userId": String userId, + "text": String text, + }, + }) { + if (type != "user") { + // This isn't a user tag. Fizzle. + return false; + } + + final selection = composer.selection; + if (selection == null) { + // The selection should always be defined during insertions. This + // shouldn't happen. Fizzle. + return false; + } + if (!selection.isCollapsed) { + // The selection should always be collapsed during insertions. This + // shouldn't happen. Fizzle. + return false; + } + + final extentPosition = selection.extent; + if (extentPosition.nodePosition is! TextNodePosition) { + // Insertions should always happen in text nodes. This shouldn't happen. + // Fizzle. + return false; + } + + editor.execute([ + InsertTextRequest( + documentPosition: extentPosition, + textToInsert: text, + attributions: { + _UserTagAttribution(userId), + }, + ), + ]); + + return true; + } + + return false; + } +} + +class _UserTagAttribution implements Attribution { + const _UserTagAttribution(this.userId); + + @override + String get id => userId; + + final String userId; + + @override + bool canMergeWith(Attribution other) { + return other is _UserTagAttribution && userId == other.userId; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _UserTagAttribution && runtimeType == other.runtimeType && userId == other.userId; + + @override + int get hashCode => userId.hashCode; +} diff --git a/super_editor/test/infrastructure/serialization/quill/serializing_test.dart b/super_editor/test/infrastructure/serialization/quill/serializing_test.dart new file mode 100644 index 0000000000..ab867843bc --- /dev/null +++ b/super_editor/test/infrastructure/serialization/quill/serializing_test.dart @@ -0,0 +1,627 @@ +import 'package:dart_quill_delta/dart_quill_delta.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import 'test_documents.dart'; + +void main() { + group("Delta document serializing >", () { + test("empty document", () { + final deltas = MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText(""), + ), + ], + ).toQuillDeltas(); + + final expectedDeltas = Delta.fromJson([ + {"insert": "\n"}, + ]); + + expect(deltas, quillDocumentEquivalentTo(expectedDeltas)); + }); + + group("text >", () { + test("multiline header", () { + // Note: The official Quill editor doesn't seem to support multiline + // headers, visually. The Delta format definitely doesn't + // support them. Each header line gets its own attributed + // insertion. + final deltas = MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("This paragraph is followed by a multiline header:"), + ), + ParagraphNode( + id: "2", + text: AttributedText("This is a header\nThis is line two\nThis is line three"), + metadata: const { + "blockType": header1Attribution, + }, + ), + ], + ).toQuillDeltas(); + + final expectedDeltas = Delta.fromJson([ + {"insert": "This paragraph is followed by a multiline header:\nThis is a header"}, + { + "attributes": {"header": 1}, + "insert": "\n", + }, + {"insert": "This is line two"}, + { + "attributes": {"header": 1}, + "insert": "\n", + }, + {"insert": "This is line three"}, + { + "attributes": {"header": 1}, + "insert": "\n", + }, + ]); + + expect(deltas, quillDocumentEquivalentTo(expectedDeltas)); + }); + + test("multiline blockquote", () { + // Note: The official Quill editor doesn't seem to support multiline + // blockquotes, visually. The Delta format definitely doesn't + // support them. Each blockquote line gets its own attributed + // insertion. + final deltas = MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("This paragraph is followed by a multiline blockquote:"), + ), + ParagraphNode( + id: "2", + text: AttributedText("This is a blockquote\nThis is line two\nThis is line three"), + metadata: const { + "blockType": blockquoteAttribution, + }, + ), + ], + ).toQuillDeltas(); + + final expectedDeltas = Delta.fromJson([ + {"insert": "This paragraph is followed by a multiline blockquote:\nThis is a blockquote"}, + { + "attributes": {"blockquote": true}, + "insert": "\n", + }, + {"insert": "This is line two"}, + { + "attributes": {"blockquote": true}, + "insert": "\n", + }, + {"insert": "This is line three"}, + { + "attributes": {"blockquote": true}, + "insert": "\n", + }, + ]); + + expect(deltas, quillDocumentEquivalentTo(expectedDeltas)); + }); + + test("multiline code block", () { + // Note: Quill can display multiple lines in a single code block, but + // Delta serializes each of those lines as separate, attributed + // insertions. + final deltas = MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("This paragraph is followed by a multiline code block:"), + ), + ParagraphNode( + id: "2", + text: AttributedText("This is a code block\nThis is line two\nThis is line three"), + metadata: const { + "blockType": codeAttribution, + }, + ), + ], + ).toQuillDeltas(); + + final expectedDeltas = Delta.fromJson([ + {"insert": "This paragraph is followed by a multiline code block:\nThis is a code block"}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n", + }, + {"insert": "This is line two"}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n", + }, + {"insert": "This is line three"}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n", + }, + ]); + + expect(deltas, quillDocumentEquivalentTo(expectedDeltas)); + }); + + test("all text blocks and styles", () { + final deltas = createAllTextStylesSuperEditorDocument().toQuillDeltas(); + final expectedDeltas = Delta.fromJson(allTextStylesDeltaDocument); + + expect(deltas, quillDocumentEquivalentTo(expectedDeltas)); + }); + }); + + group("media >", () { + test("image", () { + final deltas = parseQuillDeltaDocument({ + "ops": [ + {"insert": "This is paragraph 1\n"}, + { + "insert": {"image": "https://quilljs.com/assets/images/icon.png"}, + }, + {"insert": "This is paragraph 2\n"}, + ] + }).toQuillDeltas(); + + expect(deltas.operations, [ + Operation.insert("This is paragraph 1\n"), + Operation.insert({ + "image": "https://quilljs.com/assets/images/icon.png", + }), + Operation.insert("This is paragraph 2\n"), + ]); + }); + + test("video", () { + final deltas = parseQuillDeltaDocument({ + "ops": [ + {"insert": "This is paragraph 1\n"}, + { + "insert": {"video": "https://quilljs.com/assets/videos/video.mp4"}, + }, + {"insert": "This is paragraph 2\n"}, + ] + }).toQuillDeltas(); + + expect(deltas.operations, [ + Operation.insert("This is paragraph 1\n"), + Operation.insert({ + "video": "https://quilljs.com/assets/videos/video.mp4", + }), + Operation.insert("This is paragraph 2\n"), + ]); + }); + + test("audio", () { + final deltas = parseQuillDeltaDocument({ + "ops": [ + {"insert": "This is paragraph 1\n"}, + { + "insert": {"audio": "https://quilljs.com/assets/audio/audio.mp3"}, + }, + {"insert": "This is paragraph 2\n"}, + ] + }).toQuillDeltas(); + + expect(deltas.operations, [ + Operation.insert("This is paragraph 1\n"), + Operation.insert({ + "audio": "https://quilljs.com/assets/audio/audio.mp3", + }), + Operation.insert("This is paragraph 2\n"), + ]); + }); + + test("file", () { + final deltas = parseQuillDeltaDocument({ + "ops": [ + {"insert": "This is paragraph 1\n"}, + { + "insert": {"file": "https://quilljs.com/assets/files/file.pdf"}, + }, + {"insert": "This is paragraph 2\n"}, + ] + }).toQuillDeltas(); + + expect(deltas.operations, [ + Operation.insert("This is paragraph 1\n"), + Operation.insert({ + "file": "https://quilljs.com/assets/files/file.pdf", + }), + Operation.insert("This is paragraph 2\n"), + ]); + }); + }); + + group("custom serializers >", () { + test("can serialize inline embeds from attributions", () { + const userMentionAttribution = _UserTagAttribution("123456"); + + final deltas = MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "Inline embed @John Smith and bold and italics", + AttributedSpans( + attributions: [ + const SpanMarker(attribution: userMentionAttribution, offset: 13, markerType: SpanMarkerType.start), + const SpanMarker(attribution: userMentionAttribution, offset: 23, markerType: SpanMarkerType.end), + const SpanMarker(attribution: boldAttribution, offset: 29, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 32, markerType: SpanMarkerType.end), + const SpanMarker(attribution: italicsAttribution, offset: 38, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 44, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ], + ).toQuillDeltas( + serializers: _serializersWithInlineEmbeds, + ); + + final expectedDeltas = Delta.fromJson([ + {"insert": "Inline embed "}, + { + "insert": { + "tag": { + "type": "user", + "userId": "123456", + "text": "@John Smith", + }, + }, + }, + {"insert": " and "}, + { + "insert": "bold", + "attributes": {"bold": true}, + }, + {"insert": " and "}, + { + "insert": "italics", + "attributes": {"italic": true}, + }, + {"insert": "\n"}, + ]); + + expect(deltas, quillDocumentEquivalentTo(expectedDeltas)); + }); + + group("inline placeholders >", () { + test("in the middle of text", () { + final deltas = MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "Before images >< in between images >< after images.", + null, + { + 15: const _InlineImage("http://www.somedomain.com/image1.png"), + 37: const _InlineImage("http://www.somedomain.com/image2.png"), + }, + ), + ), + ], + ).toQuillDeltas( + serializers: _serializersWithInlineEmbeds, + ); + + final expectedDeltas = Delta.fromJson([ + {"insert": "Before images >"}, + { + "insert": { + "image": { + "url": "http://www.somedomain.com/image1.png", + }, + }, + }, + {"insert": "< in between images >"}, + { + "insert": { + "image": { + "url": "http://www.somedomain.com/image2.png", + }, + }, + }, + {"insert": "< after images.\n"}, + ]); + + expect(deltas, quillDocumentEquivalentTo(expectedDeltas)); + }); + + test("at the start and end of text", () { + final deltas = MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + " < Text between images > ", + null, + { + 0: const _InlineImage("http://www.somedomain.com/image1.png"), + 26: const _InlineImage("http://www.somedomain.com/image2.png"), + }, + ), + ), + ], + ).toQuillDeltas( + serializers: _serializersWithInlineEmbeds, + ); + + final expectedDeltas = Delta.fromJson([ + { + "insert": { + "image": { + "url": "http://www.somedomain.com/image1.png", + }, + }, + }, + {"insert": " < Text between images > "}, + { + "insert": { + "image": { + "url": "http://www.somedomain.com/image2.png", + }, + }, + }, + {"insert": "\n"}, + ]); + + expect(deltas, quillDocumentEquivalentTo(expectedDeltas)); + }); + + test("within attribution spans", () { + final deltas = MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "Before attribution |< text >< text >| after attribution.", + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: boldAttribution, + offset: 20, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 38, + markerType: SpanMarkerType.end, + ), + ], + ), + { + 20: const _InlineImage("http://www.somedomain.com/image1.png"), + 29: const _InlineImage("http://www.somedomain.com/image2.png"), + 38: const _InlineImage("http://www.somedomain.com/image3.png"), + }, + ), + ), + ], + ).toQuillDeltas( + serializers: _serializersWithInlineEmbeds, + ); + + final expectedDeltas = Delta.fromJson([ + {"insert": "Before attribution |"}, + { + "insert": { + "image": { + "url": "http://www.somedomain.com/image1.png", + }, + }, + "attributes": {"bold": true}, + }, + { + "insert": "< text >", + "attributes": {"bold": true}, + }, + { + "insert": { + "image": { + "url": "http://www.somedomain.com/image2.png", + }, + }, + "attributes": {"bold": true}, + }, + { + "insert": "< text >", + "attributes": {"bold": true}, + }, + { + "insert": { + "image": { + "url": "http://www.somedomain.com/image3.png", + }, + }, + "attributes": {"bold": true}, + }, + {"insert": "| after attribution.\n"}, + ]); + + expect(deltas, quillDocumentEquivalentTo(expectedDeltas)); + }); + }); + + test("doesn't merge custom block with previous delta", () { + final deltas = MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This is a regular paragraph.", + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + "This is a banner (a custom block style).", + ), + metadata: const { + 'blockType': _BannerAttribution('red'), + }, + ), + ], + ).toQuillDeltas( + serializers: [ + const _BannerDeltaSerializer(), + ...defaultDeltaSerializers, + ], + ); + + final expectedDeltas = Delta.fromJson([ + {"insert": "This is a regular paragraph.\nThis is a banner (a custom block style)."}, + { + "insert": "\n", + "attributes": { + "banner-color": "red", + }, + }, + ]); + + expect(deltas, quillDocumentEquivalentTo(expectedDeltas)); + }); + }); + }); +} + +const _serializersWithInlineEmbeds = [ + ParagraphDeltaSerializer(inlineEmbedDeltaSerializers: _inlineEmbedSerializers), + ListItemDeltaSerializer(inlineEmbedDeltaSerializers: _inlineEmbedSerializers), + TaskDeltaSerializer(inlineEmbedDeltaSerializers: _inlineEmbedSerializers), + imageDeltaSerializer, + videoDeltaSerializer, + audioDeltaSerializer, + fileDeltaSerializer, +]; + +const _inlineEmbedSerializers = [ + _InlineImageEmbedSerializer(), + _UserTagInlineEmbedSerializer(), +]; + +class _InlineImageEmbedSerializer implements InlineEmbedDeltaSerializer { + const _InlineImageEmbedSerializer(); + + @override + bool serializeText(String text, Set attributions, Delta deltas) => false; + + @override + bool serializeInlinePlaceholder(Object placeholder, Map attributes, Delta deltas) { + if (placeholder is! _InlineImage) { + return false; + } + + deltas.operations.add( + Operation.insert( + { + "image": { + "url": placeholder.url, + }, + }, + attributes.isNotEmpty ? attributes : null, + ), + ); + + return true; + } +} + +class _InlineImage { + const _InlineImage(this.url); + + final String url; +} + +class _UserTagInlineEmbedSerializer implements InlineEmbedDeltaSerializer { + const _UserTagInlineEmbedSerializer(); + + @override + bool serializeText(String text, Set attributions, Delta deltas) { + final userTag = attributions.whereType<_UserTagAttribution>().firstOrNull; + if (userTag == null) { + return false; + } + + deltas.operations.add( + Operation.insert({ + "tag": { + "type": "user", + "userId": userTag.userId, + "text": text, + }, + }), + ); + + return true; + } + + @override + bool serializeInlinePlaceholder(Object placeholder, Map attributes, Delta deltas) => false; +} + +class _UserTagAttribution implements Attribution { + const _UserTagAttribution(this.userId); + + @override + String get id => userId; + + final String userId; + + @override + bool canMergeWith(Attribution other) { + return other is _UserTagAttribution && userId == other.userId; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _UserTagAttribution && runtimeType == other.runtimeType && userId == other.userId; + + @override + int get hashCode => userId.hashCode; +} + +class _BannerDeltaSerializer extends TextBlockDeltaSerializer { + const _BannerDeltaSerializer(); + + @override + Map getBlockFormats(TextNode textBlock) { + final bannerAttribution = textBlock.metadata['blockType']; + if (bannerAttribution is! _BannerAttribution) { + return super.getBlockFormats(textBlock); + } + + final formats = super.getBlockFormats(textBlock); + formats['banner-color'] = bannerAttribution.color; + + return formats; + } +} + +class _BannerAttribution implements Attribution { + const _BannerAttribution(this.color); + + @override + String get id => "banner-$color"; + + final String color; + + @override + bool canMergeWith(Attribution other) { + if (other is! _BannerAttribution) { + return false; + } + + return color == other.color; + } +} diff --git a/super_editor/test/infrastructure/serialization/quill/test_documents.dart b/super_editor/test/infrastructure/serialization/quill/test_documents.dart new file mode 100644 index 0000000000..3be523aa9a --- /dev/null +++ b/super_editor/test/infrastructure/serialization/quill/test_documents.dart @@ -0,0 +1,302 @@ +import 'package:flutter/painting.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A Quill Delta document that uses every type of standard editor styles. +/// +/// This is the Quill version of [createAllTextStylesSuperEditorDocument]. +const allTextStylesDeltaDocument = [ + {"insert": "All Text Styles"}, + { + "attributes": {"header": 1}, + "insert": "\n" + }, + {"insert": "Samples of styles: "}, + { + "attributes": {"bold": true}, + "insert": "bold" + }, + {"insert": ", "}, + { + "attributes": {"italic": true}, + "insert": "italics" + }, + {"insert": ", "}, + { + "attributes": {"underline": true}, + "insert": "underline" + }, + {"insert": ", "}, + { + "attributes": {"strike": true}, + "insert": "strikethrough" + }, + {"insert": ", "}, + { + "attributes": {"color": "#e60000"}, + "insert": "text color" + }, + {"insert": ", "}, + { + "attributes": {"background": "#e60000"}, + "insert": "background color" + }, + {"insert": ", "}, + { + "attributes": {"font": "serif"}, + "insert": "font change" + }, + {"insert": ", "}, + { + "attributes": {"link": "google.com"}, + "insert": "link" + }, + {"insert": "\n\nLeft aligned\nCenter aligned"}, + { + "attributes": {"align": "center"}, + "insert": "\n" + }, + {"insert": "Right aligned"}, + { + "attributes": {"align": "right"}, + "insert": "\n" + }, + {"insert": "Justified"}, + { + "attributes": {"align": "justify"}, + "insert": "\n" + }, + {"insert": "\nOrdered item 1"}, + { + "attributes": {"list": "ordered"}, + "insert": "\n" + }, + {"insert": "Ordered item 2"}, + { + "attributes": {"list": "ordered"}, + "insert": "\n" + }, + {"insert": "\nUnordered item 1"}, + { + "attributes": {"list": "bullet"}, + "insert": "\n" + }, + {"insert": "Unordered item 2"}, + { + "attributes": {"list": "bullet"}, + "insert": "\n" + }, + {"insert": "\nI'm a task that's incomplete"}, + { + "attributes": {"list": "unchecked"}, + "insert": "\n" + }, + {"insert": "I'm a task that's complete"}, + { + "attributes": {"list": "checked"}, + "insert": "\n" + }, + {"insert": "\nI'm an indented paragraph at level 1"}, + { + "attributes": {"indent": 1}, + "insert": "\n" + }, + {"insert": "I'm a paragraph indented at level 2"}, + { + "attributes": {"indent": 2}, + "insert": "\n" + }, + {"insert": "\nSome content"}, + { + "attributes": {"script": "sub"}, + "insert": "This is a subscript" + }, + {"insert": "\nSome content"}, + { + "attributes": {"script": "super"}, + "insert": "This is a superscript" + }, + {"insert": "\n\n"}, + { + "attributes": {"size": "huge"}, + "insert": "HUGE" + }, + {"insert": "\n"}, + { + "attributes": {"size": "large"}, + "insert": "Large" + }, + {"insert": "\n"}, + { + "attributes": {"size": "small"}, + "insert": "small" + }, + {"insert": "\n\nThis is a blockquote"}, + { + "attributes": {"blockquote": true}, + "insert": "\n" + }, + // Notice: A multiline code block, while rendered as a single block, is + // encoded as independently attributed deltas. + {"insert": "\nThis is a code block"}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n" + }, + {"insert": "That spans two lines."}, + { + "attributes": {"code-block": "plain"}, + "insert": "\n" + } +]; + +/// A Super Editor document that uses every type editor style that appears in +/// a standard Quill editor. +/// +/// This is the Super Editor version of [allTextStylesDeltaDocument]. +MutableDocument createAllTextStylesSuperEditorDocument() { + return MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("All Text Styles"), + metadata: const { + "blockType": header1Attribution, + }, + ), + ParagraphNode( + id: "2", + text: AttributedText( + "Samples of styles: bold, italics, underline, strikethrough, text color, background color, font change, link", + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 19, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 22, markerType: SpanMarkerType.end), + const SpanMarker(attribution: italicsAttribution, offset: 25, markerType: SpanMarkerType.start), + const SpanMarker(attribution: italicsAttribution, offset: 31, markerType: SpanMarkerType.end), + const SpanMarker(attribution: underlineAttribution, offset: 34, markerType: SpanMarkerType.start), + const SpanMarker(attribution: underlineAttribution, offset: 42, markerType: SpanMarkerType.end), + const SpanMarker(attribution: strikethroughAttribution, offset: 45, markerType: SpanMarkerType.start), + const SpanMarker(attribution: strikethroughAttribution, offset: 57, markerType: SpanMarkerType.end), + const SpanMarker( + attribution: ColorAttribution(Color(0xFFe60000)), offset: 60, markerType: SpanMarkerType.start), + const SpanMarker( + attribution: ColorAttribution(Color(0xFFe60000)), offset: 69, markerType: SpanMarkerType.end), + const SpanMarker( + attribution: BackgroundColorAttribution(Color(0xFFe60000)), + offset: 72, + markerType: SpanMarkerType.start), + const SpanMarker( + attribution: BackgroundColorAttribution(Color(0xFFe60000)), + offset: 87, + markerType: SpanMarkerType.end), + const SpanMarker( + attribution: FontFamilyAttribution("serif"), offset: 90, markerType: SpanMarkerType.start), + const SpanMarker( + attribution: FontFamilyAttribution("serif"), offset: 100, markerType: SpanMarkerType.end), + const SpanMarker( + attribution: LinkAttribution("google.com"), offset: 103, markerType: SpanMarkerType.start), + const SpanMarker(attribution: LinkAttribution("google.com"), offset: 106, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ParagraphNode(id: "3", text: AttributedText("")), + ParagraphNode(id: "4", text: AttributedText("Left aligned")), + ParagraphNode(id: "5", text: AttributedText("Center aligned"), metadata: const {"textAlign": "center"}), + ParagraphNode(id: "6", text: AttributedText("Right aligned"), metadata: const {"textAlign": "right"}), + ParagraphNode(id: "7", text: AttributedText("Justified"), metadata: const {"textAlign": "justify"}), + ParagraphNode(id: "8", text: AttributedText("")), + ListItemNode(id: "9", itemType: ListItemType.ordered, text: AttributedText("Ordered item 1")), + ListItemNode(id: "10", itemType: ListItemType.ordered, text: AttributedText("Ordered item 2")), + ParagraphNode(id: "11", text: AttributedText("")), + ListItemNode(id: "12", itemType: ListItemType.unordered, text: AttributedText("Unordered item 1")), + ListItemNode(id: "13", itemType: ListItemType.unordered, text: AttributedText("Unordered item 2")), + ParagraphNode(id: "14", text: AttributedText("")), + TaskNode(id: "15", text: AttributedText("I'm a task that's incomplete"), isComplete: false), + TaskNode(id: "16", text: AttributedText("I'm a task that's complete"), isComplete: true), + ParagraphNode(id: "17", text: AttributedText("")), + ParagraphNode(id: "18", text: AttributedText("I'm an indented paragraph at level 1"), indent: 1), + ParagraphNode(id: "19", text: AttributedText("I'm a paragraph indented at level 2"), indent: 2), + ParagraphNode(id: "20", text: AttributedText("")), + ParagraphNode( + id: "21", + text: AttributedText( + "Some contentThis is a subscript", + AttributedSpans( + attributions: [ + const SpanMarker(attribution: subscriptAttribution, offset: 12, markerType: SpanMarkerType.start), + const SpanMarker(attribution: subscriptAttribution, offset: 30, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ParagraphNode( + id: "22", + text: AttributedText( + "Some contentThis is a superscript", + AttributedSpans( + attributions: [ + const SpanMarker(attribution: superscriptAttribution, offset: 12, markerType: SpanMarkerType.start), + const SpanMarker(attribution: superscriptAttribution, offset: 32, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ParagraphNode(id: "23", text: AttributedText("")), + ParagraphNode( + id: "24", + text: AttributedText( + "HUGE", + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: NamedFontSizeAttribution("huge"), offset: 0, markerType: SpanMarkerType.start), + const SpanMarker( + attribution: NamedFontSizeAttribution("huge"), offset: 3, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ParagraphNode( + id: "25", + text: AttributedText( + "Large", + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: NamedFontSizeAttribution("large"), offset: 0, markerType: SpanMarkerType.start), + const SpanMarker( + attribution: NamedFontSizeAttribution("large"), offset: 4, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ParagraphNode( + id: "26", + text: AttributedText( + "small", + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: NamedFontSizeAttribution("small"), offset: 0, markerType: SpanMarkerType.start), + const SpanMarker( + attribution: NamedFontSizeAttribution("small"), offset: 4, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ParagraphNode(id: "27", text: AttributedText("")), + ParagraphNode( + id: "28", + text: AttributedText("This is a blockquote"), + metadata: const {"blockType": blockquoteAttribution}, + ), + ParagraphNode(id: "29", text: AttributedText("")), + ParagraphNode( + id: "30", + text: AttributedText("This is a code block\nThat spans two lines."), + metadata: const {"blockType": codeAttribution}, + ), + ], + ); +} diff --git a/super_editor/test/src/infrastructure/strings_test.dart b/super_editor/test/infrastructure/strings_test.dart similarity index 100% rename from super_editor/test/src/infrastructure/strings_test.dart rename to super_editor/test/infrastructure/strings_test.dart diff --git a/super_editor/test/src/_document_test_tools.dart b/super_editor/test/src/_document_test_tools.dart deleted file mode 100644 index 5799e9d9b5..0000000000 --- a/super_editor/test/src/_document_test_tools.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:mockito/mockito.dart'; -import 'package:super_editor/super_editor.dart'; - -/// Fake [DocumentLayout], intended for tests that interact with -/// a logical [DocumentLayout] but do not depend upon a real -/// widget tree with a real [DocumentLayout] implementation. -class FakeDocumentLayout with Mock implements DocumentLayout {} - -EditContext createEditContext({ - required MutableDocument document, - DocumentEditor? documentEditor, - DocumentLayout? documentLayout, - DocumentComposer? documentComposer, - CommonEditorOperations? commonOps, -}) { - final editor = documentEditor ?? DocumentEditor(document: document); - DocumentLayout layoutResolver() => documentLayout ?? FakeDocumentLayout(); - final composer = documentComposer ?? DocumentComposer(); - - return EditContext( - editor: editor, - getDocumentLayout: layoutResolver, - composer: composer, - commonOps: commonOps ?? - CommonEditorOperations( - editor: editor, - composer: composer, - documentLayoutResolver: layoutResolver, - ), - ); -} - -Document createExampleDocument() { - return MutableDocument( - nodes: [ - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: 'Example Document', - ), - metadata: { - 'blockType': header1Attribution, - }, - ), - HorizontalRuleNode(id: DocumentEditor.createNodeId()), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', - ), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: - 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', - ), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: - 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', - ), - ), - ], - ); -} diff --git a/super_editor/test/src/_text_entry_test_tools.dart b/super_editor/test/src/_text_entry_test_tools.dart deleted file mode 100644 index 743497a8ae..0000000000 --- a/super_editor/test/src/_text_entry_test_tools.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/services.dart'; - -/// Concrete version of [RawKeyEvent] used to manually simulate -/// a specific key event sent from Flutter. -/// -/// [FakeRawKeyEvent] does not validate its configuration. It will -/// reflect whatever information you provide in the constructor, even -/// if that configuration couldn't exist in reality. -/// -/// [FakeRawKeyEvent] might lack some controls or functionality. It's -/// a tool designed to meet the needs of specific tests. If new tests -/// require broader functionality, then that functionality should be -/// added to [FakeRawKeyEvent] and other associated classes. -class FakeRawKeyEvent extends RawKeyEvent { - const FakeRawKeyEvent({ - required RawKeyEventData data, - String? character, - }) : super(data: data, character: character); - - @override - bool get isMetaPressed => data.isMetaPressed; - - @override - bool get isAltPressed => data.isAltPressed; - - @override - bool get isControlPressed => data.isControlPressed; - - @override - bool get isShiftPressed => data.isShiftPressed; -} - -/// Concrete version of [FakeRawKeyEventData] used to manually simulate -/// a specific key event sent from Flutter. -/// -/// [FakeRawKeyEventData] does not validate its configuration. It will -/// reflect whatever information you provide in the constructor, even -/// if that configuration couldn't exist in reality. -/// -/// [FakeRawKeyEventData] might lack some controls or functionality. It's -/// a tool designed to meet the needs of specific tests. If new tests -/// require broader functionality, then that functionality should be -/// added to [FakeRawKeyEventData] and other associated classes. -class FakeRawKeyEventData extends RawKeyEventData { - const FakeRawKeyEventData({ - this.keyLabel = 'fake_key_event', - required this.logicalKey, - required this.physicalKey, - this.isMetaPressed = false, - this.isControlPressed = false, - this.isAltPressed = false, - this.isModifierKeyPressed = false, - this.isShiftPressed = false, - }); - - @override - final String keyLabel; - - @override - final LogicalKeyboardKey logicalKey; - - @override - final PhysicalKeyboardKey physicalKey; - - final bool isModifierKeyPressed; - - @override - final bool isMetaPressed; - - @override - final bool isAltPressed; - - @override - final bool isControlPressed; - - @override - final bool isShiftPressed; - - @override - bool isModifierPressed(ModifierKey key, {KeyboardSide side = KeyboardSide.any}) { - return isModifierKeyPressed; - } - - @override - KeyboardSide? getModifierSide(ModifierKey key) { - throw UnimplementedError(); - } -} diff --git a/super_editor/test/src/default_editor/auto_scroll_test.dart b/super_editor/test/src/default_editor/auto_scroll_test.dart deleted file mode 100644 index 12fcd0bd83..0000000000 --- a/super_editor/test/src/default_editor/auto_scroll_test.dart +++ /dev/null @@ -1,291 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_test_robots/flutter_test_robots.dart'; -import 'package:super_editor/src/infrastructure/blinking_caret.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_editor/super_editor_test.dart'; - -import '../../super_editor/document_test_tools.dart'; -import '../../test_tools.dart'; -import '../_document_test_tools.dart'; - -void main() { - group('SuperEditor', () { - group('auto-scroll', () { - const screenSizeWithoutKeyboard = Size(390.0, 844.0); - const screenSizeWithKeyboard = Size(390.0, 544.0); - const keyboardExpansionFrameCount = 60; - final shrinkPerFrame = - (screenSizeWithoutKeyboard.height - screenSizeWithKeyboard.height) / keyboardExpansionFrameCount; - - testWidgets('on Android, keeps caret visible when keyboard appears', (WidgetTester tester) async { - tester.binding.window - ..physicalSizeTestValue = screenSizeWithoutKeyboard - ..platformDispatcher.textScaleFactorTestValue = 1.0 - ..devicePixelRatioTestValue = 1.0; - - await tester.pumpWidget( - const _SliverTestEditor( - gestureMode: DocumentGestureMode.android, - ), - ); - - // Select text near the bottom of the screen, where the keyboard will appear - final tapPosition = Offset(screenSizeWithoutKeyboard.width / 2, screenSizeWithoutKeyboard.height - 1); - await tester.tapAt(tapPosition); - - // Shrink the screen height, as if the keyboard appeared. - await _simulateKeyboardAppearance( - tester: tester, - initialScreenSize: screenSizeWithoutKeyboard, - shrinkPerFrame: shrinkPerFrame, - frameCount: keyboardExpansionFrameCount, - ); - - // Ensure that the editor auto-scrolled to keep the caret visible. - // TODO: there are 2 `BlinkingCaret` at the same time. There should be only 1 caret - final caretFinder = find.byType(BlinkingCaret); - final caretOffset = tester.getBottomLeft(caretFinder.last); - - // The default trailing boundary of the default `SuperEditor` - const trailingBoundary = 54.0; - - // The caret should be at the trailing boundary, within a small margin of error - expect(caretOffset.dy, lessThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary)); - expect(caretOffset.dy, greaterThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary)); - }); - - testWidgets('on iOS, keeps caret visible when keyboard appears', (WidgetTester tester) async { - tester.binding.window - ..physicalSizeTestValue = screenSizeWithoutKeyboard - ..platformDispatcher.textScaleFactorTestValue = 1.0 - ..devicePixelRatioTestValue = 1.0; - - await tester.pumpWidget( - const _SliverTestEditor( - gestureMode: DocumentGestureMode.iOS, - ), - ); - - // Select text near the bottom of the screen, where the keyboard will appear - final tapPosition = Offset(screenSizeWithoutKeyboard.width / 2, screenSizeWithoutKeyboard.height - 1); - await tester.tapAt(tapPosition); - - // Shrink the screen height, as if the keyboard appeared. - await _simulateKeyboardAppearance( - tester: tester, - initialScreenSize: screenSizeWithoutKeyboard, - shrinkPerFrame: shrinkPerFrame, - frameCount: keyboardExpansionFrameCount, - ); - - // Ensure that the editor auto-scrolled to keep the caret visible. - // TODO: there are 2 `BlinkingCaret` at the same time. There should be only 1 caret - final caretFinder = find.byType(BlinkingCaret); - final caretOffset = tester.getBottomLeft(caretFinder.last); - - // The default trailing boundary of the default `SuperEditor` - const trailingBoundary = 54.0; - - // The caret should be at the trailing boundary, within a small margin of error - expect(caretOffset.dy, lessThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary)); - expect(caretOffset.dy, greaterThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary)); - }); - - testWidgetsOnAllPlatforms("doesn't jump the content when typing at the first line", (tester) async { - final scrollController = ScrollController(); - - // We use a custom stylesheet to avoid any padding, ensuring that the text - // will be close to the edge. - await tester // - .createDocument() - .withSingleParagraph() - .withScrollController(scrollController) - .withInputSource(DocumentInputSource.keyboard) - .useStylesheet( - Stylesheet( - inlineTextStyler: (Set attributions, TextStyle base) { - return base; - }, - rules: [ - StyleRule(BlockSelector.all, (document, node) { - return { - "textStyle": const TextStyle( - color: Colors.black, - ), - }; - }), - ], - ), - ) - .pump(); - - // Ensure the editor starts without any scrolling. - expect(scrollController.position.pixels, 0); - - // Place caret at the beginning of the document. - await tester.placeCaretInParagraph('1', 0); - - // Simulate the user typing. - await tester.typeKeyboardText("A"); - - // Ensure typing doesn't cause the content to jump. - expect(scrollController.position.pixels, 0); - }); - - testWidgetsOnAllPlatforms("doesn't jump the content when typing at the last line", (tester) async { - final scrollController = ScrollController(); - - // Pump an editor with a size that will know will cause it to be scrollable. - // We use a custom stylesheet to avoid any padding, ensuring that the text - // will be close to the edge. - await tester // - .createDocument() - .withSingleParagraph() - .withScrollController(scrollController) - .withInputSource(DocumentInputSource.keyboard) - .withEditorSize(const Size(600, 100)) - .useStylesheet( - Stylesheet( - inlineTextStyler: (Set attributions, TextStyle base) { - return base; - }, - rules: [ - StyleRule(BlockSelector.all, (document, node) { - return { - "textStyle": const TextStyle( - color: Colors.black, - ), - }; - }), - ], - ), - ) - .pump(); - - // Ensure the editor starts without any scrolling. - expect(scrollController.position.pixels, 0); - - // Ensure the editor is scrollable. - expect(scrollController.position.maxScrollExtent, greaterThan(0)); - - // On mobile, changing the selection isn't causing the editor - // to reveal the selection, so we manually jump to the end of the scrollable - // and then change the selection. - scrollController.position.jumpTo(scrollController.position.maxScrollExtent); - // Place caret at last line of the editor. - await tester.placeCaretInParagraph('1', 444); - - // Simulate the user typing. - await tester.typeKeyboardText("A"); - - // Ensure typing doesn't cause the content to jump. - expect(scrollController.position.pixels, scrollController.position.maxScrollExtent); - }); - }); - }); -} - -/// Displays a [SuperEditor] within a parent [Scrollable], including additional -/// content above the [SuperEditor] and additional content on top of [Scrollable]. -/// -/// By including content above the [SuperEditor], it doesn't have the same origin as the parent [Scrollable]. -/// -/// By including content on top of [Scrollable], it doesn't have the origin at [Offset.zero]. -class _SliverTestEditor extends StatefulWidget { - const _SliverTestEditor({ - Key? key, - required this.gestureMode, - }) : super(key: key); - - final DocumentGestureMode gestureMode; - - @override - State<_SliverTestEditor> createState() => _SliverTestEditorState(); -} - -class _SliverTestEditorState extends State<_SliverTestEditor> { - late Document _doc; - late DocumentEditor _docEditor; - - @override - void initState() { - super.initState(); - - _doc = createExampleDocument(); - _docEditor = DocumentEditor(document: _doc as MutableDocument); - } - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - body: Padding( - padding: const EdgeInsets.only(top: 300), - child: CustomScrollView( - slivers: [ - SliverAppBar( - title: const Text( - 'Rich Text Editor Sliver Example', - ), - expandedHeight: 200.0, - leading: const SizedBox(), - flexibleSpace: FlexibleSpaceBar( - background: Container(color: Colors.blue), - ), - ), - const SliverToBoxAdapter( - child: Text( - 'Lorem Ipsum Dolor', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ), - SliverToBoxAdapter( - child: SuperEditor( - editor: _docEditor, - stylesheet: defaultStylesheet.copyWith( - documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), - ), - gestureMode: widget.gestureMode, - inputSource: DocumentInputSource.ime, - ), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return ListTile(title: Text('$index')); - }, - ), - ), - ], - ), - ), - ), - debugShowCheckedModeBanner: false, - ); - } -} - -/// Slowly reduces window size to imitate the appearance of a keyboard. -Future _simulateKeyboardAppearance({ - required WidgetTester tester, - required Size initialScreenSize, - required double shrinkPerFrame, - required int frameCount, -}) async { - // Shrink the height of the screen, one frame at a time. - double keyboardHeight = 0.0; - for (var i = 0; i < frameCount; i++) { - // Shrink the height of the screen by a small amount. - keyboardHeight += shrinkPerFrame; - final currentScreenSize = (initialScreenSize - Offset(0, keyboardHeight)) as Size; - tester.binding.window.physicalSizeTestValue = currentScreenSize; - - // Let the scrolling system auto-scroll, as desired. - await tester.pumpAndSettle(); - } -} diff --git a/super_editor/test/src/default_editor/blockquote_test.dart b/super_editor/test/src/default_editor/blockquote_test.dart deleted file mode 100644 index 27938d6983..0000000000 --- a/super_editor/test/src/default_editor/blockquote_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_text_layout/super_text_layout.dart'; - -void main() { - group('Blockquote', () { - testWidgets("applies the textStyle from SuperEditor's styleSheet", (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SuperEditor( - editor: DocumentEditor(document: _singleBlockquoteDoc()), - stylesheet: _styleSheet, - ), - ), - ), - ); - - // Ensure that the textStyle from the styleSheet was applied - expect(find.byType(LayoutAwareRichText), findsOneWidget); - final richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; - expect(richText.text.style!.color, Colors.blue); - expect(richText.text.style!.fontSize, 16); - }); - }); -} - -MutableDocument _singleBlockquoteDoc() => MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: "This is a blockquote."), - metadata: {'blockType': blockquoteAttribution}, - ) - ], -); - -TextStyle _inlineTextStyler(Set attributions, TextStyle base) => base; - -final _styleSheet = Stylesheet( - inlineTextStyler: _inlineTextStyler, - rules: [ - StyleRule( - const BlockSelector("blockquote"), - (doc, docNode) { - return { - "textStyle": const TextStyle( - color: Colors.blue, - fontSize: 16 - ), - }; - }, - ), - ], -); \ No newline at end of file diff --git a/super_editor/test/src/default_editor/document_input_ime_test.dart b/super_editor/test/src/default_editor/document_input_ime_test.dart deleted file mode 100644 index 47971340a2..0000000000 --- a/super_editor/test/src/default_editor/document_input_ime_test.dart +++ /dev/null @@ -1,379 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_test_robots/flutter_test_robots.dart'; -import 'package:logging/logging.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_editor/super_editor_test.dart'; - -import '../../super_editor/document_test_tools.dart'; -import '../../test_tools.dart'; -import '../_document_test_tools.dart'; -import '../../super_editor/test_documents.dart'; - -void main() { - group('IME input', () { - group('delta use-cases', () { - test('can handle an auto-inserted period', () { - // On iOS, adding 2 spaces causes the two spaces to be replaced by a - // period and a space. This test applies the same type and order of deltas - // that were observed on iOS. - // - // Previously, we had a bug where the period was appearing after the - // 2nd space, instead of between the two spaces. This test prevents - // that regression. - final document = MutableDocument(nodes: [ - ParagraphNode( - id: "1", - text: AttributedText(text: "This is a sentence"), - ), - ]); - final editor = DocumentEditor(document: document); - final composer = DocumentComposer( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 18), - ), - ), - ); - final commonOps = CommonEditorOperations( - editor: editor, - composer: composer, - documentLayoutResolver: () => FakeDocumentLayout(), - ); - final softwareKeyboardHandler = SoftwareKeyboardHandler( - editor: editor, - composer: composer, - commonOps: commonOps, - ); - - softwareKeyboardHandler.applyDeltas([ - const TextEditingDeltaInsertion( - textInserted: ' ', - insertionOffset: 18, - selection: TextSelection.collapsed(offset: 19), - composing: TextRange(start: -1, end: -1), - oldText: 'This is a sentence', - ), - ]); - softwareKeyboardHandler.applyDeltas([ - const TextEditingDeltaReplacement( - oldText: 'This is a sentence ', - replacementText: '.', - replacedRange: TextRange(start: 18, end: 19), - selection: TextSelection.collapsed(offset: 19), - composing: TextRange(start: -1, end: -1), - ), - ]); - softwareKeyboardHandler.applyDeltas([ - const TextEditingDeltaInsertion( - textInserted: ' ', - insertionOffset: 19, - selection: TextSelection.collapsed(offset: 20), - composing: TextRange(start: -1, end: -1), - oldText: 'This is a sentence.', - ), - ]); - - expect((document.nodes.first as ParagraphNode).text.text, "This is a sentence. "); - }); - - testWidgets('can type compound character in an empty paragraph', (tester) async { - // Inserting special characters, or compound characters, like ü, requires - // multiple key presses, which are combined by the IME, based on the - // composing region. - // - // A blank paragraph is serialized with a leading ". " to trick IMEs into - // auto-capitalizing the first character the user types, while still reporting - // a `backspace` operation, if the user presses backspace on a software keyboard. - // - // This test ensures that when we go from an empty paragraph with a hidden ". ", to - // a character with a composing region, like "¨", we report the correct composing region. - // For example, due to our hidden ". ", when the user enters a "¨", the IME thinks - // the composing region is [2,3], like ". ¨", but the text is actually "¨", so we - // need to adjust the composing region to [0,1]. - final editContext = createEditContext( - // Use a two-paragraph document so that the selection in the 2nd - // paragraph sends a hidden placeholder to the IME for backspace. - document: twoParagraphEmptyDoc(), - documentComposer: DocumentComposer( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - // Start the caret in the 2nd paragraph so that we send a - // hidden placeholder to the IME to report backspaces. - nodeId: "2", - nodePosition: TextNodePosition( - offset: 0, - ), - ), - ), - ), - ); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SuperEditor( - editor: editContext.editor, - composer: editContext.composer, - inputSource: DocumentInputSource.ime, - gestureMode: DocumentGestureMode.mouse, - autofocus: true, - ), - ), - ), - ); - - // Send the deltas that should produce a ü. - // - // We have to use implementation details to send the simulated IME deltas - // because Flutter doesn't have any testing tools for IME deltas. - final imeInteractor = find.byType(DocumentImeInteractor).evaluate().first; - final deltaClient = (imeInteractor as StatefulElement).state as DeltaTextInputClient; - - // Ensure that the delta client starts with the expected invisible placeholder - // characters. - expect(deltaClient.currentTextEditingValue!.text, ". "); - expect(deltaClient.currentTextEditingValue!.selection, const TextSelection.collapsed(offset: 2)); - expect(deltaClient.currentTextEditingValue!.composing, const TextRange(start: -1, end: -1)); - - // Insert the "opt+u" character. - deltaClient.updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: ". ", - textInserted: "¨", - insertionOffset: 2, - selection: TextSelection.collapsed(offset: 3), - composing: TextRange(start: 2, end: 3), - ), - ]); - - // Ensure that the empty paragraph now reads "¨". - expect((editContext.editor.document.nodes[1] as ParagraphNode).text.text, "¨"); - - // Ensure that the reported composing region respects the removal of the - // invisible placeholder characters. THIS IS WHERE THE ORIGINAL BUG HAPPENED. - expect(deltaClient.currentTextEditingValue!.text, "¨"); - expect(deltaClient.currentTextEditingValue!.composing, const TextRange(start: 0, end: 1)); - - // Insert the "u" character to create the compound character. - deltaClient.updateEditingValueWithDeltas([ - const TextEditingDeltaReplacement( - oldText: "¨", - replacementText: "ü", - replacedRange: TextRange(start: 0, end: 1), - selection: TextSelection.collapsed(offset: 1), - composing: TextRange(start: -1, end: -1), - ), - ]); - - // Ensure that the empty paragraph now reads "ü". - expect((editContext.editor.document.nodes[1] as ParagraphNode).text.text, "ü"); - }); - }); - - group('text serialization and selected content', () { - test('within a single node is reported as a TextEditingValue', () { - const text = "This is a paragraph of text."; - - _expectTextEditingValue( - actualTextEditingValue: DocumentImeSerializer( - MutableDocument(nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: text)), - ]), - const DocumentSelection( - base: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 10), - ), - extent: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 19), - ), - ), - ).toTextEditingValue(), - expectedTextWithSelection: "This is a |paragraph| of text.", - ); - }); - - test('two text nodes is reported as a TextEditingValue', () { - const text1 = "This is the first paragraph of text."; - const text2 = "This is the second paragraph of text."; - - _expectTextEditingValue( - actualTextEditingValue: DocumentImeSerializer( - MutableDocument(nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: text1)), - ParagraphNode(id: "2", text: AttributedText(text: text2)), - ]), - const DocumentSelection( - base: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 12), - ), - extent: DocumentPosition( - nodeId: "2", - nodePosition: TextNodePosition(offset: 28), - ), - ), - ).toTextEditingValue(), - expectedTextWithSelection: "This is the |first paragraph of text.\nThis is the second paragraph| of text.", - ); - }); - - test('text with internal non-text reported as a TextEditingValue', () { - const text = "This is a paragraph of text."; - - _expectTextEditingValue( - actualTextEditingValue: DocumentImeSerializer( - MutableDocument(nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: text)), - HorizontalRuleNode(id: "2"), - ParagraphNode(id: "3", text: AttributedText(text: text)), - ]), - const DocumentSelection( - base: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 10), - ), - extent: DocumentPosition( - nodeId: "3", - nodePosition: TextNodePosition(offset: 19), - ), - ), - ).toTextEditingValue(), - expectedTextWithSelection: "This is a |paragraph of text.\n~\nThis is a paragraph| of text.", - ); - }); - - test('text with non-text end-caps reported as a TextEditingValue', () { - const text = "This is the first paragraph of text."; - - _expectTextEditingValue( - actualTextEditingValue: DocumentImeSerializer( - MutableDocument(nodes: [ - HorizontalRuleNode(id: "1"), - ParagraphNode(id: "2", text: AttributedText(text: text)), - HorizontalRuleNode(id: "3"), - ]), - const DocumentSelection( - base: DocumentPosition( - nodeId: "1", - nodePosition: UpstreamDownstreamNodePosition.upstream(), - ), - extent: DocumentPosition( - nodeId: "3", - nodePosition: UpstreamDownstreamNodePosition.downstream(), - ), - ), - ).toTextEditingValue(), - expectedTextWithSelection: "|~\nThis is the first paragraph of text.\n~|", - ); - }); - }); - - group('typing characters near a link', () { - testWidgetsOnMobile('does not expand the link when inserting before the link', (tester) async { - // Configure and render a document. - await tester // - .createDocument() - .withCustomContent(_singleParagraphWithLinkDoc()) - .pump(); - - // Place the caret at the start of the link. - await tester.placeCaretInParagraph('1', 0); - - // Type characters before the link using the IME - await tester.ime.typeText("Go to ", getter: imeClientGetter); - - // Ensure that the link is unchanged - expect( - SuperEditorInspector.findDocument(), - equalsMarkdown("Go to [https://google.com](https://google.com)"), - ); - }); - - testWidgetsOnMobile('does not expand the link when inserting after the link', (tester) async { - // Configure and render a document. - await tester // - .createDocument() - .withCustomContent(_singleParagraphWithLinkDoc()) - .pump(); - - // Place the caret at the end of the link. - await tester.placeCaretInParagraph('1', 18); - - // Type characters after the link using the IME - await tester.ime.typeText(" to learn anything", getter: imeClientGetter); - - // Ensure that the link is unchanged - expect( - SuperEditorInspector.findDocument(), - equalsMarkdown("[https://google.com](https://google.com) to learn anything"), - ); - }); - }); - }); -} - -/// Expects that the given [expectedTextWithSelection] corresponds to a -/// `TextEditingValue` that matches [actualTextEditingValue]. -/// -/// By combining the expected text with the expected selection into a formatted -/// `String`, this method provides a naturally readable expectation, as opposed -/// to a `TextSelection` with indices. For example, if the expected selection is -/// `TextSelection(base: 10, extent: 19)`, what segment of text does that include? -/// Instead, the caller provides a formatted `String`, like "Here is so|me text w|ith selection". -/// -/// [expectedTextWithSelection] represents the expected text, and the expected -/// selection, all in one. The text within [expectedTextWithSelection] that -/// should be selected should be surrounded with "|" vertical bars. -/// -/// Example: -/// -/// This is expected text, and |this is the expected selection|. -/// -/// This method doesn't work with text that actually contains "|" vertical bars. -void _expectTextEditingValue({ - required String expectedTextWithSelection, - required TextEditingValue actualTextEditingValue, -}) { - final selectionStartIndex = expectedTextWithSelection.indexOf("|"); - final selectionEndIndex = - expectedTextWithSelection.indexOf("|", selectionStartIndex + 1) - 1; // -1 to account for the selection start "|" - final expectedText = expectedTextWithSelection.replaceAll("|", ""); - final expectedSelection = TextSelection(baseOffset: selectionStartIndex, extentOffset: selectionEndIndex); - - expect( - actualTextEditingValue, - TextEditingValue(text: expectedText, selection: expectedSelection), - ); -} - -MutableDocument _singleParagraphWithLinkDoc() { - return MutableDocument( - nodes: [ - ParagraphNode( - id: "1", - text: AttributedText( - text: "https://google.com", - spans: AttributedSpans( - attributions: [ - SpanMarker( - attribution: LinkAttribution(url: Uri.parse('https://google.com')), - offset: 0, - markerType: SpanMarkerType.start, - ), - SpanMarker( - attribution: LinkAttribution(url: Uri.parse('https://google.com')), - offset: 17, - markerType: SpanMarkerType.end, - ), - ], - ), - ), - ) - ], - ); -} diff --git a/super_editor/test/src/default_editor/document_keyboard_actions_test.dart b/super_editor/test/src/default_editor/document_keyboard_actions_test.dart deleted file mode 100644 index 41ee189168..0000000000 --- a/super_editor/test/src/default_editor/document_keyboard_actions_test.dart +++ /dev/null @@ -1,1065 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_test_robots/flutter_test_robots.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_editor/super_editor_test.dart'; - -import '../../super_editor/document_test_tools.dart'; -import '../../super_editor/test_documents.dart'; -import '../../test_tools.dart'; -import '../_document_test_tools.dart'; -import '../_text_entry_test_tools.dart'; - -void main() { - group( - 'Document keyboard actions', - () { - group('jumps to', () { - testWidgetsOnMac('beginning of line with CMD + LEFT ARROW', (tester) async { - // Start the user's selection somewhere after the beginning of the first - // line in the first node. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressCmdLeftArrow(); - - // Ensure that the caret moved to the beginning of the line. - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 0), - ), - ), - ); - }); - - testWidgetsOnMac('end of line with CMD + RIGHT ARROW', (tester) async { - // Start the user's selection somewhere before the end of the first line - // in the first node. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressCmdRightArrow(); - - // Ensure that the caret moved to the end of the line. This value - // is very fragile. If the text size or layout width changes, this value - // will also need to change. - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 27), - ), - ), - ); - }); - - testWidgetsOnMac('beginning of word with ALT + LEFT ARROW', (tester) async { - // Start the user's selection somewhere in the middle of a word. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressAltLeftArrow(); - - // Ensure that the caret moved to the beginning of the word. - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 6), - ), - ), - ); - }); - - testWidgetsOnMac('end of word with ALT + RIGHT ARROW', (tester) async { - // Start the user's selection somewhere in the middle of a word. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressAltRightArrow(); - - // Ensure that the caret moved to the beginning of the word. - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 11), - ), - ), - ); - }); - - testWidgetsOnLinux('preceding character with ALT + LEFT ARROW', (tester) async { - // Start the user's selection somewhere in the middle of a word. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressAltLeftArrow(); - - // Ensure that the caret moved one character to the left. - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 7), - ), - ), - ); - }); - - testWidgetsOnLinux('next character with ALT + RIGHT ARROW', (tester) async { - // Start the user's selection somewhere in the middle of a word. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressAltRightArrow(); - - // Ensure that the caret moved one character to the right - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 9), - ), - ), - ); - }); - - testWidgetsOnWindowsAndLinux('beginning of line with HOME in an auto-wrapping paragraph', (tester) async { - await _pumpAutoWrappingTestSetup(tester); - - // Place caret at the second line at "adipiscing |elit" - // We avoid placing the caret in the first line to make sure HOME doesn't move caret - // all the way to the beginning of the text - await tester.placeCaretInParagraph('1', 51); - - await tester.pressHome(); - - // Ensure that the caret moved to the beginning of the wrapped line at "|adipiscing elit" - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 40), - ), - ), - ); - }); - - testWidgetsOnWindowsAndLinux('beginning of line with HOME in a paragraph with explicit new lines', - (tester) async { - await _pumpExplicitLineBreakTestSetup(tester); - - // Place caret at the second line at "consectetur adipiscing |elit" - // We avoid placing the caret in the first line to make sure HOME doesn't move caret - // all the way to the beginning of the text - await tester.placeCaretInParagraph('1', 51); - - await tester.pressHome(); - - // Ensure that the caret moved to the beginning of the second line at "|consectetur adipiscing elit" - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 27), - ), - ), - ); - }); - - testWidgetsOnWindowsAndLinux('end of line with END in an auto-wrapping paragraph', (tester) async { - await _pumpAutoWrappingTestSetup(tester); - - // Place caret at the start of the first line - // We avoid placing the caret in the last line to make sure END doesn't move caret - // all the way to the end of the text - await tester.placeCaretInParagraph('1', 0); - - await tester.pressEnd(); - - // Ensure that the caret moved to the end of the line. This value - // is very fragile. If the text size or layout width changes, this value - // will also need to change. - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 17), - ), - ), - ); - }); - - testWidgetsOnWindowsAndLinux('end of line with END in a paragraph with explicit new lines', (tester) async { - // Configure the screen to a size big enough so there's no auto line-wrapping - await _pumpExplicitLineBreakTestSetup(tester, size: const Size(1024, 400)); - - // Place caret at the first line at "Lorem |ipsum" - // Avoid placing caret in the last line to make sure END doesn't move caret - // all the way to the end of the text - await tester.placeCaretInParagraph('1', 6); - - await tester.pressEnd(); - - // Ensure that the caret moved the end of the first line - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 26, affinity: TextAffinity.upstream), - ), - ), - ); - }); - - testWidgetsOnWindowsAndLinux('beginning of word with CTRL + LEFT ARROW', (tester) async { - // Start the user's selection somewhere in the middle of a word. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressCtlLeftArrow(); - - // Ensure that the caret moved to the beginning of the word. - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 6), - ), - ), - ); - }); - - testWidgetsOnWindowsAndLinux('end of word with CTRL + RIGHT ARROW', (tester) async { - // Start the user's selection somewhere in the middle of a word. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressCtlRightArrow(); - - // Ensure that the caret moved to the beginning of the word. - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 11), - ), - ), - ); - }); - }); - - group("does nothing", () { - testWidgetsOnWindows("with ALT + LEFT ARROW", (tester) async { - // Start the user's selection somewhere in the middle of a word. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressAltLeftArrow(); - - // Ensure that the caret didn't move - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 8), - ), - ), - ); - }); - - testWidgetsOnWindows("with ALT + RIGHT ARROW", (tester) async { - // Start the user's selection somewhere in the middle of a word. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressAltRightArrow(); - - // Ensure that the caret didn't move - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 8), - ), - ), - ); - }); - - testWidgetsOnWindowsAndLinux('with ALT + UP ARROW', (tester) async { - await _pumpExplicitLineBreakTestSetup(tester); - - // Place caret at the second line at "consectetur adipiscing |elit" - await tester.placeCaretInParagraph('1', 51); - - await tester.pressAltUpArrow(); - - // Ensure that the caret didn't move - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 51), - ), - ), - ); - }); - - testWidgetsOnWindowsAndLinux('with ALT + DOWN ARROW', (tester) async { - await _pumpExplicitLineBreakTestSetup(tester); - - // Place caret at the first line at "Lorem |ipsum" - await tester.placeCaretInParagraph('1', 6); - - await tester.pressAltDownArrow(); - - // Ensure that the caret didn't move - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 6), - ), - ), - ); - }); - }); - - group("shortcuts for Windows and Linux do nothing on mac", () { - testWidgetsOnMac('HOME', (tester) async { - // Start the user's selection somewhere after the beginning of the first - // line in the first node. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressHome(); - - // Ensure that the caret didn't move - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 8), - ), - ), - ); - }); - - testWidgetsOnMac('END', (tester) async { - // Start the user's selection somewhere after the beginning of the first - // line in the first node. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 2); - - await tester.pressEnd(); - - // Ensure that the caret didn't move - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 2), - ), - ), - ); - }); - - testWidgetsOnMac('CTRL + LEFT ARROW', (tester) async { - // Start the user's selection somewhere in the middle of a word. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressCtlLeftArrow(); - - // Ensure that the caret moved only one character to the left - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 7), - ), - ), - ); - }); - - testWidgetsOnMac('CTRL + RIGHT ARROW', (tester) async { - // Start the user's selection somewhere in the middle of a word. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressCtlRightArrow(); - - // Ensure that the caret moved only one character to the right - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 9), - ), - ), - ); - }); - }); - - group("shortcuts for Mac do nothing on Windows and Linux", () { - testWidgetsOnWindowsAndLinux('CMD + LEFT ARROW', (tester) async { - // Start the user's selection somewhere after the beginning of the first - // line in the first node. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); - - await tester.pressCmdLeftArrow(); - - // Ensure that the caret didn't move to the beginning of the line. - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 7), - ), - ), - ); - }); - - testWidgetsOnWindowsAndLinux('CMD + RIGHT ARROW', (tester) async { - // Start the user's selection somewhere before the end of the first line - // in the first node. - await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 2); - - await tester.pressCmdRightArrow(); - - // Ensure that the caret didn't move to the end of the line. - expect( - SuperEditorInspector.findDocumentSelection(), - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: 3), - ), - ), - ); - }); - }); - - group( - 'CMD + A to select all', - () { - testOnMac( - 'it does nothing when meta key is pressed but A-key is not pressed', - () { - final editContext = createEditContext(document: MutableDocument()); - var result = selectAllWhenCmdAIsPressed( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyC, - physicalKey: PhysicalKeyboardKey.keyC, - isMetaPressed: true, - isModifierKeyPressed: false, - ), - character: 'c', - ), - ); - - // The handler should pass on handling the key. - expect(result, ExecutionInstruction.continueExecution); - }, - ); - - testOnMac( - 'it does nothing when A-key is pressed but meta key is not pressed', - () { - final editContext = createEditContext(document: MutableDocument()); - var result = selectAllWhenCmdAIsPressed( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - isMetaPressed: false, - isModifierKeyPressed: false, - ), - character: 'a', - ), - ); - - // The handler should pass on handling the key. - expect(result, ExecutionInstruction.continueExecution); - }, - ); - - testOnMac( - 'it does nothing when CMD+A is pressed but the document is empty', - () { - final editContext = createEditContext(document: MutableDocument()); - var result = selectAllWhenCmdAIsPressed( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - isMetaPressed: true, - isModifierKeyPressed: false, - ), - character: 'a'), - ); - - // The handler should pass on handling the key. - expect(result, ExecutionInstruction.continueExecution); - }, - ); - - testOnMac( - 'it selects all when CMD+A is pressed with a single-node document', - () { - final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: 'paragraph', - text: AttributedText(text: 'This is some text'), - ), - ], - ), - ); - var result = selectAllWhenCmdAIsPressed( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - isMetaPressed: true, - isModifierKeyPressed: false, - ), - character: 'a', - ), - ); - - expect(result, ExecutionInstruction.haltExecution); - expect( - editContext.composer.selection!.base, - const DocumentPosition( - nodeId: 'paragraph', - nodePosition: TextNodePosition(offset: 0), - ), - ); - expect( - editContext.composer.selection!.extent, - const DocumentPosition( - nodeId: 'paragraph', - nodePosition: TextNodePosition(offset: 'This is some text'.length), - ), - ); - }, - ); - testOnMac( - 'it selects all when CMD+A is pressed with a two-node document', - () { - final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: 'paragraph_1', - text: AttributedText(text: 'This is some text'), - ), - ParagraphNode( - id: 'paragraph_2', - text: AttributedText(text: 'This is some text'), - ), - ], - ), - ); - var result = selectAllWhenCmdAIsPressed( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - isMetaPressed: true, - isModifierKeyPressed: false, - ), - character: 'a', - ), - ); - - expect(result, ExecutionInstruction.haltExecution); - expect( - editContext.composer.selection!.base, - const DocumentPosition( - nodeId: 'paragraph_1', - nodePosition: TextNodePosition(offset: 0), - ), - ); - expect( - editContext.composer.selection!.extent, - const DocumentPosition( - nodeId: 'paragraph_2', - nodePosition: TextNodePosition(offset: 'This is some text'.length), - ), - ); - }, - ); - testOnMac( - 'it selects all when CMD+A is pressed with a three-node document', - () { - final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ImageNode( - id: 'image_1', - imageUrl: 'https://fake.com/image/url.png', - ), - ParagraphNode( - id: 'paragraph', - text: AttributedText(text: 'This is some text'), - ), - ImageNode( - id: 'image_2', - imageUrl: 'https://fake.com/image/url.png', - ), - ], - ), - ); - var result = selectAllWhenCmdAIsPressed( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - isMetaPressed: true, - isModifierKeyPressed: false, - ), - character: 'a', - ), - ); - - expect(result, ExecutionInstruction.haltExecution); - expect( - editContext.composer.selection!.base, - const DocumentPosition( - nodeId: 'image_1', - nodePosition: UpstreamDownstreamNodePosition.upstream(), - ), - ); - expect( - editContext.composer.selection!.extent, - const DocumentPosition( - nodeId: 'image_2', - nodePosition: UpstreamDownstreamNodePosition.downstream(), - ), - ); - }, - ); - }, - ); - - group('key pressed with selection', () { - testOnMac('deletes selection if backspace is pressed', () { - final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Text with [DELETEME] selection'), - ), - ], - ), - documentComposer: DocumentComposer( - initialSelection: const DocumentSelection( - base: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 11), - ), - extent: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 19), - ), - ), - ), - ); - - var result = anyCharacterOrDestructiveKeyToDeleteSelection( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.backspace, - physicalKey: PhysicalKeyboardKey.backspace, - ), - ), - ); - - expect(result, ExecutionInstruction.haltExecution); - - final paragraph = editContext.editor.document.nodes.first as ParagraphNode; - expect(paragraph.text.text, 'Text with [] selection'); - - expect( - editContext.composer.selection, - equals( - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 11), - ), - ), - ), - ); - }); - - testOnMac('deletes selection if delete is pressed', () { - final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Text with [DELETEME] selection'), - ), - ], - ), - documentComposer: DocumentComposer( - initialSelection: const DocumentSelection( - base: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 11), - ), - extent: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 19), - ), - ), - ), - ); - - var result = anyCharacterOrDestructiveKeyToDeleteSelection( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.delete, - physicalKey: PhysicalKeyboardKey.delete, - ), - ), - ); - - expect(result, ExecutionInstruction.haltExecution); - - final paragraph = editContext.editor.document.nodes.first as ParagraphNode; - expect(paragraph.text.text, 'Text with [] selection'); - - expect( - editContext.composer.selection, - equals( - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 11), - ), - ), - ), - ); - }); - - testOnMac('deletes selection when character key is pressed', () { - final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Text with [DELETEME] selection'), - ), - ], - ), - documentComposer: DocumentComposer( - initialSelection: const DocumentSelection( - base: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 11), - ), - extent: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 19), - ), - ), - ), - ); - - var result = anyCharacterOrDestructiveKeyToDeleteSelection( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), - character: 'a', - ), - ); - - // When we use a character key for deletion, we expect to continue - // execution so that the character is also entered by another handler. - expect(result, ExecutionInstruction.continueExecution); - - final paragraph = editContext.editor.document.nodes.first as ParagraphNode; - expect(paragraph.text.text, 'Text with [] selection'); - - expect( - editContext.composer.selection, - equals( - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 11), - ), - ), - ), - ); - }); - - testOnMac('collapses selection if escape is pressed', () { - final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Text with [SELECTME] selection'), - ), - ], - ), - documentComposer: DocumentComposer( - initialSelection: const DocumentSelection( - base: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 11), - ), - extent: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 19), - ), - ), - ), - ); - - final result = collapseSelectionWhenEscIsPressed( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.escape, - physicalKey: PhysicalKeyboardKey.escape, - ), - ), - ); - - expect(result, ExecutionInstruction.haltExecution); - - // The text should remain the same - final paragraph = editContext.editor.document.nodes.first as ParagraphNode; - expect(paragraph.text.text, 'Text with [SELECTME] selection'); - - // The selection should be collapsed - expect( - editContext.composer.selection, - equals( - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 19), - ), - ), - ), - ); - }); - }); - - group('typing characters near a link', () { - testWidgets('does not expand the link when inserting before the link', (tester) async { - // Configure and render a document. - await tester // - .createDocument() - .withCustomContent(_singleParagraphWithLinkDoc()) - .pump(); - - // Place the caret in the first paragraph at the start of the link. - await tester.placeCaretInParagraph('1', 0); - - // Type some text by simulating hardware keyboard key presses. - await tester.typeKeyboardText('Go to '); - - // Ensure that the link is unchanged - expect( - SuperEditorInspector.findDocument(), - equalsMarkdown("Go to [https://google.com](https://google.com)"), - ); - }); - - testWidgets('does not expand the link when inserting after the link', (tester) async { - // Configure and render a document. - await tester // - .createDocument() - .withCustomContent(_singleParagraphWithLinkDoc()) - .pump(); - - // Place the caret in the first paragraph at the start of the link. - await tester.placeCaretInParagraph('1', 18); - - // Type some text by simulating hardware keyboard key presses. - await tester.typeKeyboardText(' to learn anything'); - - // Ensure that the link is unchanged - expect( - SuperEditorInspector.findDocument(), - equalsMarkdown("[https://google.com](https://google.com) to learn anything"), - ); - }); - }); - - testOnMac('does nothing when escape is pressed if the selection is collapsed', () { - final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'This is some text'), - ), - ], - ), - documentComposer: DocumentComposer( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 8), - ), - ), - ), - ); - - final result = collapseSelectionWhenEscIsPressed( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.escape, - physicalKey: PhysicalKeyboardKey.escape, - ), - ), - ); - - // The handler should pass on do nothing when there is no selection. - expect(result, ExecutionInstruction.continueExecution); - - // The text should remain the same - final paragraph = editContext.editor.document.nodes.first as ParagraphNode; - expect(paragraph.text.text, 'This is some text'); - - // The selection should remain the same - expect( - editContext.composer.selection, - equals( - const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: '1', - nodePosition: TextNodePosition(offset: 8), - ), - ), - ), - ); - }); - }, - ); -} - -/// Pumps a [SuperEditor] with a single-paragraph document, with focus, and returns -/// the associated [EditContext] for further inspection and control. -/// -/// This particular setup is intended for caret movement testing within a single -/// paragraph node. -Future _pumpCaretMovementTestSetup( - WidgetTester tester, { - required int textOffsetInFirstNode, -}) async { - final composer = DocumentComposer( - initialSelection: DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition(offset: textOffsetInFirstNode), - ), - ), - ); - final editContext = createEditContext( - document: singleParagraphDoc(), - documentComposer: composer, - ); - - final focusNode = FocusNode()..requestFocus(); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SuperEditor( - focusNode: focusNode, - editor: editContext.editor, - composer: composer, - ), - ), - ), - ); - - return editContext; -} - -Future _pumpAutoWrappingTestSetup(WidgetTester tester) async { - return await tester.createDocument().withSingleParagraph().forDesktop().withEditorSize(const Size(400, 400)).pump(); -} - -Future _pumpExplicitLineBreakTestSetup( - WidgetTester tester, { - Size? size, -}) async { - return await tester - .createDocument() - .withCustomContent(MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'Lorem ipsum dolor sit amet\nconsectetur adipiscing elit', - ), - ), - ], - )) - .forDesktop() - .withEditorSize(size) - .pump(); -} - -MutableDocument _singleParagraphWithLinkDoc() { - return MutableDocument( - nodes: [ - ParagraphNode( - id: "1", - text: AttributedText( - text: "https://google.com", - spans: AttributedSpans( - attributions: [ - SpanMarker( - attribution: LinkAttribution(url: Uri.parse('https://google.com')), - offset: 0, - markerType: SpanMarkerType.start, - ), - SpanMarker( - attribution: LinkAttribution(url: Uri.parse('https://google.com')), - offset: 17, - markerType: SpanMarkerType.end, - ), - ], - ), - ), - ) - ], - ); -} diff --git a/super_editor/test/src/default_editor/list_items_test.dart b/super_editor/test/src/default_editor/list_items_test.dart deleted file mode 100644 index 57e706d265..0000000000 --- a/super_editor/test/src/default_editor/list_items_test.dart +++ /dev/null @@ -1,533 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_test_robots/flutter_test_robots.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_editor/super_editor_test.dart'; -import 'package:super_text_layout/super_text_layout.dart'; - -import '../../super_editor/document_test_tools.dart'; -import '../../test_tools.dart'; -import '../_document_test_tools.dart'; -import '../_text_entry_test_tools.dart'; - -void main() { - group('List items', () { - group('node conversion', () { - testOnMac('converts paragraph with "1. " to ordered list item', () { - final _editContext = _createEditContextWithParagraph(); - - _typeKeys(_editContext, [ - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.numpad1, - physicalKey: PhysicalKeyboardKey.numpad1, - ), - character: '1', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.period, - physicalKey: PhysicalKeyboardKey.period, - ), - character: '.', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.space, - physicalKey: PhysicalKeyboardKey.space, - ), - character: ' ', - ), - ]); - - final listItemNode = _editContext.editor.document.nodes.first; - expect(listItemNode, isA()); - expect((listItemNode as ListItemNode).text.text.isEmpty, isTrue); - }); - - testOnMac('converts paragraph with " 1. " to ordered list item', () { - final _editContext = _createEditContextWithParagraph(); - - _typeKeys(_editContext, [ - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.space, - physicalKey: PhysicalKeyboardKey.space, - ), - character: ' ', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.numpad1, - physicalKey: PhysicalKeyboardKey.numpad1, - ), - character: '1', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.period, - physicalKey: PhysicalKeyboardKey.period, - ), - character: '.', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.space, - physicalKey: PhysicalKeyboardKey.space, - ), - character: ' ', - ), - ]); - - final listItemNode = _editContext.editor.document.nodes.first; - expect(listItemNode, isA()); - expect((listItemNode as ListItemNode).text.text.isEmpty, isTrue); - }); - - testOnMac('converts paragraph with "1) " to ordered list item', () { - final _editContext = _createEditContextWithParagraph(); - - _typeKeys(_editContext, [ - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.numpad1, - physicalKey: PhysicalKeyboardKey.numpad1, - ), - character: '1', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.parenthesisRight, - physicalKey: PhysicalKeyboardKey.digit0, - ), - character: ')', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.space, - physicalKey: PhysicalKeyboardKey.space, - ), - character: ' ', - ), - ]); - - final listItemNode = _editContext.editor.document.nodes.first; - expect(listItemNode, isA()); - expect((listItemNode as ListItemNode).text.text.isEmpty, isTrue); - }); - - testOnMac('converts paragraph with " 1) " to ordered list item', () { - final _editContext = _createEditContextWithParagraph(); - - _typeKeys(_editContext, [ - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.space, - physicalKey: PhysicalKeyboardKey.space, - ), - character: ' ', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.numpad1, - physicalKey: PhysicalKeyboardKey.numpad1, - ), - character: '1', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.parenthesisRight, - physicalKey: PhysicalKeyboardKey.digit0, - ), - character: ')', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.space, - physicalKey: PhysicalKeyboardKey.space, - ), - character: ' ', - ), - ]); - - final listItemNode = _editContext.editor.document.nodes.first; - expect(listItemNode, isA()); - expect((listItemNode as ListItemNode).text.text.isEmpty, isTrue); - }); - - testOnMac('does not convert paragraph with "1 " to ordered list item', () { - final _editContext = _createEditContextWithParagraph(); - - _typeKeys(_editContext, [ - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.numpad1, - physicalKey: PhysicalKeyboardKey.numpad1, - ), - character: '1', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.space, - physicalKey: PhysicalKeyboardKey.space, - ), - character: ' ', - ), - ]); - - final paragraphNode = _editContext.editor.document.nodes.first; - expect(paragraphNode, isA()); - expect((paragraphNode as ParagraphNode).text.text, "1 "); - }); - - testOnMac('does not convert paragraph with " 1 " to ordered list item', () { - final _editContext = _createEditContextWithParagraph(); - - _typeKeys(_editContext, [ - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.space, - physicalKey: PhysicalKeyboardKey.space, - ), - character: ' ', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.numpad1, - physicalKey: PhysicalKeyboardKey.numpad1, - ), - character: '1', - ), - const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.space, - physicalKey: PhysicalKeyboardKey.space, - ), - character: ' ', - ), - ]); - - final paragraphNode = _editContext.editor.document.nodes.first; - expect(paragraphNode, isA()); - expect((paragraphNode as ParagraphNode).text.text, " 1 "); - }); - - testWidgetsOnArbitraryDesktop("applies styles when unordered list item is converted to and from a paragraph", - (WidgetTester tester) async { - final testContext = await _pumpUnorderedList( - tester, - styleSheet: _styleSheet, - ); - final doc = SuperEditorInspector.findDocument()!; - - LayoutAwareRichText richText; - - // Ensure that the textStyle for a list item was applied. - expect(find.byType(LayoutAwareRichText), findsWidgets); - richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; - expect(richText.text.style!.color, Colors.blue); - - // Tap to place caret. - await tester.placeCaretInParagraph(doc.nodes.first.id, 0); - - // Convert the list item to a paragraph. - testContext.editContext.commonOps.convertToParagraph( - newMetadata: { - 'blockType': const NamedAttribution("paragraph"), - }, - ); - await tester.pumpAndSettle(); - - // Ensure that the textStyle for a paragraph was applied. - expect(find.byType(LayoutAwareRichText), findsWidgets); - richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; - expect(richText.text.style!.color, Colors.red); - - // Convert the paragraph back to an unordered list item. - testContext.editContext.commonOps.convertToListItem( - ListItemType.unordered, - (doc.nodes.first as ParagraphNode).text, - ); - await tester.pumpAndSettle(); - - // Ensure that the textStyle for a list item was applied. - expect(find.byType(LayoutAwareRichText), findsWidgets); - richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; - expect(richText.text.style!.color, Colors.blue); - }); - - testWidgetsOnArbitraryDesktop("applies styles when ordered list item is converted to and from a paragraph", - (WidgetTester tester) async { - final testContext = await _pumpOrderedList( - tester, - styleSheet: _styleSheet, - ); - final doc = SuperEditorInspector.findDocument()!; - - LayoutAwareRichText richText; - - // Ensure that the textStyle for a list item was applied. - expect(find.byType(LayoutAwareRichText), findsWidgets); - richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; - expect(richText.text.style!.color, Colors.blue); - - // Tap to place caret. - await tester.placeCaretInParagraph(doc.nodes.first.id, 0); - - // Convert the list item to a paragraph. - testContext.editContext.commonOps.convertToParagraph( - newMetadata: { - 'blockType': const NamedAttribution("paragraph"), - }, - ); - await tester.pumpAndSettle(); - - // Ensure that the textStyle for a paragraph was applied. - expect(find.byType(LayoutAwareRichText), findsWidgets); - richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; - expect(richText.text.style!.color, Colors.red); - - // Convert the paragraph back to an ordered list item. - testContext.editContext.commonOps.convertToListItem( - ListItemType.ordered, - (doc.nodes.first as ParagraphNode).text, - ); - await tester.pumpAndSettle(); - - // Ensure that the textStyle for a list item was applied. - expect(find.byType(LayoutAwareRichText), findsWidgets); - richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; - expect(richText.text.style!.color, Colors.blue); - }); - }); - - group('unordered list', () { - testWidgetsOnArbitraryDesktop('updates caret position when indenting', (tester) async { - await _pumpUnorderedList(tester); - - final doc = SuperEditorInspector.findDocument()!; - final listItemId = doc.nodes.first.id; - - // Place caret at the first list item, which has one level of indentation. - await tester.placeCaretInParagraph(listItemId, 0); - - final caretOffsetBeforeIndent = SuperEditorInspector.findCaretOffsetInDocument(); - - // Press tab to trigger the list indent command. - await tester.pressTab(); - - // Compute the offset at which the caret should be displayed. - final computedOffsetAfterIndent = SuperEditorInspector.calculateOffsetForCaret( - DocumentPosition( - nodeId: listItemId, - nodePosition: const TextNodePosition(offset: 0), - ), - ); - - // Ensure the list indentation was actually performed. - expect(computedOffsetAfterIndent.dx, greaterThan(caretOffsetBeforeIndent.dx)); - - // Ensure the caret is being displayed at the correct position. - expect(SuperEditorInspector.findCaretOffsetInDocument(), offsetMoreOrLessEquals(computedOffsetAfterIndent)); - }); - - testWidgetsOnArbitraryDesktop('updates caret position when unindenting', (tester) async { - await _pumpUnorderedList(tester); - - final doc = SuperEditorInspector.findDocument()!; - final listItemId = doc.nodes.last.id; - - // Place caret at the last list item, which has two levels of indentation. - // For some reason, taping at the first character isn't displaying any caret, - // so we put the caret at the second character and then go back one position. - await tester.placeCaretInParagraph(listItemId, 1); - await tester.pressLeftArrow(); - - final caretOffsetBeforeUnindent = SuperEditorInspector.findCaretOffsetInDocument(); - - // Press backspace to trigger the list unindent command. - await tester.pressBackspace(); - - // Compute the offset at which the caret should be displayed. - final computedOffsetAfterUnindent = SuperEditorInspector.calculateOffsetForCaret( - DocumentPosition( - nodeId: listItemId, - nodePosition: const TextNodePosition(offset: 0), - ), - ); - - // Ensure the list indentation was actually performed. - expect(computedOffsetAfterUnindent.dx, lessThan(caretOffsetBeforeUnindent.dx)); - - // Ensure the caret is being displayed at the correct position. - expect(SuperEditorInspector.findCaretOffsetInDocument(), offsetMoreOrLessEquals(computedOffsetAfterUnindent)); - }); - }); - - group('ordered list', () { - testWidgetsOnArbitraryDesktop('updates caret position when indenting', (tester) async { - await _pumpOrderedList(tester); - - final doc = SuperEditorInspector.findDocument()!; - final listItemId = doc.nodes.first.id; - - // Place caret at the first list item, which has one level of indentation. - await tester.placeCaretInParagraph(listItemId, 0); - - final caretOffsetBeforeIndent = SuperEditorInspector.findCaretOffsetInDocument(); - - // Press tab to trigger the list indent command. - await tester.pressTab(); - - // Compute the offset at which the caret should be displayed. - final computedOffsetAfterIndent = SuperEditorInspector.calculateOffsetForCaret( - DocumentPosition( - nodeId: listItemId, - nodePosition: const TextNodePosition(offset: 0), - ), - ); - - // Ensure the list indentation was actually performed. - expect(computedOffsetAfterIndent.dx, greaterThan(caretOffsetBeforeIndent.dx)); - - // Ensure the caret is being displayed at the correct position. - expect(SuperEditorInspector.findCaretOffsetInDocument(), offsetMoreOrLessEquals(computedOffsetAfterIndent)); - }); - - testWidgetsOnArbitraryDesktop('updates caret position when unindenting', (tester) async { - await _pumpOrderedList(tester); - - final doc = SuperEditorInspector.findDocument()!; - final listItemId = doc.nodes.last.id; - - // Place caret at the last list item, which has two levels of indentation. - // For some reason, taping at the first character isn't displaying any caret, - // so we put the caret at the second character and then go back one position. - await tester.placeCaretInParagraph(listItemId, 1); - await tester.pressLeftArrow(); - - final caretOffsetBeforeUnindent = SuperEditorInspector.findCaretOffsetInDocument(); - - // Press backspace to trigger the list unindent command. - await tester.pressBackspace(); - - // Compute the offset at which the caret should be displayed. - final computedOffsetAfterUnindent = SuperEditorInspector.calculateOffsetForCaret(DocumentPosition( - nodeId: listItemId, - nodePosition: const TextNodePosition(offset: 0), - )); - - // Ensure the list indentation was actually performed. - expect(computedOffsetAfterUnindent.dx, lessThan(caretOffsetBeforeUnindent.dx)); - - // Ensure the caret is being displayed at the correct position. - expect(SuperEditorInspector.findCaretOffsetInDocument(), offsetMoreOrLessEquals(computedOffsetAfterUnindent)); - }); - }); - }); -} - -EditContext _createEditContextWithParagraph() { - return createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: 'paragraph', - text: AttributedText(text: ''), - ), - ], - ), - documentComposer: DocumentComposer( - initialSelection: const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: 'paragraph', - nodePosition: TextNodePosition(offset: 0), - ), - ), - ), - ); -} - -void _typeKeys(EditContext editContext, List keys) { - for (final key in keys) { - anyCharacterToInsertInParagraph( - editContext: editContext, - keyEvent: key, - ); - } -} - -/// Pumps a [SuperEditor] containing 3 unordered list items. -/// -/// The first two items have one level of indentation. -/// -/// The last two items have two levels of indentation. -Future _pumpUnorderedList( - WidgetTester tester, { - Stylesheet? styleSheet, -}) async { - const markdown = ''' - * list item 1 - * list item 2 - * list item 2.1 - * list item 2.2'''; - - return await tester // - .createDocument() - .fromMarkdown(markdown) - .useStylesheet(styleSheet) - .pump(); -} - -/// Pumps a [SuperEditor] containing 4 ordered list items. -/// -/// The first two items have one level of indentation. -/// -/// The last two items have two levels of indentation. -Future _pumpOrderedList( - WidgetTester tester, { - Stylesheet? styleSheet, -}) async { - const markdown = ''' - 1. list item 1 - 1. list item 2 - 1. list item 2.1 - 1. list item 2.2'''; - - return await tester // - .createDocument() - .fromMarkdown(markdown) - .useStylesheet(styleSheet) - .pump(); -} - -TextStyle _inlineTextStyler(Set attributions, TextStyle base) => base; - -final _styleSheet = Stylesheet( - inlineTextStyler: _inlineTextStyler, - rules: [ - StyleRule( - const BlockSelector("paragraph"), - (doc, docNode) { - return { - "textStyle": const TextStyle( - color: Colors.red, - fontSize: 16, - ), - }; - }, - ), - StyleRule( - const BlockSelector("listItem"), - (doc, docNode) { - return { - "textStyle": const TextStyle( - color: Colors.blue, - fontSize: 16, - ), - }; - }, - ), - ], -); diff --git a/super_editor/test/src/default_editor/super_editor_test.dart b/super_editor/test/src/default_editor/super_editor_test.dart deleted file mode 100644 index cfa490517d..0000000000 --- a/super_editor/test/src/default_editor/super_editor_test.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_editor/super_editor_test.dart'; - -import '../../super_editor/document_test_tools.dart'; - -void main() { - group('SuperEditor', () { - group('autofocus', () { - testWidgets('does not claim focus when autofocus is false', (tester) async { - // Configure and render a document. - await tester // - .createDocument() - .withSingleParagraph() - .withInputSource(_inputAndGestureVariants.currentValue!.inputSource) - .withGestureMode(_inputAndGestureVariants.currentValue!.gestureMode) - .autoFocus(false) - .pump(); - - expect(SuperEditorInspector.hasFocus(), false); - }, variant: _inputAndGestureVariants); - - testWidgets('claims focus when autofocus is true', (tester) async { - // Configure and render a document. - await tester // - .createDocument() - .withSingleParagraph() - .withInputSource(_inputAndGestureVariants.currentValue!.inputSource) - .withGestureMode(_inputAndGestureVariants.currentValue!.gestureMode) - .autoFocus(true) - .pump(); - - expect(SuperEditorInspector.hasFocus(), true); - }, variant: _inputAndGestureVariants); - - testWidgets('claims focus by gesture when autofocus is false', (tester) async { - // Configure and render a document. - await tester // - .createDocument() - .withSingleParagraph() - .withInputSource(_inputAndGestureVariants.currentValue!.inputSource) - .withGestureMode(_inputAndGestureVariants.currentValue!.gestureMode) - .autoFocus(false) - .pump(); - - await tester.placeCaretInParagraph("1", 0); - - expect(SuperEditorInspector.hasFocus(), true); - }, variant: _inputAndGestureVariants); - }); - - group("stylesheet", () { - testWidgets("change causes presentation to run again", (tester) async { - // Configure and render a document. - final testDocument = await tester // - .createDocument() - .withSingleParagraph() - .useStylesheet(_stylesheet1) - .pump(); - - // Ensure that the initial text is black - expect(SuperEditorInspector.findParagraphStyle("1")!.color, Colors.black); - - // Configure and render a document with a different stylesheet. - await tester // - .updateDocument(testDocument) - .useStylesheet(_stylesheet2) - .pump(); - - // Expect the paragraph to now be white. - expect(SuperEditorInspector.findParagraphStyle("1")!.color, Colors.white); - }); - }); - }); -} - -final _stylesheet1 = Stylesheet( - inlineTextStyler: inlineTextStyler, - rules: [ - StyleRule(BlockSelector.all, (document, node) { - return { - "textStyle": const TextStyle( - color: Colors.black, - ), - }; - }), - ], -); - -final _stylesheet2 = Stylesheet( - inlineTextStyler: inlineTextStyler, - rules: [ - StyleRule(BlockSelector.all, (document, node) { - return { - "textStyle": const TextStyle( - color: Colors.white, - ), - }; - }), - ], -); - -TextStyle inlineTextStyler(Set attributions, TextStyle base) { - return base; -} - -class _InputAndGestureTuple { - final DocumentInputSource inputSource; - final DocumentGestureMode gestureMode; - - const _InputAndGestureTuple(this.inputSource, this.gestureMode); - - @override - String toString() { - return '${inputSource.name} Input Source & ${gestureMode.name} Gesture Mode'; - } -} - -final _inputAndGestureVariants = ValueVariant<_InputAndGestureTuple>( - { - const _InputAndGestureTuple(DocumentInputSource.keyboard, DocumentGestureMode.mouse), - const _InputAndGestureTuple(DocumentInputSource.keyboard, DocumentGestureMode.iOS), - const _InputAndGestureTuple(DocumentInputSource.keyboard, DocumentGestureMode.android), - const _InputAndGestureTuple(DocumentInputSource.ime, DocumentGestureMode.mouse), - const _InputAndGestureTuple(DocumentInputSource.ime, DocumentGestureMode.iOS), - const _InputAndGestureTuple(DocumentInputSource.ime, DocumentGestureMode.android), - }, -); diff --git a/super_editor/test/super_editor/bug_fix_test.dart b/super_editor/test/super_editor/bug_fix_test.dart index c3c3a5b474..0553e3d775 100644 --- a/super_editor/test/super_editor/bug_fix_test.dart +++ b/super_editor/test/super_editor/bug_fix_test.dart @@ -1,19 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; import 'package:super_editor/super_editor.dart'; void main() { group("Bug fix", () { group("429 - delete multiple new nodes", () { testWidgets("bug repro", (tester) async { - final document = MutableDocument( - nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "")), - ], - ); - final editor = DocumentEditor(document: document); - final composer = DocumentComposer( + final document = MutableDocument.empty("1"); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition( nodeId: "1", @@ -21,38 +17,47 @@ void main() { ), ), ); + final editor = createDefaultDocumentEditor(document: document, composer: composer); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SuperEditor( editor: editor, - composer: composer, gestureMode: DocumentGestureMode.mouse, - inputSource: DocumentInputSource.keyboard, + inputSource: TextInputSource.keyboard, ), ), ), ); + await tester.tap(find.byType(SuperEditor)); await tester.pumpAndSettle(); // Create a couple new nodes. - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pumpAndSettle(); + await tester.pressEnter(); + await tester.pressEnter(); + + // Ensure we created the new nodes. + expect(document.nodeCount, 3); // Select the new nodes. - composer.selection = DocumentSelection( - base: DocumentPosition( - nodeId: document.nodes[2].id, - nodePosition: document.nodes[2].endPosition, - ), - extent: DocumentPosition( - nodeId: document.nodes[1].id, - nodePosition: document.nodes[1].beginningPosition, + editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: document.getNodeAt(2)!.id, + nodePosition: document.getNodeAt(2)!.endPosition, + ), + extent: DocumentPosition( + nodeId: document.getNodeAt(1)!.id, + nodePosition: document.getNodeAt(1)!.beginningPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, ), - ); + ]); await tester.pumpAndSettle(); // Delete the new nodes. @@ -61,25 +66,20 @@ void main() { // Bug #429 - the deletion threw an error due to a selection // type mismatch. - expect(document.nodes.length, 2); + expect(document.nodeCount, 2); expect(composer.selection!.isCollapsed, true); expect( composer.selection!.extent, DocumentPosition( - nodeId: document.nodes.last.id, + nodeId: document.last.id, nodePosition: const TextNodePosition(offset: 0), ), ); }); testWidgets("related to bug", (tester) async { - final document = MutableDocument( - nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "")), - ], - ); - final editor = DocumentEditor(document: document); - final composer = DocumentComposer( + final document = MutableDocument.empty("1"); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition( nodeId: "1", @@ -87,15 +87,15 @@ void main() { ), ), ); + final editor = createDefaultDocumentEditor(document: document, composer: composer); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SuperEditor( editor: editor, - composer: composer, gestureMode: DocumentGestureMode.mouse, - inputSource: DocumentInputSource.keyboard, + inputSource: TextInputSource.keyboard, ), ), ), @@ -109,16 +109,22 @@ void main() { await tester.pumpAndSettle(); // Select the new nodes. - composer.selection = DocumentSelection( - base: DocumentPosition( - nodeId: document.nodes[1].id, - nodePosition: document.nodes[1].beginningPosition, - ), - extent: DocumentPosition( - nodeId: document.nodes[2].id, - nodePosition: document.nodes[2].endPosition, + editor.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: document.getNodeAt(1)!.id, + nodePosition: document.getNodeAt(1)!.beginningPosition, + ), + extent: DocumentPosition( + nodeId: document.getNodeAt(2)!.id, + nodePosition: document.getNodeAt(2)!.endPosition, + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, ), - ); + ]); await tester.pumpAndSettle(); // Delete the new nodes. @@ -128,12 +134,12 @@ void main() { // The bug was a problem with an expanded upstream selection. // Here we make sure that deleting an expanded downstream // selection works, too. - expect(document.nodes.length, 2); + expect(document.nodeCount, 2); expect(composer.selection!.isCollapsed, true); expect( composer.selection!.extent, DocumentPosition( - nodeId: document.nodes.last.id, + nodeId: document.last.id, nodePosition: const TextNodePosition(offset: 0), ), ); diff --git a/super_editor/test/src/default_editor/upstream_downstream_selection_test.dart b/super_editor/test/super_editor/components/block_node_test.dart similarity index 75% rename from super_editor/test/src/default_editor/upstream_downstream_selection_test.dart rename to super_editor/test/super_editor/components/block_node_test.dart index 0bf2ffaed3..fe9ab3e551 100644 --- a/super_editor/test/src/default_editor/upstream_downstream_selection_test.dart +++ b/super_editor/test/super_editor/components/block_node_test.dart @@ -1,23 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_inspector.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; -import '../../super_editor/test_documents.dart'; +import '../test_documents.dart'; /// Upstream/downstream selection refers components that only support /// a caret position at the upstream edge, or downstream edge. For /// example, an image component might use upstream/downstream selection. void main() { - group("Upstream-downstream block", () { + group("Block nodes", () { group("move caret up", () { testWidgets("up arrow moves text caret to upstream edge of block from node below", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "3", nodePosition: TextNodePosition(offset: 0)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pump(); @@ -28,13 +32,14 @@ void main() { }); testWidgets("up arrow moves text caret to downstream edge of block from node below", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( // The caret needs to be on the 1st line, in the right half of the line. position: DocumentPosition(nodeId: "3", nodePosition: TextNodePosition(offset: 33)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pump(); @@ -45,12 +50,13 @@ void main() { }); testWidgets("up arrow moves caret from upstream edge to text node above", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pump(); @@ -61,12 +67,13 @@ void main() { }); testWidgets("up arrow moves caret from downstream edge to text node above", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pump(); @@ -77,12 +84,13 @@ void main() { }); testWidgets("left arrow moves caret to text node above", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pump(); @@ -94,12 +102,13 @@ void main() { }); testWidgets("right arrow moves caret to text node below", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); @@ -111,12 +120,13 @@ void main() { }); testWidgets("delete moves caret down to block from node above", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 37)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.delete); await tester.pump(); @@ -127,12 +137,13 @@ void main() { }); testWidgets("backspace moves caret up to block from node below", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "3", nodePosition: TextNodePosition(offset: 0)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.backspace); await tester.pump(); @@ -145,13 +156,14 @@ void main() { group("move caret down", () { testWidgets("text caret moves to upstream edge of block from node above", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( // Caret needs to sit on the left half of the last line in the paragraph. position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); @@ -162,13 +174,14 @@ void main() { }); testWidgets("text caret moves to downstream edge of block from node above", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( // Caret needs to sit in right half of the last line in the paragraph. position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 37)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); @@ -179,12 +192,13 @@ void main() { }); testWidgets("upstream block caret moves to text node below", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); @@ -195,12 +209,13 @@ void main() { }); testWidgets("downstream block caret moves to text node below", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); @@ -211,12 +226,13 @@ void main() { }); testWidgets("right arrow moves caret to text node below", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); @@ -230,12 +246,13 @@ void main() { group("move caret horizontally", () { testWidgets("right arrow moves caret downstream", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); @@ -246,12 +263,13 @@ void main() { }); testWidgets("left arrow moves caret upstream", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pump(); @@ -260,16 +278,55 @@ void main() { expect(composer.selection!.extent.nodeId, "2"); expect(composer.selection!.extent.nodePosition, const UpstreamDownstreamNodePosition.upstream()); }); + + testWidgets("right arrow collapses the expanded selection around block node to a caret on the downstream edge", + (tester) async { + await tester + .createDocument() + .withCustomContent(paragraphThenHrThenParagraphDoc()) + .withEditorSize(const Size(300, 300)) + .pump(); + + await tester.doubleTapAtDocumentPosition(const DocumentPosition( + nodeId: "2", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + )); + await tester.pump(kTapMinTime + const Duration(milliseconds: 1)); + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "2", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + final selection = SuperEditorInspector.findDocumentSelection(); + + expect(selection!.isCollapsed, true); + expect(selection.extent.nodeId, "2"); + expect(selection.extent.nodePosition, const UpstreamDownstreamNodePosition.downstream()); + }); }); group("deletion", () { testWidgets("backspace moves caret to node above when caret is on upstream edge", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.backspace); await tester.pump(); @@ -280,12 +337,13 @@ void main() { }); testWidgets("backspace removes block node when caret is on downstream edge", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.backspace); await tester.pump(); @@ -296,12 +354,13 @@ void main() { }); testWidgets("delete moves caret to node below when caret is at downstream edge", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.delete); await tester.pump(); @@ -312,12 +371,13 @@ void main() { }); testWidgets("delete removes block node when caret is at upstream edge", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.delete); await tester.pump(); @@ -328,13 +388,14 @@ void main() { }); testWidgets("backspace removes block node when selected", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), extent: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.backspace); await tester.pump(); @@ -345,13 +406,14 @@ void main() { }); testWidgets("delete removes block node when selected", (tester) async { - final composer = DocumentComposer( + final document = paragraphThenHrThenParagraphDoc(); + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), extent: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.delete); await tester.pump(); @@ -363,7 +425,7 @@ void main() { testWidgets("backspace removes block and part of node above", (tester) async { final document = paragraphThenHrThenParagraphDoc(); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 20)), extent: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), @@ -375,14 +437,14 @@ void main() { await tester.pump(); expect(composer.selection!.isCollapsed, true); - expect(document.nodes.length, 2); + expect(document.nodeCount, 2); expect(composer.selection!.extent.nodeId, "1"); expect((composer.selection!.extent.nodePosition as TextNodePosition).offset, 20); }); testWidgets("backspace removes block and part of node below", (tester) async { final document = paragraphThenHrThenParagraphDoc(); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), extent: DocumentPosition(nodeId: "3", nodePosition: TextNodePosition(offset: 20)), @@ -394,14 +456,14 @@ void main() { await tester.pump(); expect(composer.selection!.isCollapsed, true); - expect(document.nodes.length, 2); + expect(document.nodeCount, 2); expect(composer.selection!.extent.nodeId, "3"); expect((composer.selection!.extent.nodePosition as TextNodePosition).offset, 0); }); testWidgets("backspace removes block and merges surrounding text nodes", (tester) async { final document = paragraphThenHrThenParagraphDoc(); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 20)), extent: DocumentPosition(nodeId: "3", nodePosition: TextNodePosition(offset: 20)), @@ -413,14 +475,14 @@ void main() { await tester.pump(); expect(composer.selection!.isCollapsed, true); - expect(document.nodes.length, 1); + expect(document.nodeCount, 1); expect(composer.selection!.extent.nodeId, "1"); expect((composer.selection!.extent.nodePosition as TextNodePosition).offset, 20); }); testWidgets("backspace does nothing at beginning of document", (tester) async { final document = singleBlockDoc(); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "1", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), @@ -437,7 +499,7 @@ void main() { testWidgets("delete does nothing at end of document", (tester) async { final document = singleBlockDoc(); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "1", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), @@ -456,7 +518,7 @@ void main() { group("Insert new nodes", () { testWidgets("newline inserts paragraph before block", (tester) async { final document = singleBlockDoc(); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "1", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), @@ -467,7 +529,7 @@ void main() { await tester.pump(); expect(composer.selection!.isCollapsed, true); - expect(document.nodes.length, 2); + expect(document.nodeCount, 2); expect(composer.selection!.extent.nodePosition, isA()); expect((composer.selection!.extent.nodePosition as TextNodePosition).offset, 0); expect(document.getNodeAt(0)!.id, composer.selection!.extent.nodeId); @@ -475,7 +537,7 @@ void main() { testWidgets("newline inserts paragraph after block", (tester) async { final document = singleBlockDoc(); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "1", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), @@ -486,7 +548,7 @@ void main() { await tester.pump(); expect(composer.selection!.isCollapsed, true); - expect(document.nodes.length, 2); + expect(document.nodeCount, 2); expect(composer.selection!.extent.nodePosition, isA()); expect((composer.selection!.extent.nodePosition as TextNodePosition).offset, 0); expect(document.getNodeAt(1)!.id, composer.selection!.extent.nodeId); @@ -496,7 +558,7 @@ void main() { group("typing at boundary", () { testWidgets("inserts paragraph before upstream edge", (tester) async { final document = singleBlockDoc(); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "1", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), @@ -507,15 +569,15 @@ void main() { await tester.pump(); expect(composer.selection!.isCollapsed, true); - expect(document.nodes.length, 2); - expect(document.nodes[0], isA()); - expect(document.nodes[1], isA()); + expect(document.nodeCount, 2); + expect(document.getNodeAt(0)!, isA()); + expect(document.getNodeAt(1)!, isA()); expect(composer.selection!.extent.nodePosition, const TextNodePosition(offset: 1)); }); testWidgets("inserts paragraph after downstream edge", (tester) async { final document = singleBlockDoc(); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "1", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), @@ -526,20 +588,20 @@ void main() { await tester.pump(); expect(composer.selection!.isCollapsed, true); - expect(document.nodes.length, 2); - expect(document.nodes[0], isA()); - expect(document.nodes[1], isA()); + expect(document.nodeCount, 2); + expect(document.getNodeAt(0)!, isA()); + expect(document.getNodeAt(1)!, isA()); expect(composer.selection!.extent.nodePosition, const TextNodePosition(offset: 1)); }); testWidgets("deletes empty paragraph in node above when backspace pressed from upstream edge", (tester) async { final document = MutableDocument( nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "")), + ParagraphNode(id: "1", text: AttributedText()), HorizontalRuleNode(id: "2"), ], ); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), @@ -550,22 +612,21 @@ void main() { await tester.pump(); expect(composer.selection!.isCollapsed, true); - expect(document.nodes.length, 1); - expect(document.nodes[0], isA()); + expect(document.nodeCount, 1); + expect(document.getNodeAt(0)!, isA()); expect(composer.selection!.extent.nodePosition, const UpstreamDownstreamNodePosition.upstream()); }); }); }); } -Widget _buildHardwareKeyboardEditor(MutableDocument document, DocumentComposer composer) { - final editor = DocumentEditor(document: document); +Widget _buildHardwareKeyboardEditor(MutableDocument document, MutableDocumentComposer composer) { + final editor = createDefaultDocumentEditor(document: document, composer: composer); return MaterialApp( home: Scaffold( body: SuperEditor( editor: editor, - composer: composer, // Make the text small so that the test paragraphs fit on a single // line, so that we can place the caret on the left/right halves // of lines, as needed. @@ -573,7 +634,7 @@ Widget _buildHardwareKeyboardEditor(MutableDocument document, DocumentComposer c addRulesAfter: [ StyleRule(BlockSelector.all, (doc, node) { return { - "textStyle": const TextStyle( + Styles.textStyle: const TextStyle( fontSize: 12, ), }; @@ -581,6 +642,7 @@ Widget _buildHardwareKeyboardEditor(MutableDocument document, DocumentComposer c ], ), gestureMode: DocumentGestureMode.mouse, + inputSource: TextInputSource.keyboard, autofocus: true, ), ), diff --git a/super_editor/test/super_editor/components/blockquote_test.dart b/super_editor/test/super_editor/components/blockquote_test.dart new file mode 100644 index 0000000000..f760cd4c65 --- /dev/null +++ b/super_editor/test/super_editor/components/blockquote_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +void main() { + group('Super Editor > Blockquote >', () { + testWidgets("applies the textStyle from SuperEditor's styleSheet", (WidgetTester tester) async { + await tester + .createDocument() // + .withCustomContent(_singleBlockquoteDoc()) + .useStylesheet(_styleSheet) + .pump(); + + // Ensure that the textStyle from the styleSheet was applied + expect(find.byType(LayoutAwareRichText), findsOneWidget); + final richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; + expect(richText.text.style!.color, Colors.blue); + expect(richText.text.style!.fontSize, 16); + }); + + group("insert newlines >", () { + testWidgetsOnAllPlatforms("inserts newline in middle and splits blockquote into two blockquotes", + (WidgetTester tester) async { + await tester + .createDocument() // + .withCustomContent(_singleBlockquoteDoc()) + .pump(); + + // Place caret in the middle of the blockquote: + // "This is |a blockquote." + await tester.placeCaretInParagraph("1", 8); + + // Insert a newline. + switch (debugDefaultTargetPlatformOverride) { + case TargetPlatform.android: + case TargetPlatform.iOS: + // FIXME: pressEnterWithIme should work, but it seems to think there are no + // connected IME clients, so it fizzles. For now, we use the implementation + // directly. + // await tester.pressEnterWithIme(); + await tester.testTextInput.receiveAction(TextInputAction.newline); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case null: + await tester.pressEnter(); + } + + // Ensure we have two blockquotes, each with part of the original text. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 2); + + expect(document.first.metadata["blockType"], blockquoteAttribution); + expect(document.first.asTextNode.text.toPlainText(), "This is "); + + expect(document.last.metadata["blockType"], blockquoteAttribution); + expect(document.last.asTextNode.text.toPlainText(), "a blockquote."); + }); + + testWidgetsOnAllPlatforms("inserts newline at end of blockquote to create a new empty paragraph", + (WidgetTester tester) async { + await tester + .createDocument() // + .withCustomContent(_singleBlockquoteDoc()) + .pump(); + + // Place caret at the end of the blockquote. + await tester.placeCaretInParagraph("1", 21); + + // Insert a newline. + switch (debugDefaultTargetPlatformOverride) { + case TargetPlatform.android: + case TargetPlatform.iOS: + // FIXME: pressEnterWithIme should work, but it seems to think there are no + // connected IME clients, so it fizzles. For now, we use the implementation + // directly. + // await tester.pressEnterWithIme(); + await tester.testTextInput.receiveAction(TextInputAction.newline); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case null: + await tester.pressEnter(); + } + + // Ensure a new, empty paragraph was inserted after the blockquote. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 2); + + expect(document.first.metadata["blockType"], blockquoteAttribution); + expect(document.first.asTextNode.text.toPlainText(), "This is a blockquote."); + + expect(document.last.metadata["blockType"], paragraphAttribution); + expect(document.last.asTextNode.text.toPlainText(), ""); + }); + }); + }); +} + +MutableDocument _singleBlockquoteDoc() => MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText("This is a blockquote."), + metadata: const {'blockType': blockquoteAttribution}, + ) + ], + ); + +final _styleSheet = Stylesheet( + inlineTextStyler: _inlineTextStyler, + rules: [ + StyleRule( + const BlockSelector("blockquote"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle(color: Colors.blue, fontSize: 16), + }; + }, + ), + ], +); + +TextStyle _inlineTextStyler(Set attributions, TextStyle base) => base; diff --git a/super_editor/test/super_editor/components/hint_text_test.dart b/super_editor/test/super_editor/components/hint_text_test.dart new file mode 100644 index 0000000000..8ac8d73ab7 --- /dev/null +++ b/super_editor/test/super_editor/components/hint_text_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart' show Colors; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group("Super Editor > components > hint text >", () { + testWidgetsOnArbitraryDesktop("displays inline widgets", (tester) async { + await tester + .createDocument() + .withCustomContent(MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("Hello to ", null, { + 9: "fake_mention", + })) + ], + )) + .withComponentBuilders([ + const HintComponentBuilder("Hello", _hintStyler), + ...defaultComponentBuilders, + ]) + .useStylesheet(defaultStylesheet.copyWith( + inlineWidgetBuilders: _inlineWidgetBuilders, + )) + .pump(); + + // Ensure that we really are using the hint text component. + expect(find.byType(TextWithHintComponent), findsOne); + + final richText = SuperEditorInspector.findRichTextInParagraph("1"); + expect(richText.children, isNotNull); + + // Verify that we show the text in the node. + expect(richText.children!.first, isA()); + expect((richText.children!.first as TextSpan).text, "Hello to "); + + // Verify that we built the inline widget for the place holder in the node. + expect(richText.children!.last, isA()); + expect((richText.children!.last as WidgetSpan).child, isA<_FakeInlineWidget>()); + }); + }); +} + +TextStyle _hintStyler(BuildContext _) => TextStyle(); + +const _inlineWidgetBuilders = [ + _buildFakeInlineWidget, +]; + +Widget? _buildFakeInlineWidget(BuildContext context, TextStyle style, Object placeholder) { + if (placeholder is! String || placeholder != "fake_mention") { + return null; + } + + return const _FakeInlineWidget(); +} + +class _FakeInlineWidget extends StatelessWidget { + const _FakeInlineWidget(); + + @override + Widget build(BuildContext context) { + return Container( + width: 16, + height: 16, + color: Colors.red, + ); + } +} diff --git a/super_editor/test/super_editor/components/horizontal_rule_test.dart b/super_editor/test/super_editor/components/horizontal_rule_test.dart new file mode 100644 index 0000000000..853ca4095c --- /dev/null +++ b/super_editor/test/super_editor/components/horizontal_rule_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperEditor horizontal rule component', () { + testWidgetsOnAllPlatforms('inserts a paragraph when typing at the end', (tester) async { + final testContext = await tester + .createDocument() // + .fromMarkdown(''' +Paragraph 1 + +--- + +Paragraph 2 +''') + .withInputSource(TextInputSource.ime) + .pump(); + + final document = testContext.findEditContext().document; + + // Place the caret at the end of the horizontal rule, by first placing the caret in the paragraph after the + // horizontal rule, and then pressing the left arrow to move it up. + await tester.placeCaretInParagraph(document.last.id, 0); + await tester.pressLeftArrow(); + + // Type at the end of the horizontal rule + await tester.typeImeText('new paragraph'); + + // Ensure that the new text was inserted in a new paragraph after the horizontal rule. + expect(document.nodeCount, 4); + final insertedNode = document.getNodeAt(2)!; + expect(insertedNode, isA()); + expect((insertedNode as ParagraphNode).text.toPlainText(), 'new paragraph'); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: insertedNode.id, + nodePosition: const TextNodePosition(offset: 13), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms('inserts a paragraph when typing at the beginning', (tester) async { + final testContext = await tester + .createDocument() // + .fromMarkdown(''' +Paragraph 1 + +--- + +Paragraph 2 +''') + .withInputSource(TextInputSource.ime) + .pump(); + + final document = testContext.findEditContext().document; + + // Place the caret at the beginning of the horizontal rule, by first placing the caret in the paragraph before the + // horizontal rule, and then pressing the right arrow to move it down. + await tester.placeCaretInParagraph(document.first.id, 11); + await tester.pressRightArrow(); + + // Type at the beginning of the horizontal rule + await tester.typeImeText('new paragraph'); + + // Ensure that the new text was inserted in a new paragraph before the horizontal rule. + expect(document.nodeCount, 4); + final insertedNode = document.getNodeAt(1)!; + expect(insertedNode, isA()); + expect((insertedNode as ParagraphNode).text.toPlainText(), 'new paragraph'); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: insertedNode.id, + nodePosition: const TextNodePosition(offset: 13), + ), + ), + ); + }); + }); +} diff --git a/super_editor/test/super_editor/components/list_items_test.dart b/super_editor/test/super_editor/components/list_items_test.dart new file mode 100644 index 0000000000..ca50b15e79 --- /dev/null +++ b/super_editor/test/super_editor/components/list_items_test.dart @@ -0,0 +1,1386 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +import '../../test_runners.dart'; + +void main() { + group('List items', () { + group('node conversion', () { + testWidgetsOnArbitraryDesktop("applies styles when unordered list item is converted to and from a paragraph", + (WidgetTester tester) async { + final testContext = await _pumpUnorderedList( + tester, + styleSheet: _styleSheet, + ); + final doc = SuperEditorInspector.findDocument()!; + + LayoutAwareRichText richText; + + // Ensure that the textStyle for a list item was applied. + expect(find.byType(LayoutAwareRichText), findsWidgets); + richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; + expect(richText.text.style!.color, Colors.blue); + + // Tap to place caret. + await tester.placeCaretInParagraph(doc.first.id, 0); + + // Convert the list item to a paragraph. + testContext.findEditContext().commonOps.convertToParagraph( + newMetadata: { + 'blockType': const NamedAttribution("paragraph"), + }, + ); + await tester.pumpAndSettle(); + + // Ensure that the textStyle for a paragraph was applied. + expect(find.byType(LayoutAwareRichText), findsWidgets); + richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; + expect(richText.text.style!.color, Colors.red); + + // Convert the paragraph back to an unordered list item. + testContext.findEditContext().commonOps.convertToListItem( + ListItemType.unordered, + (doc.first as ParagraphNode).text, + ); + await tester.pumpAndSettle(); + + // Ensure that the textStyle for a list item was applied. + expect(find.byType(LayoutAwareRichText), findsWidgets); + richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; + expect(richText.text.style!.color, Colors.blue); + }); + + testWidgetsOnArbitraryDesktop("applies styles when ordered list item is converted to and from a paragraph", + (WidgetTester tester) async { + final testContext = await _pumpOrderedList( + tester, + styleSheet: _styleSheet, + ); + final doc = SuperEditorInspector.findDocument()!; + + LayoutAwareRichText richText; + + // Ensure that the textStyle for a list item was applied. + expect(find.byType(LayoutAwareRichText), findsWidgets); + richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; + expect(richText.text.style!.color, Colors.blue); + + // Tap to place caret. + await tester.placeCaretInParagraph(doc.first.id, 0); + + // Convert the list item to a paragraph. + testContext.findEditContext().commonOps.convertToParagraph( + newMetadata: { + 'blockType': const NamedAttribution("paragraph"), + }, + ); + await tester.pumpAndSettle(); + + // Ensure that the textStyle for a paragraph was applied. + expect(find.byType(LayoutAwareRichText), findsWidgets); + richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; + expect(richText.text.style!.color, Colors.red); + + // Convert the paragraph back to an ordered list item. + testContext.findEditContext().commonOps.convertToListItem( + ListItemType.ordered, + (doc.first as ParagraphNode).text, + ); + await tester.pumpAndSettle(); + + // Ensure that the textStyle for a list item was applied. + expect(find.byType(LayoutAwareRichText), findsWidgets); + richText = (find.byType(LayoutAwareRichText).evaluate().first.widget) as LayoutAwareRichText; + expect(richText.text.style!.color, Colors.blue); + }); + }); + + group('newlines >', () { + testWidgetsOnAllPlatforms("does nothing when caret is in non-deletable task", (tester) async { + await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ListItemNode.unordered( + id: "1", + text: AttributedText("Non-deletable list item."), + metadata: const { + NodeMetadata.isDeletable: false, + }, + ), + ParagraphNode( + id: "2", + text: AttributedText("A deletable paragraph."), + ), + ], + ), + ) + .pump(); + + // Place caret in the middle of the non-deletable list item. + await tester.placeCaretInParagraph("1", 5); + + // Press enter to try to split the list item. + switch (debugDefaultTargetPlatformOverride) { + case TargetPlatform.android: + case TargetPlatform.iOS: + // FIXME: pressEnterWithIme should work, but it seems to think there are no + // connected IME clients, so it fizzles. For now, we use the implementation + // directly. + // await tester.pressEnterWithIme(); + await tester.testTextInput.receiveAction(TextInputAction.newline); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case null: + await tester.pressEnter(); + } + + // Ensure the list item wasn't changed. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 2); + expect(document.first.asTextNode.text.toPlainText(), "Non-deletable list item."); + expect(document.first, isA()); + }); + + testWidgetsOnAllPlatforms("does nothing when non-deletable content is selected", (tester) async { + final editContext = await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ListItemNode.ordered( + id: "1", + text: AttributedText("A list item."), + ), + HorizontalRuleNode( + id: "2", + metadata: const { + NodeMetadata.isDeletable: false, + }, + ), + ], + ), + ) + .autoFocus(true) + .pump(); + + // Select from the list item across the HR. + editContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 5), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + await tester.pump(); + + // Press enter to try to delete part of the list item and a non-deletable + // horizontal rule. + switch (debugDefaultTargetPlatformOverride) { + case TargetPlatform.android: + case TargetPlatform.iOS: + // FIXME: pressEnterWithIme should work, but it seems to think there are no + // connected IME clients, so it fizzles. For now, we use the implementation + // directly. + // await tester.pressEnterWithIme(); + await tester.testTextInput.receiveAction(TextInputAction.newline); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case null: + await tester.pressEnter(); + } + + // Ensure nothing happened to the document. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 2); + expect(document.first.asTextNode.text.toPlainText(), "A list item."); + expect(document.last, isA()); + }); + }); + + group('unordered list', () { + testWidgetsOnDesktop('updates caret position when indenting', (tester) async { + await _pumpOrderedListWithTextField(tester); + + final doc = SuperEditorInspector.findDocument()!; + + // Place caret at the first list item, which has one level of indentation. + await tester.placeCaretInParagraph(doc.first.id, 0); + + // Ensure the list item has first level of indentation. + expect(doc.first.asListItem.indent, 0); + + // Ensure the caret is initially positioned near the upstream edge of the first + // character of the list item. + // + // We only care about a roughly accurate caret offset because the logic around + // exact caret positioning might change and we don't want that to break this test. + final caretOffsetBeforeIndent = SuperEditorInspector.findCaretOffsetInDocument(); + final firstCharacterRectBeforeIndent = SuperEditorInspector.findDocumentLayout().getRectForPosition( + DocumentPosition(nodeId: doc.first.id, nodePosition: const TextNodePosition(offset: 0)), + )!; + expect(caretOffsetBeforeIndent.dx, moreOrLessEquals(firstCharacterRectBeforeIndent.left, epsilon: 5)); + + // Press tab to trigger the list indent command. + await tester.pressTab(); + + // Ensure the list item has second level of indentation. + expect(doc.first.asListItem.indent, 1); + + // Ensure that the caret's current offset is downstream from the initial caret offset, + // and also that the current caret offset is roughly positioned near the upstream edge + // of the first list item character. + // + // We only care about a roughly accurate caret offset because the logic around + // exact caret positioning might change and we don't want that to break this test. + final caretOffsetAfterIndent = SuperEditorInspector.findCaretOffsetInDocument(); + expect(caretOffsetAfterIndent.dx, greaterThan(caretOffsetBeforeIndent.dx)); + final firstCharacterRectAfterIndent = SuperEditorInspector.findDocumentLayout().getRectForPosition( + DocumentPosition(nodeId: doc.first.id, nodePosition: const TextNodePosition(offset: 0)), + )!; + expect(caretOffsetAfterIndent.dx, moreOrLessEquals(firstCharacterRectAfterIndent.left, epsilon: 5)); + }); + + testWidgetsOnDesktop('updates caret position when unindenting', (tester) async { + await _pumpUnorderedListWithTextField(tester); + + final doc = SuperEditorInspector.findDocument()!; + + // Place caret at the last list item, which has two levels of indentation. + // For some reason, taping at the first character isn't displaying any caret, + // so we put the caret at the second character and then go back one position. + await tester.placeCaretInParagraph(doc.last.id, 1); + await tester.pressLeftArrow(); + + // Ensure the list item has second level of indentation. + expect(doc.last.asListItem.indent, 1); + + // Ensure the caret is initially positioned near the upstream edge of the first + // character of the list item. + // + // We only care about a roughly accurate caret offset because the logic around + // exact caret positioning might change and we don't want that to break this test. + final caretOffsetBeforeUnIndent = SuperEditorInspector.findCaretOffsetInDocument(); + final firstCharacterRectBeforeUnIndent = SuperEditorInspector.findDocumentLayout().getRectForPosition( + DocumentPosition(nodeId: doc.last.id, nodePosition: const TextNodePosition(offset: 0)), + )!; + expect(caretOffsetBeforeUnIndent.dx, moreOrLessEquals(firstCharacterRectBeforeUnIndent.left, epsilon: 5)); + + // Press backspace to trigger the list unindent command. + await tester.pressBackspace(); + + // Ensure the list item has first level of indentation. + expect(doc.last.asListItem.indent, 0); + + // Ensure that the caret's current offset is upstream from the initial caret offset, + // and also that the current caret offset is roughly positioned near the upstream edge + // of the first list item character. + // + // We only care about a roughly accurate caret offset because the logic around + // exact caret positioning might change and we don't want that to break this test. + final caretOffsetAfterUnIndent = SuperEditorInspector.findCaretOffsetInDocument(); + expect(caretOffsetAfterUnIndent.dx, lessThan(caretOffsetBeforeUnIndent.dx)); + final firstCharacterRectAfterUnIndent = SuperEditorInspector.findDocumentLayout().getRectForPosition( + DocumentPosition(nodeId: doc.last.id, nodePosition: const TextNodePosition(offset: 0)), + )!; + expect(caretOffsetAfterUnIndent.dx, moreOrLessEquals(firstCharacterRectAfterUnIndent.left, epsilon: 5)); + }); + + testWidgetsOnDesktop('unindents with SHIFT + TAB', (tester) async { + await _pumpUnorderedListWithTextField(tester); + + final doc = SuperEditorInspector.findDocument()!; + + // Place caret at the last list item, which has two levels of indentation. + // For some reason, tapping at the first character isn't displaying any caret, + // so we put the caret at the second character and then go back one position. + await tester.placeCaretInParagraph(doc.last.id, 1); + await tester.pressLeftArrow(); + + // Ensure the list item has second level of indentation. + expect(doc.last.asListItem.indent, 1); + + // Press SHIFT + TAB to trigger the list unindent command. + await _pressShiftTab(tester); + + // Ensure the list item has first level of indentation. + expect(doc.last.asListItem.indent, 0); + }); + + testWidgetsOnDesktopAndWeb('unindents with BACKSPACE with caret at beginning of list item', (tester) async { + await _pumpUnorderedListWithTextField(tester); + + final doc = SuperEditorInspector.findDocument()!; + + // Place caret at the last list item, which has two levels of indentation. + await tester.placeCaretInParagraph(doc.last.id, 0); + + // Ensure the list item has second level of indentation. + expect(doc.last.asListItem.indent, 1); + + // Press BACKSPACE to trigger the list unindent command. + await tester.pressBackspace(); + + // Ensure the list item has first level of indentation. + expect(doc.last.asListItem.indent, 0); + }); + + testWidgetsOnAllPlatforms("inserts new item on ENTER at end of existing item", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('* Item 1') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(document.last.id, 6); + + // Type at the end of the list item to generate a composing region, + // simulating the Samsung keyboard. + await tester.typeImeText('2'); + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. Item 12', + selection: TextSelection.collapsed(offset: 9), + composing: TextRange.collapsed(9), + ), + ], getter: imeClientGetter); + + // Press enter to create a new list item. + await tester.pressEnter(); + + // Ensure that a new, empty list item was created. + expect(document.nodeCount, 2); + + // Ensure the existing item remains the same. + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "Item 12"); + + // Ensure the new item has the correct list item type and indentation. + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), ""); + expect((document.last as ListItemNode).type, ListItemType.unordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAndroid("inserts new item upon new line insertion at end of existing item", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('* Item 1') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(document.first.id, 6); + + // Type at the end of the list item to generate a composing region, + // simulating the Samsung keyboard. + await tester.typeImeText('2'); + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. Item 12', + selection: TextSelection.collapsed(offset: 9), + composing: TextRange.collapsed(9), + ), + ], getter: imeClientGetter); + + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText("\n"); + + // Ensure that a new, empty list item was created. + expect(document.nodeCount, 2); + + // Ensure the existing item remains the same. + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "Item 12"); + + // Ensure the new item has the correct list item type and indentation. + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), ""); + expect((document.last as ListItemNode).type, ListItemType.unordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnWebAndroid("inserts new item upon new line insertion at end of existing item", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('* Item 1') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(document.first.id, 6); + + // Type at the end of the list item to generate a composing region, + // simulating the Samsung keyboard. + await tester.typeImeText('2'); + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. Item 12', + selection: TextSelection.collapsed(offset: 9), + composing: TextRange.collapsed(9), + ), + ], getter: imeClientGetter); + + // On Android Web, pressing ENTER generates both a "\n" insertion and a newline input action. + await tester.pressEnterWithIme(getter: imeClientGetter); + + // Ensure that a new, empty list item was created. + expect(document.nodeCount, 2); + + // Ensure the existing item remains the same. + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "Item 12"); + + // Ensure the new item has the correct list item type and indentation. + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), ""); + expect((document.last as ListItemNode).type, ListItemType.unordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnMobile("inserts new item upon new line input action at end of existing item", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('* Item 1') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(document.first.id, 6); + + // Type at the end of the list item to generate a composing region, + // simulating the Samsung keyboard. + await tester.typeImeText('2'); + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. Item 12', + selection: TextSelection.collapsed(offset: 9), + composing: TextRange.collapsed(9), + ), + ], getter: imeClientGetter); + + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + + // Ensure that a new, empty list item was created. + expect(document.nodeCount, 2); + + // Ensure the existing item remains the same. + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "Item 12"); + + // Ensure the new item has the correct list item type and indentation. + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), ""); + expect((document.last as ListItemNode).type, ListItemType.unordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("splits list item into two on ENTER in middle of existing item", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('* List Item') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at "List |Item" + await tester.placeCaretInParagraph(document.first.id, 5); + + // Press enter to split the existing item into two. + await tester.pressEnter(); + + // Ensure that a new item was created with part of the previous item. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "List "); + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), "Item"); + expect((document.last as ListItemNode).type, ListItemType.unordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAndroid("splits list item into two upon new line insertion in middle of existing item", + (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('* List Item') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at "List |Item" + await tester.placeCaretInParagraph(document.first.id, 5); + + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText("\n"); + + // Ensure that a new item was created with part of the previous item. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "List "); + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), "Item"); + expect((document.last as ListItemNode).type, ListItemType.unordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnWebAndroid("splits list item into two upon new line insertion in middle of existing item", + (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('* List Item') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at "List |Item" + await tester.placeCaretInParagraph(document.first.id, 5); + + // On Android Web, pressing ENTER generates both a "\n" insertion and a newline input action. + await tester.pressEnterWithIme(getter: imeClientGetter); + + // Ensure that a new item was created with part of the previous item. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "List "); + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), "Item"); + expect((document.last as ListItemNode).type, ListItemType.unordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnMobile("splits list item into two upon new line input action in middle of existing item", + (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('* List Item') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at "List |Item" + await tester.placeCaretInParagraph(document.first.id, 5); + + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + + // Ensure that a new item was created with part of the previous item. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "List "); + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), "Item"); + expect((document.last as ListItemNode).type, ListItemType.unordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + }); + + group('ordered list', () { + testWidgetsOnArbitraryDesktop('keeps sequence for items split by unordered list', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(""" +1. First ordered item + - First unordered item + - Second unoredered item + +2. Second ordered item + - First unordered item + - Second unoredered item""") // + .pump(); + + expect(context.document.nodeCount, 6); + + // Ensure the nodes have the correct type. + expect(context.document.getNodeAt(0), isA()); + expect((context.document.getNodeAt(0) as ListItemNode).type, ListItemType.ordered); + + expect(context.document.getNodeAt(1), isA()); + expect((context.document.getNodeAt(1) as ListItemNode).type, ListItemType.unordered); + + expect(context.document.getNodeAt(2), isA()); + expect((context.document.getNodeAt(2) as ListItemNode).type, ListItemType.unordered); + + expect(context.document.getNodeAt(3), isA()); + expect((context.document.getNodeAt(3) as ListItemNode).type, ListItemType.ordered); + + expect(context.document.getNodeAt(4), isA()); + expect((context.document.getNodeAt(4) as ListItemNode).type, ListItemType.unordered); + + expect(context.document.getNodeAt(5), isA()); + expect((context.document.getNodeAt(5) as ListItemNode).type, ListItemType.unordered); + + // Ensure the sequence was kept. + final firstOrderedItem = tester.widget( + find.ancestor( + of: find.byWidget(SuperEditorInspector.findWidgetForComponent(context.document.getNodeAt(0)!.id)), + matching: find.byType(OrderedListItemComponent), + ), + ); + expect(firstOrderedItem.listIndex, 1); + + final secondOrderedItem = tester.widget( + find.ancestor( + of: find.byWidget(SuperEditorInspector.findWidgetForComponent(context.document.getNodeAt(3)!.id)), + matching: find.byType(OrderedListItemComponent), + ), + ); + expect(secondOrderedItem.listIndex, 2); + }); + + testWidgetsOnArbitraryDesktop('keeps sequence for items split by ordered list items with higher indentation', + (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(""" + 1. list item 1 + 2. list item 2 + 1. list item 2.1 + 2. list item 2.2 + 3. list item 3 + 1. list item 3.1 +""") // + .pump(); + + expect(context.document.nodeCount, 6); + + // Ensure the nodes have the correct type. + for (int i = 0; i < 6; i++) { + expect(context.document.getNodeAt(i), isA()); + expect((context.document.getNodeAt(i) as ListItemNode).type, ListItemType.ordered); + } + + // Ensure the sequence was kept. + expect(SuperEditorInspector.findListItemOrdinal(context.document.getNodeAt(0)!.id), 1); + expect(SuperEditorInspector.findListItemOrdinal(context.document.getNodeAt(1)!.id), 2); + expect(SuperEditorInspector.findListItemOrdinal(context.document.getNodeAt(2)!.id), 1); + expect(SuperEditorInspector.findListItemOrdinal(context.document.getNodeAt(3)!.id), 2); + expect(SuperEditorInspector.findListItemOrdinal(context.document.getNodeAt(4)!.id), 3); + expect(SuperEditorInspector.findListItemOrdinal(context.document.getNodeAt(5)!.id), 1); + }); + + testWidgetsOnArbitraryDesktop('restarts item order when separated by an unordered item', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(""" +1. First ordered item +2. Second ordered item +- First unordered item +- Second unordered item +1. First ordered item +2. Second ordered item""") // + .pump(); + + expect(context.document.nodeCount, 6); + + // Ensure the nodes have the correct type. + expect(context.document.getNodeAt(0), isA()); + expect((context.document.getNodeAt(0) as ListItemNode).type, ListItemType.ordered); + + expect(context.document.getNodeAt(1), isA()); + expect((context.document.getNodeAt(1) as ListItemNode).type, ListItemType.ordered); + + expect(context.document.getNodeAt(2), isA()); + expect((context.document.getNodeAt(2) as ListItemNode).type, ListItemType.unordered); + + expect(context.document.getNodeAt(3), isA()); + expect((context.document.getNodeAt(3) as ListItemNode).type, ListItemType.unordered); + + expect(context.document.getNodeAt(4), isA()); + expect((context.document.getNodeAt(4) as ListItemNode).type, ListItemType.ordered); + + expect(context.document.getNodeAt(5), isA()); + expect((context.document.getNodeAt(5) as ListItemNode).type, ListItemType.ordered); + + // Ensure the sequence restarted after the unordered items. + expect(SuperEditorInspector.findListItemOrdinal(context.document.getNodeAt(0)!.id), 1); + expect(SuperEditorInspector.findListItemOrdinal(context.document.getNodeAt(1)!.id), 2); + expect(SuperEditorInspector.findListItemOrdinal(context.document.getNodeAt(4)!.id), 1); + expect(SuperEditorInspector.findListItemOrdinal(context.document.getNodeAt(5)!.id), 2); + }); + + testWidgetsOnArbitraryDesktop('does not keep sequence for items split by paragraphs', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(""" +1. First ordered item + +A paragraph + +2. Second ordered item""") // + .pump(); + + expect(context.document.nodeCount, 3); + + // Ensure the nodes have the correct type. + expect(context.document.getNodeAt(0), isA()); + expect((context.document.getNodeAt(0) as ListItemNode).type, ListItemType.ordered); + + expect(context.document.getNodeAt(1), isA()); + + expect(context.document.getNodeAt(2), isA()); + expect((context.document.getNodeAt(2) as ListItemNode).type, ListItemType.ordered); + + // Ensure the sequence reset when reaching the second list item. + final firstOrderedItem = tester.widget( + find.ancestor( + of: find.byWidget(SuperEditorInspector.findWidgetForComponent(context.document.getNodeAt(0)!.id)), + matching: find.byType(OrderedListItemComponent), + ), + ); + expect(firstOrderedItem.listIndex, 1); + + final secondOrderedItem = tester.widget( + find.ancestor( + of: find.byWidget(SuperEditorInspector.findWidgetForComponent(context.document.getNodeAt(2)!.id)), + matching: find.byType(OrderedListItemComponent), + ), + ); + expect(secondOrderedItem.listIndex, 1); + }); + + testWidgetsOnArbitraryDesktop('updates caret position when indenting', (tester) async { + await _pumpOrderedListWithTextField(tester); + + final doc = SuperEditorInspector.findDocument()!; + + // Place caret at the first list item, which has one level of indentation. + await tester.placeCaretInParagraph(doc.first.id, 0); + + // Ensure the list item has first level of indentation. + expect(doc.first.asListItem.indent, 0); + + // Ensure the caret is initially positioned near the upstream edge of the first + // character of the list item. + // + // We only care about a roughly accurate caret offset because the logic around + // exact caret positioning might change and we don't want that to break this test. + final caretOffsetBeforeIndent = SuperEditorInspector.findCaretOffsetInDocument(); + final firstCharacterRectBeforeIndent = SuperEditorInspector.findDocumentLayout().getRectForPosition( + DocumentPosition(nodeId: doc.first.id, nodePosition: const TextNodePosition(offset: 0)), + )!; + expect(caretOffsetBeforeIndent.dx, moreOrLessEquals(firstCharacterRectBeforeIndent.left, epsilon: 5)); + + // Press tab to trigger the list indent command. + await tester.pressTab(); + + // Ensure the list item has second level of indentation. + expect(doc.first.asListItem.indent, 1); + + // Ensure that the caret's current offset is downstream from the initial caret offset, + // and also that the current caret offset is roughly positioned near the upstream edge + // of the first list item character. + // + // We only care about a roughly accurate caret offset because the logic around + // exact caret positioning might change and we don't want that to break this test. + final caretOffsetAfterIndent = SuperEditorInspector.findCaretOffsetInDocument(); + expect(caretOffsetAfterIndent.dx, greaterThan(caretOffsetBeforeIndent.dx)); + final firstCharacterRectAfterIndent = SuperEditorInspector.findDocumentLayout().getRectForPosition( + DocumentPosition(nodeId: doc.first.id, nodePosition: const TextNodePosition(offset: 0)), + )!; + expect(caretOffsetAfterIndent.dx, moreOrLessEquals(firstCharacterRectAfterIndent.left, epsilon: 5)); + }); + + testWidgetsOnArbitraryDesktop('updates caret position when unindenting', (tester) async { + await _pumpOrderedListWithTextField(tester); + + final doc = SuperEditorInspector.findDocument()!; + + // Place caret at the last list item, which has two levels of indentation. + // For some reason, taping at the first character isn't displaying any caret, + // so we put the caret at the second character and then go back one position. + await tester.placeCaretInParagraph(doc.last.id, 1); + await tester.pressLeftArrow(); + + // Ensure the list item has second level of indentation. + expect(doc.last.asListItem.indent, 1); + + // Ensure the caret is initially positioned near the upstream edge of the first + // character of the list item. + // + // We only care about a roughly accurate caret offset because the logic around + // exact caret positioning might change and we don't want that to break this test. + final caretOffsetBeforeUnIndent = SuperEditorInspector.findCaretOffsetInDocument(); + final firstCharacterRectBeforeUnIndent = SuperEditorInspector.findDocumentLayout().getRectForPosition( + DocumentPosition(nodeId: doc.last.id, nodePosition: const TextNodePosition(offset: 0)), + )!; + expect(caretOffsetBeforeUnIndent.dx, moreOrLessEquals(firstCharacterRectBeforeUnIndent.left, epsilon: 5)); + + // Press backspace to trigger the list unindent command. + await tester.pressBackspace(); + + // Ensure the list item has first level of indentation. + expect(doc.last.asListItem.indent, 0); + + // Ensure that the caret's current offset is upstream from the initial caret offset, + // and also that the current caret offset is roughly positioned near the upstream edge + // of the first list item character. + // + // We only care about a roughly accurate caret offset because the logic around + // exact caret positioning might change and we don't want that to break this test. + final caretOffsetAfterUnIndent = SuperEditorInspector.findCaretOffsetInDocument(); + expect(caretOffsetAfterUnIndent.dx, lessThan(caretOffsetBeforeUnIndent.dx)); + final firstCharacterRectAfterUnIndent = SuperEditorInspector.findDocumentLayout().getRectForPosition( + DocumentPosition(nodeId: doc.last.id, nodePosition: const TextNodePosition(offset: 0)), + )!; + expect(caretOffsetAfterUnIndent.dx, moreOrLessEquals(firstCharacterRectAfterUnIndent.left, epsilon: 5)); + }); + + testWidgetsOnDesktop('unindents with SHIFT + TAB', (tester) async { + await _pumpOrderedListWithTextField(tester); + + final doc = SuperEditorInspector.findDocument()!; + + // Place caret at the last list item, which has two levels of indentation. + // For some reason, taping at the first character isn't displaying any caret, + // so we put the caret at the second character and then go back one position. + await tester.placeCaretInParagraph(doc.last.id, 1); + await tester.pressLeftArrow(); + + // Ensure the list item has second level of indentation. + expect(doc.last.asListItem.indent, 1); + + // Press SHIFT + TAB to trigger the list unindent command. + await _pressShiftTab(tester); + + // Ensure the list item has first level of indentation. + expect(doc.last.asListItem.indent, 0); + }); + + testWidgetsOnAllPlatforms("inserts new item on ENTER at end of existing item", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('1. Item 1') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(document.first.id, 6); + + // Press enter to create a new list item. + await tester.pressEnter(); + + // Ensure that a new, empty list item was created. + expect(document.nodeCount, 2); + + // Ensure the existing item remains the same. + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "Item 1"); + + // Ensure the new item has the correct list item type and indentation. + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), ""); + expect((document.last as ListItemNode).type, ListItemType.ordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAndroid("inserts new item upon new line insertion at end of existing item", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('1. Item 1') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(document.first.id, 6); + + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText("\n"); + + // Ensure that a new, empty list item was created. + expect(document.nodeCount, 2); + + // Ensure the existing item remains the same. + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "Item 1"); + + // Ensure the new item has the correct list item type and indentation. + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), ""); + expect((document.last as ListItemNode).type, ListItemType.ordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnWebAndroid("inserts new item upon new line insertion at end of existing item", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('1. Item 1') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(document.first.id, 6); + + // On Android Web, pressing ENTER generates both a "\n" insertion and a newline input action. + await tester.pressEnterWithIme(getter: imeClientGetter); + + // Ensure that a new, empty list item was created. + expect(document.nodeCount, 2); + + // Ensure the existing item remains the same. + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "Item 1"); + + // Ensure the new item has the correct list item type and indentation. + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), ""); + expect((document.last as ListItemNode).type, ListItemType.ordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnMobile("inserts new item upon new line input action at end of existing item", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('1. Item 1') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(document.first.id, 6); + + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + + // Ensure that a new, empty list item was created. + expect(document.nodeCount, 2); + + // Ensure the existing item remains the same. + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "Item 1"); + + // Ensure the new item has the correct list item type and indentation. + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), ""); + expect((document.last as ListItemNode).type, ListItemType.ordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("splits list item into two on ENTER in middle of existing item", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('1. List Item') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at "List |Item" + await tester.placeCaretInParagraph(document.first.id, 5); + + // Press enter to split the existing item into two. + await tester.pressEnter(); + + // Ensure that a new item was created with part of the previous item. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "List "); + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), "Item"); + expect((document.last as ListItemNode).type, ListItemType.ordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAndroid("splits list item into two upon new line insertion in middle of existing item", + (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('1. List Item') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at "List |Item" + await tester.placeCaretInParagraph(document.first.id, 5); + + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText("\n"); + + // Ensure that a new item was created with part of the previous item. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "List "); + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), "Item"); + expect((document.last as ListItemNode).type, ListItemType.ordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnWebAndroid("splits list item into two upon new line insertion in middle of existing item", + (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('1. List Item') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at "List |Item" + await tester.placeCaretInParagraph(document.first.id, 5); + + // On Android Web, pressing ENTER generates both a "\n" insertion and a newline input action. + await tester.pressEnterWithIme(getter: imeClientGetter); + + // Ensure that a new item was created with part of the previous item. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "List "); + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), "Item"); + expect((document.last as ListItemNode).type, ListItemType.ordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnMobile("splits list item into two upon new line input action in middle of existing item", + (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('1. List Item') + .pump(); + + final document = context.findEditContext().document; + + // Place the caret at "List |Item" + await tester.placeCaretInParagraph(document.first.id, 5); + + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + + // Ensure that a new item was created with part of the previous item. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as ListItemNode).text.toPlainText(), "List "); + expect(document.last, isA()); + expect((document.last as ListItemNode).text.toPlainText(), "Item"); + expect((document.last as ListItemNode).type, ListItemType.ordered); + expect((document.last as ListItemNode).indent, 0); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + }); + }); +} + +/// Pumps a [SuperEditor] containing 3 unordered list items. +/// +/// The first two items have one level of indentation. +/// +/// The last two items have two levels of indentation. +Future _pumpUnorderedList( + WidgetTester tester, { + Stylesheet? styleSheet, +}) async { + const markdown = ''' + * list item 1 + * list item 2 + * list item 2.1 + * list item 2.2'''; + + return await tester // + .createDocument() + .fromMarkdown(markdown) + .useStylesheet(styleSheet) + .pump(); +} + +/// Pumps a [SuperEditor] containing 4 unordered list items and a [TextField] below it. +/// +/// The first two items have one level of indentation. +/// +/// The last two items have two levels of indentation. +Future _pumpUnorderedListWithTextField( + WidgetTester tester, { + Stylesheet? styleSheet, +}) async { + const markdown = ''' + * list item 1 + * list item 2 + * list item 2.1 + * list item 2.2'''; + + return await tester // + .createDocument() + .fromMarkdown(markdown) + .useStylesheet(styleSheet) + .withInputSource(TextInputSource.ime) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + const TextField(), + Expanded(child: superEditor), + const TextField(), + ], + ), + ), + ), + ) + .pump(); +} + +/// Pumps a [SuperEditor] containing 4 ordered list items. +/// +/// The first two items have one level of indentation. +/// +/// The last two items have two levels of indentation. +Future _pumpOrderedList( + WidgetTester tester, { + Stylesheet? styleSheet, +}) async { + const markdown = ''' + 1. list item 1 + 1. list item 2 + 1. list item 2.1 + 1. list item 2.2'''; + + return await tester // + .createDocument() + .fromMarkdown(markdown) + .useStylesheet(styleSheet) + .pump(); +} + +/// Pumps a [SuperEditor] containing 4 ordered list items and a [TextField] below it. +/// +/// The first two items have one level of indentation. +/// +/// The last two items have two levels of indentation. +Future _pumpOrderedListWithTextField( + WidgetTester tester, { + Stylesheet? styleSheet, +}) async { + const markdown = ''' + 1. list item 1 + 1. list item 2 + 1. list item 2.1 + 1. list item 2.2'''; + + return await tester // + .createDocument() + .fromMarkdown(markdown) + .useStylesheet(styleSheet) + .withInputSource(TextInputSource.ime) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + Expanded(child: superEditor), + const TextField(), + ], + ), + ), + ), + ) + .pump(); +} + +Future _pressShiftTab(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pumpAndSettle(); +} + +TextStyle _inlineTextStyler(Set attributions, TextStyle base) => base; + +final _styleSheet = Stylesheet( + inlineTextStyler: _inlineTextStyler, + rules: [ + StyleRule( + const BlockSelector("paragraph"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.red, + fontSize: 16, + ), + }; + }, + ), + StyleRule( + const BlockSelector("listItem"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.blue, + fontSize: 16, + ), + }; + }, + ), + ], +); diff --git a/super_editor/test/super_editor/components/markdown_table_test.dart b/super_editor/test/super_editor/components/markdown_table_test.dart new file mode 100644 index 0000000000..008e944cd1 --- /dev/null +++ b/super_editor/test/super_editor/components/markdown_table_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperEditor > Markdown Table >', () { + group('gestures >', () { + testWidgetsOnAllPlatforms('places caret at left edge when tapping at the left side', (tester) async { + await _pumpTestApp(tester); + + // Tap close to the left edge of the table to place the caret + // upstream on the table. + await tester.tapAt( + tester.getTopLeft(find.byType(MarkdownTableComponent)) + const Offset(20, 20), + ); + await tester.pump(); + + // Ensure the caret is placed at the upstream side of the table. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: SuperEditorInspector.findDocument()!.first.id, + nodePosition: const UpstreamDownstreamNodePosition.upstream(), + ), + ), + ); + + // Allow the long press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnAllPlatforms('places caret at right edge when tapping at the right side', (tester) async { + await _pumpTestApp(tester); + + // Tap close to the right edge of the table to place the caret + // downstream on the table. + await tester.tapAt( + tester.getTopRight(find.byType(MarkdownTableComponent)) + const Offset(-20, 20), + ); + await tester.pump(); + + // Ensure the caret is placed at the downstream side of the table. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: SuperEditorInspector.findDocument()!.first.id, + nodePosition: const UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // Allow the long press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnAllPlatforms('places an expanded selection when double tapping', (tester) async { + await _pumpTestApp(tester); + + // Double tap in the middle of the table to select the entire table. + await tester.tapAt( + tester.getCenter(find.byType(MarkdownTableComponent)), + ); + await tester.pump(kTapMinTime); + await tester.tapAt( + tester.getCenter(find.byType(MarkdownTableComponent)), + ); + await tester.pump(kTapMinTime); + + // The entire table should be selected. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection( + base: DocumentPosition( + nodeId: SuperEditorInspector.findDocument()!.first.id, + nodePosition: const UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: SuperEditorInspector.findDocument()!.first.id, + nodePosition: const UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + }); + }); + }); +} + +Future _pumpTestApp(WidgetTester tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +| Header 1 | Header 2 | +|---|---| +| Cell 1 | Cell 2 | +| Cell 3 | Cell 4 |''') // + .withAddedComponents([const MarkdownTableComponentBuilder()]) // + .pump(); +} diff --git a/super_editor/test/super_editor/components/paragraph_test.dart b/super_editor/test/super_editor/components/paragraph_test.dart new file mode 100644 index 0000000000..edeff6327c --- /dev/null +++ b/super_editor/test/super_editor/components/paragraph_test.dart @@ -0,0 +1,425 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_runners.dart'; + +void main() { + group("SuperEditor > Paragraph Component >", () { + testWidgetsOnAllPlatforms("visually updates alignment immediately after it is changed", (tester) async { + final editorContext = await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Note the visual offset of the caret when left-aligned. + final leftAlignedCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); + + // Ensure that we begin with a visually left-aligned paragraph widget. + var paragraphComponent = find.byType(TextComponent).evaluate().first.widget as TextComponent; + expect(paragraphComponent.textAlign, TextAlign.left); + + // Change the paragraph to right-alignment. + editorContext.editor.execute([ + ChangeParagraphAlignmentRequest(nodeId: "1", alignment: TextAlign.right), + ]); + await tester.pump(); + + // Ensure that the paragraph's associated widget is now right-aligned. + // + // This is as close as we can get to verifying visual text alignment without either + // inspecting the render object, or generating a golden file. + paragraphComponent = find.byType(TextComponent).evaluate().first.widget as TextComponent; + expect(paragraphComponent.textAlign, TextAlign.right); + + // Ensure that the caret didn't stay in the same location after changing the + // alignment of the paragraph. This check ensures that the caret overlay updated + // itself in response to the paragraph layout changing. + expect(SuperEditorInspector.findCaretOffsetInDocument() == leftAlignedCaretOffset, isFalse); + }); + + group("block newlines >", () { + testWidgetsOnAllPlatforms("inserts newline in middle and splits paragraph into two paragraphs", + (WidgetTester tester) async { + await tester + .createDocument() // + .withSingleShortParagraph() + .pump(); + + // Place the caret in the middle of the paragraph: + // "This is the first |node in a document." + await tester.placeCaretInParagraph("1", 18); + + // Insert a newline. + switch (debugDefaultTargetPlatformOverride) { + case TargetPlatform.android: + case TargetPlatform.iOS: + // FIXME: pressEnterWithIme should work, but it seems to think there are no + // connected IME clients, so it fizzles. For now, we use the implementation + // directly. + // await tester.pressEnterWithIme(); + await tester.testTextInput.receiveAction(TextInputAction.newline); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case null: + await tester.pressEnter(); + } + + // Ensure we have two paragraphs, each with part of the original text. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 2); + + expect(document.first.metadata["blockType"], paragraphAttribution); + expect(document.first.asTextNode.text.toPlainText(), "This is the first "); + + expect(document.last.metadata["blockType"], paragraphAttribution); + expect(document.last.asTextNode.text.toPlainText(), "node in a document."); + }); + + testWidgetsOnAllPlatforms("inserts newline at end of paragraph to create a new empty paragraph", + (WidgetTester tester) async { + await tester + .createDocument() // + .withSingleShortParagraph() + .pump(); + + // Place caret at the end of the paragraph. + await tester.placeCaretInParagraph("1", 37); + + // Insert a newline. + switch (debugDefaultTargetPlatformOverride) { + case TargetPlatform.android: + case TargetPlatform.iOS: + // FIXME: pressEnterWithIme should work, but it seems to think there are no + // connected IME clients, so it fizzles. For now, we use the implementation + // directly. + // await tester.pressEnterWithIme(); + await tester.testTextInput.receiveAction(TextInputAction.newline); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case null: + await tester.pressEnter(); + } + + // Ensure a new, empty paragraph was inserted after the blockquote. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 2); + + expect(document.first.metadata["blockType"], paragraphAttribution); + expect(document.first.asTextNode.text.toPlainText(), "This is the first node in a document."); + + expect(document.last.metadata["blockType"], paragraphAttribution); + expect(document.last.asTextNode.text.toPlainText(), ""); + }); + + testWidgetsOnAllPlatforms("does nothing when caret is in non-deletable paragraph", (tester) async { + await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("Non-deletable paragraph."), + metadata: const { + NodeMetadata.isDeletable: false, + }, + ), + ParagraphNode( + id: "2", + text: AttributedText("A deletable paragraph."), + ), + ], + ), + ) + .pump(); + + // Place caret in the middle of the non-deletable paragraph. + await tester.placeCaretInParagraph("1", 5); + + // Press enter to try to split the paragraph. + switch (debugDefaultTargetPlatformOverride) { + case TargetPlatform.android: + case TargetPlatform.iOS: + // FIXME: pressEnterWithIme should work, but it seems to think there are no + // connected IME clients, so it fizzles. For now, we use the implementation + // directly. + // await tester.pressEnterWithIme(); + await tester.testTextInput.receiveAction(TextInputAction.newline); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case null: + await tester.pressEnter(); + } + + // Ensure the paragraph wasn't changed. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 2); + expect(document.first.asTextNode.text.toPlainText(), "Non-deletable paragraph."); + }); + + testWidgetsOnAllPlatforms("does nothing when non-deletable content is selected", (tester) async { + final editContext = await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("A paragraph."), + ), + HorizontalRuleNode( + id: "2", + metadata: const { + NodeMetadata.isDeletable: false, + }, + ), + ], + ), + ) + .autoFocus(true) + .pump(); + + // Select from the paragraph across the HR. + editContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 5), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + await tester.pump(); + + // Press enter to try to delete part of the paragraph and a non-deletable + // horizontal rule. + switch (debugDefaultTargetPlatformOverride) { + case TargetPlatform.android: + case TargetPlatform.iOS: + // FIXME: pressEnterWithIme should work, but it seems to think there are no + // connected IME clients, so it fizzles. For now, we use the implementation + // directly. + // await tester.pressEnterWithIme(); + await tester.testTextInput.receiveAction(TextInputAction.newline); + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + case null: + await tester.pressEnter(); + } + + // Ensure nothing happened to the document. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 2); + expect(document.first.asTextNode.text.toPlainText(), "A paragraph."); + expect(document.last, isA()); + }); + }); + + group("soft newlines >", () { + testWidgetsOnDesktop("SHIFT + ENTER inserts a soft newline in middle of paragraph", (tester) async { + final editorContext = await tester // + .createDocument() + .withSingleShortParagraph() + .pump(); + + // Place the caret in the middle of the paragraph: + // "This is the first |node in a document." + await tester.placeCaretInParagraph("1", 18); + + // Hold shift and press enter. + await tester.pressShiftEnter(); + + // Ensure that we still have a single paragraph, but there's a newline in the middle. + expect(editorContext.document.nodeCount, 1); + expect(editorContext.document.first.asTextNode.text.toPlainText(), "This is the first \nnode in a document."); + }); + + testWidgetsOnDesktop("SHIFT + ENTER inserts a soft newline at end of paragraph", (tester) async { + final editorContext = await tester // + .createDocument() + .withSingleShortParagraph() + .pump(); + + // Place the caret at the end of the paragraph. + await tester.placeCaretInParagraph("1", 37); + + // Hold shift and press enter. + await tester.pressShiftEnter(); + + // Ensure that we still have a single paragraph, but it ends with a newline. + expect(editorContext.document.nodeCount, 1); + expect(editorContext.document.first.asTextNode.text.last, "\n"); + }); + }); + + group("indentation >", () { + testWidgetsOnDesktop("indents with Tab and un-indents with Shift+Tab", (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + // Place the caret in the child task. + await tester.placeCaretInParagraph("1", 0); + + // Ensure the paragraph isn't indented. + expect(SuperEditorInspector.findParagraphIndent("1"), 0); + + // Press Tab to indent the paragraph. + await tester.pressTab(); + + // Ensure the paragraph is indented. + expect(SuperEditorInspector.findParagraphIndent("1"), 1); + + // Press Tab to indent a second time. + await tester.pressTab(); + + // Ensure the paragraph is indented at level 2. + expect(SuperEditorInspector.findParagraphIndent("1"), 2); + + // Press Shift+Tab to unindent. + // TODO: add pressShiftTab to flutter_test_robots - https://github.com/Flutter-Bounty-Hunters/flutter_test_robots/issues/30 + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + // Ensure the paragraph was un-indented. + expect(SuperEditorInspector.findParagraphIndent("1"), 1); + + // Press Shift+Tab to unindent. + // TODO: add pressShiftTab to flutter_test_robots + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + // Ensure the paragraph was un-indented. + expect(SuperEditorInspector.findParagraphIndent("1"), 0); + + // Press Shift+Tab to unindent (should have no effect). + // TODO: add pressShiftTab to flutter_test_robots + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + // Ensure the indentation didn't change because it was already at zero. + expect(SuperEditorInspector.findParagraphIndent("1"), 0); + }); + + testWidgetsOnDesktop("indents with Tab when caret is in middle of text", (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + // Place the caret in the middle of the text. + await tester.placeCaretInParagraph("1", 2); + + // Ensure the paragraph isn't indented. + expect(SuperEditorInspector.findParagraphIndent("1"), 0); + + // Press Tab to indent the paragraph. + await tester.pressTab(); + + // Ensure the paragraph is indented. + expect(SuperEditorInspector.findParagraphIndent("1"), 1); + + // Press Shift+Tab to unindent. + // TODO: add pressShiftTab to flutter_test_robots + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + // Ensure the paragraph was un-indented. + expect(SuperEditorInspector.findParagraphIndent("1"), 0); + }); + + testWidgetsOnDesktop("next paragraph preserves indent, pressing Enter removes inherited indent", (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + // Place caret at the end of the paragraph. + await tester.placeCaretInParagraph("1", SuperEditorInspector.findTextInComponent("1").length); + + // Indent the paragraph. + await tester.pressTab(); + + // Insert a new paragraph. + await tester.pressEnter(); + + // Ensure the new paragraph is indented. + var newParagraph = SuperEditorInspector.findDocument()!.getNodeAt(1) as ParagraphNode; + expect(newParagraph.indent, 1); + + // Press Enter again to reset the indent. + await tester.pressEnter(); + + // Ensure the new paragraph is no longer indented. + newParagraph = SuperEditorInspector.findDocument()!.getNodeAt(1) as ParagraphNode; + expect(newParagraph.indent, 0); + }); + + testWidgetsOnDesktopAndWeb("Backspace at start of text un-indents paragraph", (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Indent the paragraph. + await tester.pressTab(); + + // Ensure the second task is indented. + expect(SuperEditorInspector.findParagraphIndent("1"), 1); + + // Place the caret in the middle of the text + await tester.placeCaretInParagraph("1", 3); + + // Press Backspace to delete one character. + await tester.pressBackspace(); + + // Ensure that the Backspace didn't un-indent the paragraph. + expect(SuperEditorInspector.findParagraphIndent("1"), 1); + + // Place caret at start of the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Press Backspace to un-indent the task. + await tester.pressBackspace(); + + // Ensure the paragraph was un-indented. + expect(SuperEditorInspector.findParagraphIndent("1"), 0); + }); + }); + }); +} diff --git a/super_editor/test/super_editor/components/task_test.dart b/super_editor/test/super_editor/components/task_test.dart new file mode 100644 index 0000000000..caf7a86406 --- /dev/null +++ b/super_editor/test/super_editor/components/task_test.dart @@ -0,0 +1,749 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/ime.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_inspector.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/src/test/super_editor_test/tasks_test_tools.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../../test_runners.dart'; + +void main() { + group("SuperEditor task component", () { + testWidgetsOnAllPlatforms("toggles on tap", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Ensure the task isn't checked. + expect((document.first as TaskNode).isComplete, false); + expect(TaskInspector.isChecked("1"), false); + + // Tap to check the box. + await tester.tapOnCheckbox("1"); + + // Ensure the task is checked. + expect((document.first as TaskNode).isComplete, true); + expect(TaskInspector.isChecked("1"), true); + + // Tap to uncheck the box. + await tester.tapOnCheckbox("1"); + + // Ensure the task isn't checked. + expect((document.first as TaskNode).isComplete, false); + expect(TaskInspector.isChecked("1"), false); + }); + + testWidgetsOnAllPlatforms("can be created from empty paragraph", (tester) async { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("This will be a task")), + ], + ); + final editor = await _pumpScaffold(tester, document: document); + + // Convert the paragraph to a task. + editor.execute([const ConvertParagraphToTaskRequest(nodeId: "1")]); + + // Ensure the node is now a task. + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect((document.first as TaskNode).text.toPlainText(), "This will be a task"); + }); + + group("inserts", () { + testWidgetsOnAllPlatforms("new task on ENTER at end of existing task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + final task = document.getNodeAt(0) as TaskNode; + await _pumpScaffold(tester, document: document); + + // Place the caret at the end of the task. + await tester.placeCaretInParagraph("1", task.text.length); + + // Press enter to create a new, empty task, below the original task. + await tester.pressEnter(); + + // Ensure that a new, empty task was created. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as TaskNode).text.toPlainText(), "This is a task"); + expect(document.last, isA()); + expect((document.last as TaskNode).text.toPlainText(), ""); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnWebDesktop("new task on ENTER at end of existing task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + final task = document.getNodeAt(0) as TaskNode; + await _pumpScaffold(tester, document: document); + + // Place the caret at the end of the task. + await tester.placeCaretInParagraph("1", task.text.length); + + // Press enter to create a new, empty task, below the original task. + // On Web, this generates both a newline input action and a key event. + await tester.pressEnter(); + await tester.testTextInput.receiveAction(TextInputAction.newline); + await tester.pump(); + + // Ensure that a new, empty task was created. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as TaskNode).text.toPlainText(), "This is a task"); + expect(document.last, isA()); + expect((document.last as TaskNode).text.toPlainText(), ""); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAndroid("new task upon new line insertion at end of existing task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + final task = document.getNodeAt(0) as TaskNode; + await _pumpScaffold(tester, document: document); + + // Place the caret at the end of the task. + await tester.placeCaretInParagraph("1", task.text.length); + + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText("\n"); + + // Ensure that a new, empty task was created. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as TaskNode).text.toPlainText(), "This is a task"); + expect(document.last, isA()); + expect((document.last as TaskNode).text.toPlainText(), ""); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnWebAndroid("new task upon new line insertion at end of existing task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + final task = document.getNodeAt(0) as TaskNode; + await _pumpScaffold(tester, document: document); + + // Place the caret at the end of the task. + await tester.placeCaretInParagraph("1", task.text.length); + + // On Android Web, pressing ENTER generates both a "\n" insertion and a newline input action. + await tester.pressEnterWithIme(getter: imeClientGetter); + + // Ensure that a new, empty task was created. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as TaskNode).text.toPlainText(), "This is a task"); + expect(document.last, isA()); + expect((document.last as TaskNode).text.toPlainText(), ""); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnMobile("new task upon new line input action at end of existing task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + final task = document.getNodeAt(0) as TaskNode; + await _pumpScaffold(tester, document: document); + + // Place the caret at the end of the task. + await tester.placeCaretInParagraph("1", task.text.length); + + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + + // Ensure that a new, empty task was created. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as TaskNode).text.toPlainText(), "This is a task"); + expect(document.last, isA()); + expect((document.last as TaskNode).text.toPlainText(), ""); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + }); + + group("splits", () { + testWidgetsOnAllPlatforms("task into two on ENTER in middle of existing task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret at "This is |a task" + await tester.placeCaretInParagraph("1", 8); + + // Press enter to split the existing task into two. + await tester.pressEnter(); + + // Ensure that a new task was created with part of the previous task. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as TaskNode).text.toPlainText(), "This is "); + expect(document.last, isA()); + expect((document.last as TaskNode).text.toPlainText(), "a task"); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAndroid("task into two upon new line insertion in middle of existing task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret at "This is |a task" + await tester.placeCaretInParagraph("1", 8); + + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText("\n"); + + // Ensure that a new task was created with part of the previous task. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as TaskNode).text.toPlainText(), "This is "); + expect(document.last, isA()); + expect((document.last as TaskNode).text.toPlainText(), "a task"); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnWebAndroid("task into two upon new line insertion in middle of existing task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret at "This is |a task" + await tester.placeCaretInParagraph("1", 8); + + // On Android Web, pressing ENTER generates both a "\n" insertion and a newline input action. + await tester.pressEnterWithIme(getter: imeClientGetter); + + // Ensure that a new task was created with part of the previous task. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as TaskNode).text.toPlainText(), "This is "); + expect(document.last, isA()); + expect((document.last as TaskNode).text.toPlainText(), "a task"); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnMobile("task into two upon new line input action in middle of existing task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret at "This is |a task" + await tester.placeCaretInParagraph("1", 8); + + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + + // Ensure that a new task was created with part of the previous task. + expect(document.nodeCount, 2); + expect(document.first, isA()); + expect((document.first as TaskNode).text.toPlainText(), "This is "); + expect(document.last, isA()); + expect((document.last as TaskNode).text.toPlainText(), "a task"); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + }); + + group("converts", () { + testWidgetsOnAllPlatforms("task to paragraph when the user presses BACKSPACE at the beginning", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret at the beginning of the task. + await tester.placeCaretInParagraph("1", 0); + + // Press backspace to merge the task with the previous paragraph. + await tester.pressBackspace(); + + // Ensure the task converted to a paragraph. + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect((document.first as ParagraphNode).text.toPlainText(), "This is a task"); + }); + + testWidgetsOnAllPlatforms( + "task to paragraph when the user presses BACKSPACE with software keyboard at the beginning", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret at the beginning of the task. + await tester.placeCaretInParagraph("1", 0); + + // Press backspace to convert the task into a paragraph. + // Simulate the user pressing BACKSPACE on a software keyboard. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: ". This is a task", + selection: TextSelection.collapsed(offset: 2), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: ". This is a task", + deletedRange: TextRange(start: 1, end: 2), + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty), + ], getter: imeClientGetter); + + // Ensure the task converted to a paragraph. + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect((document.first as ParagraphNode).text.toPlainText(), "This is a task"); + }); + + testWidgetsOnAllPlatforms("task to paragraph when the user presses ENTER on an empty task", (tester) async { + await _pumpScaffold(tester); + + // Place the caret at the beginning of the task. + await tester.placeCaretInParagraph("1", 0); + + // Press enter to convert the task into a paragraph. + await tester.pressEnter(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the task was converted to a paragraph. + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect((document.first as ParagraphNode).text.toPlainText(), ""); + }); + + testWidgetsOnAndroid("task to paragraph upon new line insertion on an empty task", (tester) async { + await _pumpScaffold(tester); + + // Place the caret at the beginning of the task. + await tester.placeCaretInParagraph("1", 0); + + // Press enter to convert the task into a paragraph. + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText("\n"); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the task was converted to a paragraph. + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect((document.first as ParagraphNode).text.toPlainText(), ""); + }); + + testWidgetsOnIos("task to paragraph new line input action on an empty task", (tester) async { + await _pumpScaffold(tester); + + // Place the caret at the beginning of the task. + await tester.placeCaretInParagraph("1", 0); + + // Press enter to convert the task into a paragraph. + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the task was converted to a paragraph. + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect((document.first as ParagraphNode).text.toPlainText(), ""); + }); + + testWidgetsOnWebDesktop("task to paragraph when the user presses ENTER on an empty task", (tester) async { + await _pumpScaffold(tester); + + // Place the caret at the beginning of the task. + await tester.placeCaretInParagraph("1", 0); + + // Press enter to convert the task into a paragraph. + // On Web, this generates both a newline input action and a key event. + await tester.pressEnter(); + await tester.testTextInput.receiveAction(TextInputAction.newline); + await tester.pump(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the task was converted to a paragraph. + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect((document.first as ParagraphNode).text.toPlainText(), ""); + }); + + testWidgets("paragraph to task for incomplete task", (tester) async { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("This is a task")), + ], + ); + final editor = await _pumpScaffold(tester, document: document); + + // Convert the paragraph to a task. + editor.execute([ + const ConvertParagraphToTaskRequest(nodeId: "1"), + ]); + + // Ensure the paragraph is a task, and it's not checked. + expect(document.first, isA()); + expect((document.first as TaskNode).isComplete, isFalse); + }); + + testWidgets("paragraph to task for complete task", (tester) async { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("This is a task")), + ], + ); + final editor = await _pumpScaffold(tester, document: document); + + // Convert the paragraph to a task. + editor.execute([ + const ConvertParagraphToTaskRequest(nodeId: "1", isComplete: true), + ]); + + // Ensure the paragraph is a task, and it IS checked. + expect(document.first, isA()); + expect((document.first as TaskNode).isComplete, isTrue); + }); + }); + + group("indentation >", () { + testWidgetsOnDesktop("does nothing without parent task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("can't indent"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret in the task. + await tester.placeCaretInParagraph("1", 0); + + // Ensure the task isn't indented. + expect(SuperEditorInspector.findTaskIndent("1"), 0); + + // Press Tab to try to indent the task. + await tester.pressTab(); + + // Ensure the task still isn't indented. + expect(SuperEditorInspector.findTaskIndent("1"), 0); + }); + + testWidgetsOnDesktop("applies indent when parent is a task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("parent"), isComplete: false), + TaskNode(id: "2", text: AttributedText("can indent"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret in the child task. + await tester.placeCaretInParagraph("2", 0); + + // Ensure the task isn't indented. + expect(SuperEditorInspector.findTaskIndent("2"), 0); + + // Press Tab to indent the task. + await tester.pressTab(); + + // Ensure the task is indented. + expect(SuperEditorInspector.findTaskIndent("2"), 1); + }); + + testWidgetsOnDesktop("Backspace at start of text un-indents task", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("one"), isComplete: false), + TaskNode(id: "2", text: AttributedText("two"), isComplete: false, indent: 1), + ], + ); + await _pumpScaffold(tester, document: document); + + // Ensure the second task is indented. + expect(SuperEditorInspector.findTaskIndent("2"), 1); + + // Place the caret at the end of the indented task. + await tester.placeCaretInParagraph("2", 3); + + // Press Backspace to delete one character. + await tester.pressBackspace(); + + // Ensure that the Backspace deleted a character, instead of un-indenting. + expect(SuperEditorInspector.findTaskIndent("2"), 1); + expect(SuperEditorInspector.findTextInComponent("2").toPlainText(), "tw"); + + // Place caret at start of task. + await tester.placeCaretInParagraph("2", 0); + + // Press Backspace to un-indent the task. + await tester.pressBackspace(); + + // Ensure the task was un-indented.. + expect(SuperEditorInspector.findTaskIndent("2"), 0); + }); + + testWidgetsOnDesktop("does not apply to following tasks at same level", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("one"), isComplete: false), + TaskNode(id: "2", text: AttributedText("two"), isComplete: false), + TaskNode(id: "3", text: AttributedText("three"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret in the child task. + await tester.placeCaretInParagraph("2", 0); + + // Ensure the task isn't indented. + expect(SuperEditorInspector.findTaskIndent("2"), 0); + + // Press Tab to indent the task. + await tester.pressTab(); + + // Ensure the 2nd task is indented. + expect(SuperEditorInspector.findTaskIndent("2"), 1); + + // Ensure the 3rd task isn't indented. + expect(SuperEditorInspector.findTaskIndent("3"), 0); + }); + + testWidgetsOnDesktop("can indent multiple levels based on parent", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("one"), isComplete: false), + TaskNode(id: "2", text: AttributedText("two"), isComplete: false, indent: 1), + TaskNode(id: "3", text: AttributedText("three"), isComplete: false, indent: 2), + TaskNode(id: "4", text: AttributedText("four"), isComplete: false), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret in the child task. + await tester.placeCaretInParagraph("4", 0); + + // Press Tab multiple times to indent multiple levels. + await tester.pressTab(); + await tester.pressTab(); + await tester.pressTab(); + + // Ensure the 4th task is indented to level 3. + expect(SuperEditorInspector.findTaskIndent("4"), 3); + }); + + testWidgetsOnDesktop("does not indent more than one space past the parent", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("one"), isComplete: false), + TaskNode(id: "2", text: AttributedText("two"), isComplete: false, indent: 1), + TaskNode(id: "3", text: AttributedText("three"), isComplete: false, indent: 2), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret in the child task. + await tester.placeCaretInParagraph("2", 0); + + // Ensure the task is initially indented at level 1. + expect(SuperEditorInspector.findTaskIndent("2"), 1); + + // Press Tab to attempt to further indent. + await tester.pressTab(); + + // Ensure the indent didn't change because it was already at max indent. + expect(SuperEditorInspector.findTaskIndent("2"), 1); + }); + + testWidgetsOnDesktop("unindenting parent pulls children back", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("one"), isComplete: false), + TaskNode(id: "2", text: AttributedText("two"), isComplete: false, indent: 1), + TaskNode(id: "3", text: AttributedText("three"), isComplete: false, indent: 2), + TaskNode(id: "4", text: AttributedText("four"), isComplete: false, indent: 2), + TaskNode(id: "5", text: AttributedText("five"), isComplete: false, indent: 3), + ], + ); + await _pumpScaffold(tester, document: document); + + // Place the caret in the second task. + await tester.placeCaretInParagraph("2", 0); + + // Ensure the 2nd task begins indented. + expect(SuperEditorInspector.findTaskIndent("2"), 1); + + // Un-indent the second task. + // TODO: add pressShiftTab to flutter_test_robots + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + // Ensure the 2nd task un-indented. + expect(SuperEditorInspector.findTaskIndent("2"), 0); + + // Ensure the lower tasks reduced their indentation level. + expect(SuperEditorInspector.findTaskIndent("3"), 1); + expect(SuperEditorInspector.findTaskIndent("4"), 1); + expect(SuperEditorInspector.findTaskIndent("5"), 2); + }); + + testWidgetsOnDesktop("deleting parent task pulls children back", (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("one"), isComplete: false), + TaskNode(id: "2", text: AttributedText("two"), isComplete: false, indent: 1), + TaskNode(id: "3", text: AttributedText("three"), isComplete: false, indent: 2), + TaskNode(id: "4", text: AttributedText("four"), isComplete: false, indent: 1), + TaskNode(id: "5", text: AttributedText("five"), isComplete: false, indent: 2), + TaskNode(id: "6", text: AttributedText("six"), isComplete: false, indent: 0), + ], + ); + final editor = await _pumpScaffold(tester, document: document); + + // Delete the 2nd task. + editor.execute([ + DeleteNodeRequest(nodeId: "2"), + ]); + await tester.pump(); + + // Ensure that the third task automatically decreased its indent. + expect(SuperEditorInspector.findTaskIndent("3"), 1); + + // Ensure the legal tasks below the deleted task weren't impacted. + expect(SuperEditorInspector.findTaskIndent("4"), 1); + expect(SuperEditorInspector.findTaskIndent("5"), 2); + expect(SuperEditorInspector.findTaskIndent("6"), 0); + }); + }); + }); +} + +Future _pumpScaffold(WidgetTester tester, {MutableDocument? document}) async { + document ??= MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText(), isComplete: false), + ], + ); + + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + ), + ), + ), + ); + + return editor; +} diff --git a/super_editor/test/super_editor/custom_tap_handlers/add_paragraph_at_end_tap_handler_test.dart b/super_editor/test/super_editor/custom_tap_handlers/add_paragraph_at_end_tap_handler_test.dart new file mode 100644 index 0000000000..ac67fd5485 --- /dev/null +++ b/super_editor/test/super_editor/custom_tap_handlers/add_paragraph_at_end_tap_handler_test.dart @@ -0,0 +1,102 @@ +import 'dart:ui'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_tools.dart'; + +void main() { + group('SuperEditor > SuperEditorAddEmptyParagraphTapHandler > ', () { + group('when tapping below the end of the document', () { + testWidgetsOnAllPlatforms('adds a new empty paragraph when the last node is a non-text node', (tester) async { + // Pump an editor with a height big enough so we know we can tap + // at a space after the document ends. + await tester // + .createDocument() + .withCustomContent(MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('First paragraph'), + ), + HorizontalRuleNode(id: 'hr') + ], + )) + .withEditorSize(const Size(500, 1000)) + .withTapDelegateFactories([superEditorAddEmptyParagraphTapHandlerFactory]).pump(); + + // Tap below the end of the document and wait for the double tap + // timeout to expire. + await tester.tapAt(const Offset(490, 990)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure a new empty paragraph was added. + expect(document.nodeCount, equals(3)); + expect(document.last, isA()); + expect((document.last as ParagraphNode).text.toPlainText(), isEmpty); + + // Ensure the selection was placed in the newly added paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms('does nothing when the last node is a text node', (tester) async { + // Pump an editor with a height big enough so we know we can tap + // at a space after the document ends. + await tester // + .createDocument() + .withCustomContent(MutableDocument( + nodes: [ + HorizontalRuleNode(id: 'hr'), + ParagraphNode( + id: '1', + text: AttributedText('First paragraph'), + ), + ], + )) + .withEditorSize(const Size(500, 1000)) + .withTapDelegateFactories([superEditorAddEmptyParagraphTapHandlerFactory]) // + .pump(); + + // Tap below the end of the document and wait for the double tap + // timeout to expire. + await tester.tapAt(const Offset(490, 990)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the existing paragraph was kept. + expect(document.nodeCount, equals(2)); + expect(document.last, isA()); + expect((document.last as ParagraphNode).text.toPlainText(), 'First paragraph'); + + // Ensure the selection was placed at the end of the paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 15), + ), + ), + ), + ); + }); + }); + }); +} diff --git a/super_editor/test/super_editor/desktop/super_editor_desktop_selection_test.dart b/super_editor/test/super_editor/desktop/super_editor_desktop_selection_test.dart new file mode 100644 index 0000000000..53dde01c49 --- /dev/null +++ b/super_editor/test/super_editor/desktop/super_editor_desktop_selection_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group("Super Editor > desktop >", () { + testWidgetsOnDesktop("selects by word when double tap and dragging downstream", (tester) async { + // "Lorem ipsum |dolor sit| amet, consectetur adipiscing elit, sed do eiusmod tempor..." + // ^ ^ ^ + // 12 14 21 + await _pumpSingleParagraphScaffold(tester); + + final gesture = await tester.doubleTapDownInParagraph("1", 14); + + for (int i = 0; i < 10; i += 1) { + await gesture.moveBy(const Offset(10, 0)); + await tester.pump(); + } + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + await gesture.up(); + }); + + testWidgetsOnDesktop("selects by word when double tap and dragging upstream", (tester) async { + // "Lorem |ipsum dolor| sit amet, consectetur adipiscing elit, sed do eiusmod tempor..." + // ^ ^ ^ + // 6 14 17 + await _pumpSingleParagraphScaffold(tester); + + final gesture = await tester.doubleTapDownInParagraph("1", 14); + + for (int i = 0; i < 10; i += 1) { + await gesture.moveBy(const Offset(-10, 0)); + await tester.pump(); + } + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + + await gesture.up(); + }); + }); +} + +Future _pumpSingleParagraphScaffold(WidgetTester tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .pump(); +} diff --git a/super_editor/test/super_editor/document_test_tools.dart b/super_editor/test/super_editor/document_test_tools.dart deleted file mode 100644 index ce8b2ba262..0000000000 --- a/super_editor/test/super_editor/document_test_tools.dart +++ /dev/null @@ -1,488 +0,0 @@ -import 'dart:math'; -import 'dart:ui' as ui; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_editor/super_editor_test.dart'; -import 'package:super_editor_markdown/super_editor_markdown.dart'; -import 'package:text_table/text_table.dart'; - -import 'test_documents.dart'; - -/// Extensions on [WidgetTester] that configure and pump [SuperEditor] -/// document editors. -extension DocumentTester on WidgetTester { - /// Starts the process for configuring and pumping a new [SuperEditor]. - /// - /// Use the returned [TestDocumentSelector] to continue configuring the - /// [SuperEditor]. - TestDocumentSelector createDocument() { - return TestDocumentSelector(this); - } - - /// Pumps a new [SuperEditor] using the existing [context]. - /// - /// Use this method to simulate a [SuperEditor] whose widget tree changes. - TestDocumentConfigurator updateDocument(TestDocumentContext context) { - return TestDocumentConfigurator._fromExistingContext(this, context); - } -} - -/// Selects a [Document] configuration when composing a [SuperEditor] -/// widget in a test. -/// -/// Each document selection returns a [TestDocumentConfigurator], which -/// is used to complete the configuration, and to pump the [SuperEditor]. -class TestDocumentSelector { - const TestDocumentSelector(this._widgetTester); - - final WidgetTester _widgetTester; - - TestDocumentConfigurator withCustomContent(MutableDocument document) { - return TestDocumentConfigurator._(_widgetTester, document); - } - - /// Configures the editor with a [Document] that's parsed from the - /// given [markdown]. - TestDocumentConfigurator fromMarkdown(String markdown) { - return TestDocumentConfigurator._( - _widgetTester, - deserializeMarkdownToDocument(markdown), - ); - } - - TestDocumentConfigurator withSingleEmptyParagraph() { - return TestDocumentConfigurator._( - _widgetTester, - singleParagraphEmptyDoc(), - ); - } - - TestDocumentConfigurator withSingleParagraph() { - return TestDocumentConfigurator._( - _widgetTester, - singleParagraphDoc(), - ); - } - - TestDocumentConfigurator withTwoEmptyParagraphs() { - return TestDocumentConfigurator._( - _widgetTester, - twoParagraphEmptyDoc(), - ); - } - - TestDocumentConfigurator withLongTextContent() { - return TestDocumentConfigurator._( - _widgetTester, - longTextDoc(), - ); - } -} - -/// Builder that configures and pumps a [SuperEditor] widget. -class TestDocumentConfigurator { - TestDocumentConfigurator._fromExistingContext(this._widgetTester, this._existingContext) : _document = null; - - TestDocumentConfigurator._(this._widgetTester, this._document) : _existingContext = null; - - final WidgetTester _widgetTester; - final MutableDocument? _document; - final TestDocumentContext? _existingContext; - DocumentGestureMode? _gestureMode; - DocumentInputSource? _inputSource; - ThemeData? _appTheme; - Stylesheet? _stylesheet; - final _addedComponents = []; - bool _autoFocus = false; - ui.Size? _editorSize; - List? _componentBuilders; - WidgetTreeBuilder? _widgetTreeBuilder; - ScrollController? _scrollController; - FocusNode? _focusNode; - DocumentSelection? _selection; - WidgetBuilder? _androidToolbarBuilder; - WidgetBuilder? _iOSToolbarBuilder; - - /// Configures the [SuperEditor] for standard desktop interactions, - /// e.g., mouse and keyboard input. - TestDocumentConfigurator forDesktop({ - DocumentInputSource inputSource = DocumentInputSource.keyboard, - }) { - _inputSource = inputSource; - _gestureMode = DocumentGestureMode.mouse; - return this; - } - - /// Configures the [SuperEditor] for standard Android interactions, - /// e.g., touch gestures and IME input. - TestDocumentConfigurator forAndroid() { - _gestureMode = DocumentGestureMode.android; - _inputSource = DocumentInputSource.ime; - return this; - } - - /// Configures the [SuperEditor] for standard iOS interactions, - /// e.g., touch gestures and IME input. - TestDocumentConfigurator forIOS() { - _gestureMode = DocumentGestureMode.iOS; - _inputSource = DocumentInputSource.ime; - return this; - } - - /// Configures the [SuperEditor] to use the given [inputSource]. - TestDocumentConfigurator withInputSource(DocumentInputSource inputSource) { - _inputSource = inputSource; - return this; - } - - /// Configures the [SuperEditor] to use the given [gestureMode]. - TestDocumentConfigurator withGestureMode(DocumentGestureMode gestureMode) { - _gestureMode = gestureMode; - return this; - } - - /// Configures the [SuperEditor] to constrain its maxHeight and maxWidth using the given [size]. - TestDocumentConfigurator withEditorSize(ui.Size? size) { - _editorSize = size; - return this; - } - - /// Configures the [SuperEditor] to use only the given [componentBuilders] - TestDocumentConfigurator withComponentBuilders(List? componentBuilders) { - _componentBuilders = componentBuilders; - return this; - } - - /// Configures the [SuperEditor] to use a custom widget tree above [SuperEditor]. - TestDocumentConfigurator withCustomWidgetTreeBuilder(WidgetTreeBuilder? builder) { - _widgetTreeBuilder = builder; - return this; - } - - /// Configures the [SuperEditor] to use the given [scrollController] - TestDocumentConfigurator withScrollController(ScrollController? scrollController) { - _scrollController = scrollController; - return this; - } - - /// Configures the [SuperEditor] to use the given [focusNode] - TestDocumentConfigurator withFocusNode(FocusNode? focusNode) { - _focusNode = focusNode; - return this; - } - - /// Configures the [SuperEditor] to use the given [selection] as its initial selection. - TestDocumentConfigurator withSelection(DocumentSelection? selection) { - _selection = selection; - return this; - } - - /// Configures the [SuperEditor] to use the given [builder] as its android toolbar builder. - TestDocumentConfigurator withAndroidToolbarBuilder(WidgetBuilder? builder) { - _androidToolbarBuilder = builder; - return this; - } - - /// Configures the [SuperEditor] to use the given [builder] as its iOS toolbar builder. - TestDocumentConfigurator withiOSToolbarBuilder(WidgetBuilder? builder) { - _iOSToolbarBuilder = builder; - return this; - } - - - /// Configures the [ThemeData] used for the [MaterialApp] that wraps - /// the [SuperEditor]. - TestDocumentConfigurator useAppTheme(ThemeData theme) { - _appTheme = theme; - return this; - } - - /// Configures the [SuperEditor] to use the given [stylesheet]. - TestDocumentConfigurator useStylesheet(Stylesheet? stylesheet) { - _stylesheet = stylesheet; - return this; - } - - /// Adds the given component builders to the list of component builders that are - /// used to render the document layout in the pumped [SuperEditor]. - TestDocumentConfigurator withAddedComponents(List newComponents) { - _addedComponents.addAll(newComponents); - return this; - } - - /// Configures the [SuperEditor] to auto-focus when first pumped, or not. - TestDocumentConfigurator autoFocus(bool autoFocus) { - _autoFocus = autoFocus; - return this; - } - - /// Pumps a [SuperEditor] widget tree with the desired configuration, and returns - /// a [TestDocumentContext], which includes the artifacts connected to the widget - /// tree, e.g., the [DocumentEditor], [DocumentComposer], etc. - Future pump() async { - assert(_document != null || _existingContext != null); - - late TestDocumentContext testDocumentContext; - if (_document != null) { - final layoutKey = GlobalKey(); - final focusNode = _focusNode ?? FocusNode(); - final editor = DocumentEditor(document: _document!); - final composer = DocumentComposer(initialSelection: _selection); - // ignore: prefer_function_declarations_over_variables - final layoutResolver = () => layoutKey.currentState as DocumentLayout; - final commonOps = CommonEditorOperations( - editor: editor, - documentLayoutResolver: layoutResolver, - composer: composer, - ); - final editContext = EditContext( - editor: editor, - getDocumentLayout: layoutResolver, - composer: composer, - commonOps: commonOps, - ); - - testDocumentContext = TestDocumentContext._( - focusNode: focusNode, - layoutKey: layoutKey, - editContext: editContext, - ); - } else { - testDocumentContext = _existingContext!; - } - - final superEditor = _buildContent( - SuperEditor( - documentLayoutKey: testDocumentContext.layoutKey, - editor: testDocumentContext.editContext.editor, - composer: testDocumentContext.editContext.composer, - focusNode: testDocumentContext.focusNode, - inputSource: _inputSource, - gestureMode: _gestureMode, - androidToolbarBuilder: _androidToolbarBuilder, - iOSToolbarBuilder: _iOSToolbarBuilder, - stylesheet: _stylesheet, - componentBuilders: [ - ..._addedComponents, - ...(_componentBuilders ?? defaultComponentBuilders), - ], - autofocus: _autoFocus, - scrollController: _scrollController, - ), - ); - - await _widgetTester.pumpWidget( - _buildWidgetTree(superEditor), - ); - - return testDocumentContext; - } - - Widget _buildContent(Widget superEditor) { - if (_editorSize != null) { - return ConstrainedBox( - constraints: BoxConstraints( - maxWidth: _editorSize!.width, - maxHeight: _editorSize!.height, - ), - child: superEditor, - ); - } - return superEditor; - } - - Widget _buildWidgetTree(Widget superEditor) { - if (_widgetTreeBuilder != null) { - return _widgetTreeBuilder!(superEditor); - } - return MaterialApp( - theme: _appTheme, - home: Scaffold( - body: superEditor, - ), - ); - } -} - -/// Must return a widget tree containing the given [superEditor] -typedef WidgetTreeBuilder = Widget Function(Widget superEditor); - -class TestDocumentContext { - const TestDocumentContext._({ - required this.focusNode, - required this.layoutKey, - required this.editContext, - }); - - final FocusNode focusNode; - final GlobalKey layoutKey; - final EditContext editContext; -} - -Matcher equalsMarkdown(String markdown) => DocumentEqualsMarkdownMatcher(markdown); - -class DocumentEqualsMarkdownMatcher extends Matcher { - const DocumentEqualsMarkdownMatcher(this._expectedMarkdown); - - final String _expectedMarkdown; - - @override - Description describe(Description description) { - return description.add("given Document has equivalent content to the given markdown"); - } - - @override - bool matches(covariant Object target, Map matchState) { - return _calculateMismatchReason(target, matchState) == null; - } - - @override - Description describeMismatch( - covariant Object target, - Description mismatchDescription, - Map matchState, - bool verbose, - ) { - final mismatchReason = _calculateMismatchReason(target, matchState); - if (mismatchReason != null) { - mismatchDescription.add(mismatchReason); - } - return mismatchDescription; - } - - String? _calculateMismatchReason( - Object target, - Map matchState, - ) { - late Document actualDocument; - if (target is Document) { - actualDocument = target; - } else { - // If we weren't given a Document, then we expect to receive a Finder - // that locates a SuperEditor, which contains a Document. - if (target is! Finder) { - return "the given target isn't a Document or a Finder: $target"; - } - - final document = SuperEditorInspector.findDocument(target); - if (document == null) { - return "Finder didn't match any SuperEditor widgets: $Finder"; - } - actualDocument = document; - } - - final actualMarkdown = serializeDocumentToMarkdown(actualDocument); - final stringMatcher = equals(_expectedMarkdown); - final matcherState = {}; - final matches = stringMatcher.matches(actualMarkdown, matcherState); - if (matches) { - // The document matches the markdown. Our matcher matches. - return null; - } - - return stringMatcher.describeMismatch(actualMarkdown, StringDescription(), matchState, false).toString(); - } -} - -Matcher documentEquivalentTo(Document expectedDocument) => EquivalentDocumentMatcher(expectedDocument); - -class EquivalentDocumentMatcher extends Matcher { - const EquivalentDocumentMatcher(this._expectedDocument); - - final Document _expectedDocument; - - @override - Description describe(Description description) { - return description.add("given Document has equivalent content to expected Document"); - } - - @override - bool matches(covariant Object target, Map matchState) { - return _calculateMismatchReason(target, matchState) == null; - } - - @override - Description describeMismatch( - covariant Object target, - Description mismatchDescription, - Map matchState, - bool verbose, - ) { - final mismatchReason = _calculateMismatchReason(target, matchState); - if (mismatchReason != null) { - mismatchDescription.add(mismatchReason); - } - return mismatchDescription; - } - - String? _calculateMismatchReason( - Object target, - Map matchState, - ) { - late Document actualDocument; - if (target is Document) { - actualDocument = target; - } else { - // If we weren't given a Document, then we expect to receive a Finder - // that locates a SuperEditor, which contains a Document. - if (target is! Finder) { - return "the given target isn't a Document or a Finder: $target"; - } - - final document = SuperEditorInspector.findDocument(target); - if (document == null) { - return "Finder didn't match any SuperEditor widgets: $Finder"; - } - actualDocument = document; - } - - final messages = []; - bool nodeCountMismatch = false; - bool nodeTypeOrContentMismatch = false; - - if (_expectedDocument.nodes.length != actualDocument.nodes.length) { - messages - .add("expected ${_expectedDocument.nodes.length} document nodes but found ${actualDocument.nodes.length}"); - nodeCountMismatch = true; - } else { - messages.add("document have the same number of nodes"); - } - - final maxNodeCount = max(_expectedDocument.nodes.length, actualDocument.nodes.length); - final nodeComparisons = List.generate(maxNodeCount, (index) => ["", "", " "]); - for (int i = 0; i < maxNodeCount; i += 1) { - if (i < _expectedDocument.nodes.length && i < actualDocument.nodes.length) { - nodeComparisons[i][0] = _expectedDocument.nodes[i].runtimeType.toString(); - nodeComparisons[i][1] = actualDocument.nodes[i].runtimeType.toString(); - - if (_expectedDocument.nodes[i].runtimeType != actualDocument.nodes[i].runtimeType) { - nodeComparisons[i][2] = "Wrong Type"; - nodeTypeOrContentMismatch = true; - } else if (!_expectedDocument.nodes[i].hasEquivalentContent(actualDocument.nodes[i])) { - nodeComparisons[i][2] = "Different Content"; - nodeTypeOrContentMismatch = true; - } - } else if (i < _expectedDocument.nodes.length) { - nodeComparisons[i][0] = _expectedDocument.nodes[i].runtimeType.toString(); - nodeComparisons[i][1] = "NA"; - nodeComparisons[i][2] = "Missing Node"; - } else if (i < actualDocument.nodes.length) { - nodeComparisons[i][0] = "NA"; - nodeComparisons[i][1] = actualDocument.nodes[i].runtimeType.toString(); - nodeComparisons[i][2] = "Missing Node"; - } - } - - if (nodeCountMismatch || nodeTypeOrContentMismatch) { - String messagesList = messages.join(", "); - messagesList += "\n"; - messagesList += const TableRenderer().render(nodeComparisons, columns: ["Expected", "Actual", "Difference"]); - return messagesList; - } - - return null; - } -} diff --git a/super_editor/test/src/attributed_text_styles_test.dart b/super_editor/test/super_editor/infrastructure/attributed_text_styles_test.dart similarity index 72% rename from super_editor/test/src/attributed_text_styles_test.dart rename to super_editor/test/super_editor/infrastructure/attributed_text_styles_test.dart index e741063645..e971c681a6 100644 --- a/super_editor/test/src/attributed_text_styles_test.dart +++ b/super_editor/test/super_editor/infrastructure/attributed_text_styles_test.dart @@ -1,14 +1,11 @@ -import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/super_editor.dart'; void main() { group('Attributed Text', () { test('no styles', () { - final text = AttributedText( - text: 'abcdefghij', - ); + final text = AttributedText('abcdefghij'); final textSpan = text.computeTextSpan(_styleBuilder); expect(textSpan.text, 'abcdefghij'); @@ -16,15 +13,7 @@ void main() { }); test('full-span style', () { - final text = AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), - const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), - ], - ), - ); + final text = attributedTextFromMarkdown("**abcdefghij**"); final textSpan = text.computeTextSpan(_styleBuilder); expect(textSpan.text, 'abcdefghij'); @@ -33,15 +22,7 @@ void main() { }); test('single character style', () { - final text = AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: ExpectedSpans.bold, offset: 1, markerType: SpanMarkerType.start), - const SpanMarker(attribution: ExpectedSpans.bold, offset: 1, markerType: SpanMarkerType.end), - ], - ), - ); + final text = attributedTextFromMarkdown("a**b**cdefghij"); final textSpan = text.computeTextSpan(_styleBuilder); expect(textSpan.text, null); @@ -55,8 +36,8 @@ void main() { test('single character style - reverse order', () { final text = AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( + 'abcdefghij', + AttributedSpans( attributions: [ // Notice that the markers are provided in reverse order: // end then start. Order shouldn't matter within a single @@ -78,8 +59,8 @@ void main() { }); test('add single character style', () { - final text = AttributedText(text: 'abcdefghij'); - text.addAttribution(ExpectedSpans.bold, const SpanRange(start: 1, end: 1)); + final text = AttributedText('abcdefghij'); + text.addAttribution(ExpectedSpans.bold, const SpanRange(1, 1)); final textSpan = text.computeTextSpan(_styleBuilder); expect(textSpan.text, null); @@ -92,15 +73,7 @@ void main() { }); test('partial style', () { - final text = AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), - const SpanMarker(attribution: ExpectedSpans.bold, offset: 7, markerType: SpanMarkerType.end), - ], - ), - ); + final text = attributedTextFromMarkdown("ab**cdefgh**ij"); final textSpan = text.computeTextSpan(_styleBuilder); expect(textSpan.text, null); @@ -112,19 +85,11 @@ void main() { }); test('add styled character to existing styled text', () { - final initialText = AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.start), - const SpanMarker(attribution: ExpectedSpans.bold, offset: 9, markerType: SpanMarkerType.end), - ], - ), - ); + final initialText = attributedTextFromMarkdown("abcdefghi**j**"); final newText = initialText.copyAndAppend(AttributedText( - text: 'k', - spans: AttributedSpans( + 'k', + AttributedSpans( attributions: [ const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.end), @@ -147,17 +112,7 @@ void main() { }); test('non-mingled varying styles', () { - final text = AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: ExpectedSpans.bold, offset: 0, markerType: SpanMarkerType.start), - const SpanMarker(attribution: ExpectedSpans.bold, offset: 4, markerType: SpanMarkerType.end), - const SpanMarker(attribution: ExpectedSpans.italics, offset: 5, markerType: SpanMarkerType.start), - const SpanMarker(attribution: ExpectedSpans.italics, offset: 9, markerType: SpanMarkerType.end), - ], - ), - ); + final text = attributedTextFromMarkdown("**abcde***fghij*"); final textSpan = text.computeTextSpan(_styleBuilder); expect(textSpan.text, null); @@ -171,9 +126,11 @@ void main() { }); test('intermingled varying styles', () { + // Note: we configure attributed text directly because Markdown doesn't know + // how to parse overlapping bold and italics like we have in this test. final text = AttributedText( - text: 'abcdefghij', - spans: AttributedSpans( + 'abcdefghij', + AttributedSpans( attributions: [ const SpanMarker(attribution: ExpectedSpans.bold, offset: 2, markerType: SpanMarkerType.start), const SpanMarker(attribution: ExpectedSpans.italics, offset: 4, markerType: SpanMarkerType.start), diff --git a/super_editor/test/src/default_editor/common_editor_operations_test.dart b/super_editor/test/super_editor/infrastructure/common_editor_operations_test.dart similarity index 62% rename from super_editor/test/src/default_editor/common_editor_operations_test.dart rename to super_editor/test/super_editor/infrastructure/common_editor_operations_test.dart index 42e54a80a6..5c1da25871 100644 --- a/super_editor/test/src/default_editor/common_editor_operations_test.dart +++ b/super_editor/test/super_editor/infrastructure/common_editor_operations_test.dart @@ -1,13 +1,10 @@ +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; -import '../_document_test_tools.dart'; -import 'package:flutter_test_robots/flutter_test_robots.dart'; - -import '../../super_editor/document_test_tools.dart'; -import '../../test_tools.dart'; - void main() { group("Common editor operations", () { group("deletion", () { @@ -15,19 +12,16 @@ void main() { final document = MutableDocument(nodes: [ ParagraphNode( id: "1", - text: AttributedText( - text: 'This is a blockquote!', - ), + text: AttributedText('This is a blockquote!'), ), ParagraphNode( id: "2", text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), ), ]); - final editor = DocumentEditor(document: document); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -39,17 +33,18 @@ void main() { ), ), ); + final editor = createDefaultDocumentEditor(document: document, composer: composer); final commonOps = CommonEditorOperations( editor: editor, + document: document, composer: composer, documentLayoutResolver: () => FakeDocumentLayout(), ); - commonOps.deleteSelection(); + commonOps.deleteSelection(TextAffinity.downstream); - final doc = editor.document; - expect(doc.nodes.length, 1); - expect(doc.nodes.first.id, "2"); + expect(document.nodeCount, 1); + expect(document.first.id, "2"); expect(composer.selection!.extent.nodeId, "2"); expect(composer.selection!.extent.nodePosition, const TextNodePosition(offset: 0)); }); @@ -60,12 +55,11 @@ void main() { ParagraphNode( id: "2", text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), ), ]); - final editor = DocumentEditor(document: document); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -77,17 +71,18 @@ void main() { ), ), ); + final editor = createDefaultDocumentEditor(document: document, composer: composer); final commonOps = CommonEditorOperations( editor: editor, + document: document, composer: composer, documentLayoutResolver: () => FakeDocumentLayout(), ); - commonOps.deleteSelection(); + commonOps.deleteSelection(TextAffinity.downstream); - final doc = editor.document; - expect(doc.nodes.length, 1); - expect(doc.nodes.first.id, "2"); + expect(document.nodeCount, 1); + expect(document.first.id, "2"); expect(composer.selection!.extent.nodeId, "2"); expect(composer.selection!.extent.nodePosition, const TextNodePosition(offset: 0)); }); @@ -97,13 +92,12 @@ void main() { ParagraphNode( id: "1", text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), ), HorizontalRuleNode(id: "2"), ]); - final editor = DocumentEditor(document: document); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -115,17 +109,18 @@ void main() { ), ), ); + final editor = createDefaultDocumentEditor(document: document, composer: composer); final commonOps = CommonEditorOperations( editor: editor, + document: document, composer: composer, documentLayoutResolver: () => FakeDocumentLayout(), ); - commonOps.deleteSelection(); + commonOps.deleteSelection(TextAffinity.downstream); - final doc = editor.document; - expect(doc.nodes.length, 1); - expect(doc.nodes.first.id, "1"); + expect(document.nodeCount, 1); + expect(document.first.id, "1"); expect(composer.selection!.extent.nodeId, "1"); expect(composer.selection!.extent.nodePosition, const TextNodePosition(offset: 50)); }); @@ -135,8 +130,7 @@ void main() { HorizontalRuleNode(id: "1"), HorizontalRuleNode(id: "2"), ]); - final editor = DocumentEditor(document: document); - final composer = DocumentComposer( + final composer = MutableDocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -148,18 +142,19 @@ void main() { ), ), ); + final editor = createDefaultDocumentEditor(document: document, composer: composer); final commonOps = CommonEditorOperations( editor: editor, + document: document, composer: composer, documentLayoutResolver: () => FakeDocumentLayout(), ); - commonOps.deleteSelection(); + commonOps.deleteSelection(TextAffinity.downstream); - final doc = editor.document; - expect(doc.nodes.length, 1); - expect(doc.nodes.first, isA()); - expect(doc.nodes.first.id, "1"); + expect(document.nodeCount, 1); + expect(document.first, isA()); + expect(document.first.id, "1"); expect(composer.selection!.extent.nodePosition, const TextNodePosition(offset: 0)); }); }); @@ -189,6 +184,29 @@ void main() { ); }); }); + + group('getDocumentPositionAfterExpandedDeletion', () { + test('returns null for collapsed selection', () { + final node = HorizontalRuleNode( + id: "1", + ); + + expect( + CommonEditorOperations.getDocumentPositionAfterExpandedDeletion( + document: MutableDocument(nodes: [ + node, + ]), + selection: DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: node.id, + nodePosition: node.endPosition, + ), + ), + ), + isNull, + ); + }); + }); }); } @@ -198,16 +216,16 @@ MutableDocument _singleParagraphWithLinkDoc() { ParagraphNode( id: "1", text: AttributedText( - text: "https://google.com", - spans: AttributedSpans( + "https://google.com", + AttributedSpans( attributions: [ SpanMarker( - attribution: LinkAttribution(url: Uri.parse('https://google.com')), + attribution: LinkAttribution.fromUri(Uri.parse('https://google.com')), offset: 0, markerType: SpanMarkerType.start, ), SpanMarker( - attribution: LinkAttribution(url: Uri.parse('https://google.com')), + attribution: LinkAttribution.fromUri(Uri.parse('https://google.com')), offset: 17, markerType: SpanMarkerType.end, ), diff --git a/super_editor/test/super_editor/infrastructure/document_attributions_test.dart b/super_editor/test/super_editor/infrastructure/document_attributions_test.dart new file mode 100644 index 0000000000..fa5a753dfa --- /dev/null +++ b/super_editor/test/super_editor/infrastructure/document_attributions_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group('Document selection extensions', () { + group('getAllAttributions', () { + test('returns empty list when the selection range does not contain any attributions', () { + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Text without attributions'), + ), + ], + ); + + // Create a selection for the whole text. + const selection = DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 25), + ), + ); + + expect(document.getAllAttributions(selection), isEmpty); + }); + + test('returns attributions that span throughout the entirety of the text', () { + // Create a paragraph with the following attributions: + // - bold: applied throught the entire paragraph. + // - underline: applied to the word "with", + // - italics: applied to the "th". + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'Text with attributions', + AttributedSpans( + attributions: const [ + SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: underlineAttribution, offset: 5, markerType: SpanMarkerType.start), + SpanMarker(attribution: italicsAttribution, offset: 7, markerType: SpanMarkerType.start), + SpanMarker(attribution: italicsAttribution, offset: 8, markerType: SpanMarkerType.end), + SpanMarker(attribution: underlineAttribution, offset: 8, markerType: SpanMarkerType.end), + SpanMarker(attribution: boldAttribution, offset: 21, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ], + ); + + // Create a selection for the word "with". + const selection = DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 5), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 9), + ), + ); + + expect(document.getAllAttributions(selection), {boldAttribution, underlineAttribution}); + }); + }); + + group('getAttributionsByType', () { + test('returns empty set when the selection range does not contain any attributions', () { + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Text without attributions'), + ), + ], + ); + + // Create a selection for the whole text. + const selection = DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 25), + ), + ); + + expect(document.getAttributionsByType(selection), isEmpty); + }); + + test('does not return attributions that dont apply to the entire range', () { + // Create a paragraph with a font size applied to "wit". + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'Text with attributions', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: FontSizeAttribution(14), offset: 5, markerType: SpanMarkerType.start), + const SpanMarker(attribution: FontSizeAttribution(14), offset: 7, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ], + ); + + // Create a selection for the word "with"; + const selection = DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 5), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 9), + ), + ); + + expect(document.getAttributionsByType(selection), isEmpty); + }); + + test('does not return attributions that dont apply to the entire range', () { + // Create a paragraph with a font size applied to "wit". + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'Text with attributions', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: FontSizeAttribution(14), offset: 5, markerType: SpanMarkerType.start), + const SpanMarker(attribution: FontSizeAttribution(14), offset: 7, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ], + ); + + // Create a selection for the word "with"; + const selection = DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 5), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 9), + ), + ); + + expect(document.getAttributionsByType(selection), isEmpty); + }); + + test('return attributions that apply to the entire range', () { + // Create a paragraph with a font size applied to "with". + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'Text with attributions', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: FontSizeAttribution(14), offset: 5, markerType: SpanMarkerType.start), + const SpanMarker(attribution: FontSizeAttribution(14), offset: 8, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ], + ); + + // Create a selection for the word "with"; + const selection = DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 5), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 9), + ), + ); + + expect(document.getAttributionsByType(selection), {const FontSizeAttribution(14)}); + }); + }); + }); +} diff --git a/super_editor/test/src/core/document_editor_test.dart b/super_editor/test/super_editor/infrastructure/document_editor_test.dart similarity index 78% rename from super_editor/test/src/core/document_editor_test.dart rename to super_editor/test/super_editor/infrastructure/document_editor_test.dart index 595a798563..2041e204e3 100644 --- a/super_editor/test/src/core/document_editor_test.dart +++ b/super_editor/test/super_editor/infrastructure/document_editor_test.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/super_editor.dart'; +import '../../../lib/src/test/super_reader_test/reader_test_tools.dart'; + void main() { group('MutableDocument', () { group('.moveNode()', () { @@ -60,7 +62,8 @@ void main() { ); }); - test('when the node exists in the document, and the targetIndex is valid, moves it to the given target index', () { + test('when the node exists in the document, and the targetIndex is valid, moves it to the given target index', + () { final node = ParagraphNode(id: 'move-me', text: AttributedText()); final document = MutableDocument( nodes: [ @@ -72,32 +75,44 @@ void main() { document.moveNode(nodeId: 'move-me', targetIndex: 0); expect( - document.nodes, - [ - node, // Node exists at index 0 - HorizontalRuleNode(id: '0'), - HorizontalRuleNode(id: '2'), - ], + document, + documentEquivalentTo( + MutableDocument( + nodes: [ + node, // Node exists at index 0 + HorizontalRuleNode(id: '0'), + HorizontalRuleNode(id: '2'), + ], + ), + ), ); document.moveNode(nodeId: 'move-me', targetIndex: 2); expect( - document.nodes, - [ - HorizontalRuleNode(id: '0'), - HorizontalRuleNode(id: '2'), - node, // Node exists at index 2 - ], + document, + documentEquivalentTo( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '0'), + HorizontalRuleNode(id: '2'), + node, // Node exists at index 2 + ], + ), + ), ); document.moveNode(nodeId: 'move-me', targetIndex: 1); expect( - document.nodes, - [ - HorizontalRuleNode(id: '0'), - node, // Node exists at index 1 - HorizontalRuleNode(id: '2'), - ], + document, + documentEquivalentTo( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '0'), + node, // Node exists at index 1 + HorizontalRuleNode(id: '2'), + ], + ), + ), ); }); }); @@ -116,9 +131,9 @@ void main() { document.replaceNode(oldNode: oldNode, newNode: newNode); // oldNode does not exist - expect(document.nodes.contains(oldNode), false); + expect(document.contains(oldNode), false); // newNode exists at index 1 - expect(document.nodes.indexOf(newNode), 1); + expect(document.getNodeIndexById(newNode.id), 1); }); test('it is equal to another document when both documents are empty', () { @@ -135,8 +150,8 @@ void main() { TextNode( id: '1', text: AttributedText( - text: 'a', - spans: AttributedSpans(), + 'a', + AttributedSpans(), ), ), ]); @@ -144,8 +159,8 @@ void main() { TextNode( id: '1', text: AttributedText( - text: 'a', - spans: AttributedSpans(), + 'a', + AttributedSpans(), ), ), ]); diff --git a/super_editor/test/src/core/document_selection_test.dart b/super_editor/test/super_editor/infrastructure/document_selection_test.dart similarity index 97% rename from super_editor/test/src/core/document_selection_test.dart rename to super_editor/test/super_editor/infrastructure/document_selection_test.dart index 304719cb5d..9ac6d4c320 100644 --- a/super_editor/test/src/core/document_selection_test.dart +++ b/super_editor/test/super_editor/infrastructure/document_selection_test.dart @@ -193,8 +193,8 @@ void main() { final _testDoc = MutableDocument( nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "Paragraph 1")), - ParagraphNode(id: "2", text: AttributedText(text: "Paragraph 2")), - ParagraphNode(id: "3", text: AttributedText(text: "Paragraph 3")), + ParagraphNode(id: "1", text: AttributedText("Paragraph 1")), + ParagraphNode(id: "2", text: AttributedText("Paragraph 2")), + ParagraphNode(id: "3", text: AttributedText("Paragraph 3")), ], ); diff --git a/super_editor/test/src/core/document_test.dart b/super_editor/test/super_editor/infrastructure/document_test.dart similarity index 78% rename from super_editor/test/src/core/document_test.dart rename to super_editor/test/super_editor/infrastructure/document_test.dart index f51a2eded8..ca5aa18576 100644 --- a/super_editor/test/src/core/document_test.dart +++ b/super_editor/test/super_editor/infrastructure/document_test.dart @@ -10,16 +10,16 @@ void main() { TextNode( id: '1', text: AttributedText( - text: 'a', - spans: AttributedSpans(), + 'a', + AttributedSpans(), ), ), equals( TextNode( id: '1', text: AttributedText( - text: 'a', - spans: AttributedSpans(), + 'a', + AttributedSpans(), ), ), ), @@ -31,15 +31,15 @@ void main() { TextNode( id: '1', text: AttributedText( - text: 'a', - spans: AttributedSpans(), + 'a', + AttributedSpans(), ), ) == TextNode( id: '1', text: AttributedText( - text: 'b', - spans: AttributedSpans(), + 'b', + AttributedSpans(), ), ), isFalse, @@ -51,16 +51,16 @@ void main() { ParagraphNode( id: '1', text: AttributedText( - text: 'a', - spans: AttributedSpans(), + 'a', + AttributedSpans(), ), ), equals( ParagraphNode( id: '1', text: AttributedText( - text: 'a', - spans: AttributedSpans(), + 'a', + AttributedSpans(), ), ), ), @@ -72,15 +72,15 @@ void main() { ParagraphNode( id: '1', text: AttributedText( - text: 'a', - spans: AttributedSpans(), + 'a', + AttributedSpans(), ), ) == ParagraphNode( id: '1', text: AttributedText( - text: 'b', - spans: AttributedSpans(), + 'b', + AttributedSpans(), ), ), isFalse, @@ -89,30 +89,30 @@ void main() { test("equivalent ListItemNodes are equal", () { expect( - ListItemNode(id: '1', itemType: ListItemType.ordered, text: AttributedText(text: 'abcdefghij')), + ListItemNode(id: '1', itemType: ListItemType.ordered, text: AttributedText('abcdefghij')), equals( - ListItemNode(id: '1', itemType: ListItemType.ordered, text: AttributedText(text: 'abcdefghij')), + ListItemNode(id: '1', itemType: ListItemType.ordered, text: AttributedText('abcdefghij')), ), ); expect( - ListItemNode(id: '1', itemType: ListItemType.unordered, text: AttributedText(text: 'abcdefghij')), + ListItemNode(id: '1', itemType: ListItemType.unordered, text: AttributedText('abcdefghij')), equals( - ListItemNode(id: '1', itemType: ListItemType.unordered, text: AttributedText(text: 'abcdefghij')), + ListItemNode(id: '1', itemType: ListItemType.unordered, text: AttributedText('abcdefghij')), ), ); }); test("different ListItemNodes are not equal", () { expect( - ListItemNode(id: '1', itemType: ListItemType.ordered, text: AttributedText(text: 'abcdefghij')) == - ListItemNode(id: '2', itemType: ListItemType.unordered, text: AttributedText(text: 'abcdefghij')), + ListItemNode(id: '1', itemType: ListItemType.ordered, text: AttributedText('abcdefghij')) == + ListItemNode(id: '2', itemType: ListItemType.unordered, text: AttributedText('abcdefghij')), isFalse, ); expect( - ListItemNode(id: '1', itemType: ListItemType.unordered, text: AttributedText(text: 'abcdefghij')) == - ListItemNode(id: '2', itemType: ListItemType.ordered, text: AttributedText(text: 'abcdefghij')), + ListItemNode(id: '1', itemType: ListItemType.unordered, text: AttributedText('abcdefghij')) == + ListItemNode(id: '2', itemType: ListItemType.ordered, text: AttributedText('abcdefghij')), isFalse, ); }); diff --git a/super_editor/test/super_editor/infrastructure/editor_test.dart b/super_editor/test/super_editor/infrastructure/editor_test.dart new file mode 100644 index 0000000000..dd12ecc4d5 --- /dev/null +++ b/super_editor/test/super_editor/infrastructure/editor_test.dart @@ -0,0 +1,856 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../super_editor/test_documents.dart'; + +void main() { + group('DocumentEditor', () { + group('editing', () { + // TODO: test that Document gets notified of changes before Composer, because selection + // is based on document structure, not the other way around + + test('throws exception when there is no command for a given request', () { + final editor = Editor( + editables: { + Editor.documentKey: MutableDocument.empty(), + }, + requestHandlers: [], + ); + + expectLater(() { + editor.execute([InsertCharacterAtCaretRequest(character: "a")]); + }, throwsException); + }); + + test('executes a single command', () { + final editorPieces = _createStandardEditor( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + List? changeLog; + editorPieces.editor.addListener(FunctionalEditListener((changeList) { + changeLog = changeList; + })); + + editorPieces.editor.execute([InsertCharacterAtCaretRequest(character: "a")]); + + expect(changeLog, isNotNull); + expect(changeLog!.length, 2); + expect(changeLog!.first, isA()); + expect((changeLog!.first as DocumentEdit).change, isA()); + expect(changeLog!.last, isA()); + }); + + test('executes a series of commands', () { + final editorPieces = _createStandardEditor( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + int changeLogCount = 0; + int changeEventCount = 0; + editorPieces.editor.addListener(FunctionalEditListener((changeList) { + changeLogCount += 1; + changeEventCount += changeList.length; + })); + + editorPieces.editor + ..execute([InsertCharacterAtCaretRequest(character: "H")]) + ..execute([InsertCharacterAtCaretRequest(character: "e")]) + ..execute([InsertCharacterAtCaretRequest(character: "l")]) + ..execute([InsertCharacterAtCaretRequest(character: "l")]) + ..execute([InsertCharacterAtCaretRequest(character: "o")]); + + expect(changeLogCount, 5); + expect(changeEventCount, 10); // 2 events per character insertion + expect((editorPieces.document.getNodeAt(0) as ParagraphNode).text.toPlainText(), "Hello"); + }); + + test('executes multiple expanding commands', () { + // This test ensures that if one command expands into multiple commands, + // and those commands expand to additional commands, the overall command + // order is what we expect. + List? changeList; + final document = MutableDocument.empty(); + + final composer = MutableDocumentComposer( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + final editor = Editor( + editables: { + Editor.documentKey: document, + Editor.composerKey: composer, + }, + requestHandlers: [ + (editor, request) => request is _ExpandingCommandRequest // + ? _ExpandingCommand(request) + : null, + ], + listeners: [ + FunctionalEditListener((newChangeList) { + changeList = newChangeList; + }), + ], + ); + + editor.execute([ + const _ExpandingCommandRequest( + generationId: 0, + batchId: 0, + newCommandCount: 3, + levelsOfGeneration: 2, + ), + ]); + + // Ensure that the commands printed the number and spacing that we expected, + // given the expansion order. + // + // Each value is "(a.b)" where "a" is the generation, and "b" is the batch ID + // within the generation. The output should look like a depth first tree + // traversal. + final paragraph = document.getNodeAt(0) as ParagraphNode; + expect( + paragraph.text.toPlainText(), + '''(0.0) + (1.0) + (2.0) + (2.1) + (2.2) + (1.1) + (2.0) + (2.1) + (2.2) + (1.2) + (2.0) + (2.1) + (2.2)''', + ); + + expect(changeList, isNotNull); + expect(changeList!.length, 13 * 2); // 13 commands * 2 events per command + }); + + test('editables (and their listeners) can request changes after transaction ends', () { + final editor = Editor( + editables: { + Editor.documentKey: MutableDocument.empty("1"), + Editor.composerKey: MutableDocumentComposer( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + }, + requestHandlers: List.from(defaultRequestHandlers), + ); + + // Add our Editable, which makes a change after it detects a paragraph + // that equals "H". + editor.context.put("my-editable", _ReactionaryEditable(editor)); + + // Make a change, which will cause our Editable to make more changes. + editor.execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + textToInsert: "H", + attributions: const {}, + ), + ]); + + // Ensure our listener ran, and successfully edited the document. + expect((editor.document.first as TextNode).text.toPlainText(), "He"); + }); + + test('runs reactions after a command', () { + int reactionCount = 0; + + final document = MutableDocument.empty("1"); + + final composer = MutableDocumentComposer( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + final editor = Editor( + editables: { + Editor.documentKey: document, + Editor.composerKey: composer, + }, + requestHandlers: List.from(defaultRequestHandlers), + reactionPipeline: [ + FunctionalEditReaction( + react: (editorContext, requestDispatcher, changeList) { + reactionCount += 1; + }, + ), + ], + ); + + editor.execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + textToInsert: "H", + attributions: const {}, + ), + ]); + + // Ensure that our reaction ran after the requested command. + expect(reactionCount, 1); + }); + + test('interrupts back-to-back commands to run a reaction', () { + final document = MutableDocument.empty("1"); + + final composer = MutableDocumentComposer( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + + final editor = Editor( + editables: { + Editor.documentKey: document, + Editor.composerKey: composer, + }, + requestHandlers: List.from(defaultRequestHandlers), + reactionPipeline: [ + FunctionalEditReaction(react: (editorContext, requestDispatcher, changeList) { + TextInsertionEvent? insertEEvent; + for (final edit in changeList) { + if (edit is! DocumentEdit) { + continue; + } + final change = edit.change; + if (change is! TextInsertionEvent) { + continue; + } + + insertEEvent = change.text.toPlainText().endsWith("e") ? change : null; + } + + if (insertEEvent == null) { + return; + } + + // Insert "ll" after "e" to get "Hello" when all the commands are done. + requestDispatcher.execute([ + InsertTextRequest( + documentPosition: DocumentPosition( + nodeId: insertEEvent.nodeId, + nodePosition: TextNodePosition(offset: insertEEvent.offset + 1), // +1 for "e" + ), + textToInsert: "ll", + attributions: {}, + ), + ]); + }), + ], + ); + + editor + ..execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + textToInsert: "H", + attributions: const {}, + ), + ]) + ..execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 1), + ), + textToInsert: "e", + attributions: const {}, + ), + ]) + ..execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + textToInsert: "o", + attributions: const {}, + ), + ]); + + // Ensure that our reaction ran in the middle of the requests. + expect((document.first as TextNode).text.toPlainText(), "Hello"); + }); + + test('reactions receive a change list with events from earlier reactions', () { + final document = MutableDocument.empty("1"); + + final composer = MutableDocumentComposer( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + + final editor = Editor( + editables: { + Editor.documentKey: document, + Editor.composerKey: composer, + }, + requestHandlers: List.from(defaultRequestHandlers), + reactionPipeline: [ + // Reaction 1 causes a change + FunctionalEditReaction(react: (editorContext, requestDispatcher, changeList) { + TextInsertionEvent? insertHEvent; + for (final edit in changeList) { + if (edit is! DocumentEdit) { + continue; + } + final change = edit.change; + if (change is! TextInsertionEvent) { + continue; + } + + insertHEvent = change.text.toPlainText() == "H" ? change : null; + } + + if (insertHEvent == null) { + return; + } + + // Insert "e" after "H". + requestDispatcher.execute([ + InsertTextRequest( + documentPosition: DocumentPosition( + nodeId: insertHEvent.nodeId, + nodePosition: TextNodePosition(offset: insertHEvent.offset), + ), + textToInsert: "e", + attributions: {}, + ), + ]); + }), + // Reaction 2 verifies that it sees the change event from reaction 1. + FunctionalEditReaction(react: (editorContext, requestDispatcher, changeList) { + TextInsertionEvent? insertEEvent; + for (final edit in changeList) { + if (edit is! DocumentEdit) { + continue; + } + final change = edit.change; + if (change is! TextInsertionEvent) { + continue; + } + + insertEEvent = change.text.toPlainText() == "e" ? change : null; + } + + expect(insertEEvent, isNotNull, reason: "Reaction 2 didn't receive the change from reaction 1"); + }), + ], + ); + + editor.execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + textToInsert: "H", + attributions: const {}, + ), + ]); + + // If execution makes it here then the test is successful. + }); + + test('reactions do not run in response to reactions', () { + final document = MutableDocument.empty("1"); + + final composer = MutableDocumentComposer( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + + int reactionRunCount = 0; + + final editor = Editor( + editables: { + Editor.documentKey: document, + Editor.composerKey: composer, + }, + requestHandlers: List.from(defaultRequestHandlers), + reactionPipeline: [ + FunctionalEditReaction(react: (editorContext, requestDispatcher, changeList) { + reactionRunCount += 1; + + // We expect this reaction to run after we execute a command, but we don't + // expect this reaction to react to its own command. + expect(reactionRunCount, lessThan(2)); + + // Insert "e" after "H". + requestDispatcher.execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 1), + ), + textToInsert: "e", + attributions: {}, + ), + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 2), + ), + textToInsert: "l", + attributions: {}, + ), + ]); + }), + ], + ); + + editor.execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + textToInsert: "H", + attributions: const {}, + ), + ]); + + // Ensure that our reaction ran once, but only once. + expect(reactionRunCount, 1); + }); + + test('listener can request changes after transaction ends', () { + late final Editor editor; + final editorChangeListener = FunctionalEditListener((changes) { + editor.execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 1), + ), + textToInsert: "e", + attributions: const {}, + ), + ]); + }); + + editor = Editor( + editables: { + Editor.documentKey: MutableDocument.empty("1"), + Editor.composerKey: MutableDocumentComposer( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + }, + requestHandlers: List.from(defaultRequestHandlers), + listeners: [editorChangeListener], + ); + + // Make a change, which will cause our listener to run. + editor.execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + textToInsert: "H", + attributions: const {}, + ), + ]); + + // Ensure our listener ran, and successfully edited the document. + expect((editor.document.first as TextNode).text.toPlainText(), "He"); + }); + + test('inserts character at caret', () { + final editorPieces = _createStandardEditor( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + + editorPieces.editor + ..execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + textToInsert: 'H', + attributions: const {}, + ), + ]) + ..execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 1), + ), + ), + SelectionChangeType.placeCaret, + "test", + ), + ]); + + // Ensure the character was inserted, and the caret moved forward. + expect((editorPieces.document.getNodeAt(0) as TextNode).text.toPlainText(), "H"); + expect(editorPieces.composer.selection, isNotNull); + expect( + editorPieces.composer.selection, + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 1), + ), + ), + ); + }); + + test('inserts new paragraph node at caret', () { + final editorPieces = _createStandardEditor( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + int changeLogCount = 0; + int changeEventCount = 0; + final document = editorPieces.document; + editorPieces.editor.addListener(FunctionalEditListener((changeList) { + changeLogCount += 1; + changeEventCount += changeList.length; + })); + + editorPieces.editor.execute([ + SplitParagraphRequest( + nodeId: "1", + splitPosition: const TextNodePosition(offset: 0), + newNodeId: "2", + replicateExistingMetadata: true, + ) + ]); + + // Verify content changes. + expect(document.nodeCount, 2); + expect(document.getNodeAt(0)!.id, "1"); + expect(document.getNodeAt(1)!.id, "2"); + + // Verify reported changes. + expect(changeLogCount, 1); + // Expected events: + // - submit paragraph intention: start + // - node change event: node 1 + // - node inserted event + // - selection change event + // - composing region change event + // - submit paragraph intention: end + expect(changeEventCount, 6); + }); + + test('moves a document node to a higher index', () { + final editorPieces = _createStandardEditor( + initialDocument: longTextDoc(), + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + + int changeLogCount = 0; + int changeEventCount = 0; + editorPieces.editor.addListener(FunctionalEditListener((changeList) { + changeLogCount += 1; + changeEventCount += changeList.length; + })); + + late DocumentChangeLog documentChangeLog; + editorPieces.document.addListener((changeLog) { + documentChangeLog = changeLog; + }); + + editorPieces.editor.execute([const MoveNodeRequest(nodeId: "1", newIndex: 2)]); + + // Verify final node indices. + expect(editorPieces.document.getNodeAt(0)!.id, "2"); + expect(editorPieces.document.getNodeAt(1)!.id, "3"); + expect(editorPieces.document.getNodeAt(2)!.id, "1"); + expect(editorPieces.document.getNodeAt(3)!.id, "4"); + + // Verify reported editor changes. + expect(changeLogCount, 1); + expect(changeEventCount, 3); // 3 nodes were moved + + // Verify reported document changes. + expect( + documentChangeLog.changes, + [ + const NodeMovedEvent(nodeId: "1", from: 0, to: 2), + const NodeMovedEvent(nodeId: "2", from: 1, to: 0), + const NodeMovedEvent(nodeId: "3", from: 2, to: 1), + ], + ); + }); + + test('moves a document node to a lower index', () { + final editorPieces = _createStandardEditor( + initialDocument: longTextDoc(), + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + + int changeLogCount = 0; + int changeEventCount = 0; + editorPieces.editor.addListener(FunctionalEditListener((changeList) { + changeLogCount += 1; + changeEventCount += changeList.length; + })); + + late DocumentChangeLog documentChangeLog; + editorPieces.document.addListener((changeLog) { + documentChangeLog = changeLog; + }); + + editorPieces.editor.execute([const MoveNodeRequest(nodeId: "3", newIndex: 0)]); + + // Verify final node indices. + expect(editorPieces.document.getNodeAt(0)!.id, "3"); + expect(editorPieces.document.getNodeAt(1)!.id, "1"); + expect(editorPieces.document.getNodeAt(2)!.id, "2"); + + // Verify reported editor changes. + expect(changeLogCount, 1); + expect(changeEventCount, 3); // 3 nodes were moved + + // Verify reported document changes. + expect( + documentChangeLog.changes, + [ + const NodeMovedEvent(nodeId: "1", from: 0, to: 1), + const NodeMovedEvent(nodeId: "2", from: 1, to: 2), + const NodeMovedEvent(nodeId: "3", from: 2, to: 0), + ], + ); + }); + + test('reports the node that was removed', () { + DocumentNode? removedNode; + final editorPieces = _createStandardEditor( + initialDocument: longTextDoc(), + additionalReactions: [ + FunctionalEditReaction(react: (editorContext, requestDispatcher, changeList) { + expect(changeList.length, 1); + + final event = changeList.first as DocumentEdit; + final change = event.change as NodeRemovedEvent; + removedNode = change.removedNode; + }), + ], + ); + + final nodeToRemove = editorPieces.document.getNodeById("2")!; + + editorPieces.editor.execute([ + DeleteNodeRequest(nodeId: nodeToRemove.id), + ]); + + expect(removedNode, nodeToRemove); + }); + }); + }); +} + +// TODO: check how/why this is different from default_document_editor.dart method called createDefaultDocumentEditor() +StandardEditorPieces _createStandardEditor({ + MutableDocument? initialDocument, + DocumentSelection? initialSelection, + List additionalReactions = const [], +}) { + final document = initialDocument ?? singleParagraphEmptyDoc(); + + final composer = MutableDocumentComposer(initialSelection: initialSelection); + final editor = Editor( + editables: { + Editor.documentKey: document, + Editor.composerKey: composer, + }, + requestHandlers: List.from(defaultRequestHandlers), + reactionPipeline: [ + ...additionalReactions, + const LinkifyReaction(), + const UnorderedListItemConversionReaction(), + const OrderedListItemConversionReaction(), + const BlockquoteConversionReaction(), + const HorizontalRuleConversionReaction(), + const ImageUrlConversionReaction(), + ], + ); + + return StandardEditorPieces(document, composer, editor); +} + +/// Request that runs a command, which spawns more commands, based on the +/// given [newCommandCount] and [levelsOfGeneration]. +/// +/// This request, and its command, are used to test the command spawning and +/// ordering behavior of [Editor] without finding real commands that +/// exemplify the necessary spawning behavior. +class _ExpandingCommandRequest implements EditRequest { + const _ExpandingCommandRequest({ + required this.generationId, + required this.batchId, + required this.newCommandCount, + required this.levelsOfGeneration, + }); + + /// The generation of this request, e.g., `0` for the first generation, + /// `1` for the second, etc. + final int generationId; + + /// The ID of this request within its generation batch. + /// + /// If three requests are created within a newly spawned generation, then the + /// first request to be generated will be given ID `0`, the second `1`, etc. + final int batchId; + + /// The number of new commands that this request will generate (breadth of a tree). + final int newCommandCount; + + /// The number of generations of commands that this request will generate (depth + /// of a tree). + final int levelsOfGeneration; +} + +class _ExpandingCommand extends EditCommand { + const _ExpandingCommand(this.request); + + final _ExpandingCommandRequest request; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.find(Editor.documentKey); + final paragraph = document.getNodeAt(0) as ParagraphNode; + + executor.executeCommand( + InsertTextCommand( + documentPosition: DocumentPosition( + nodeId: paragraph.id, + nodePosition: TextNodePosition(offset: paragraph.text.length), + ), + textToInsert: + "${request.generationId > 0 ? "\n" : ""}${List.filled(request.generationId, " ").join()}(${request.generationId}.${request.batchId})", + attributions: {}, + ), + ); + + if (request.levelsOfGeneration > 0) { + for (int i = 0; i < request.newCommandCount; i += 1) { + executor.executeCommand( + _ExpandingCommand( + _ExpandingCommandRequest( + generationId: request.generationId + 1, // +1 for next generation + batchId: i, // i'th member of this generation + newCommandCount: request.newCommandCount, + levelsOfGeneration: request.levelsOfGeneration - 1, // -1 generations to go + ), + ), + ); + } + } + } +} + +/// An [Editable], which upon transaction end, looks for a first paragraph +/// with a value of "H", and then inserts an "e" after it. +/// +/// Used to verify that [Editable]s can submit editor requests at the end +/// of a transaction - or that an [Editable] can emit its own change events, +/// which cause other areas of the app to submit new [Editor] requests. +class _ReactionaryEditable extends Editable { + _ReactionaryEditable(this._editor); + + final Editor _editor; + + @override + void onTransactionEnd(List edits) { + final paragraph = _editor.document.first; + if (paragraph is! ParagraphNode) { + return; + } + + if (paragraph.text.toPlainText() != "H") { + return; + } + + // Make a reactionary change. + _editor.execute([ + InsertTextRequest( + documentPosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 1), + ), + textToInsert: "e", + attributions: const {}, + ), + ]); + } +} diff --git a/super_editor/test/super_editor/mutable_document_test.dart b/super_editor/test/super_editor/infrastructure/mutable_document_test.dart similarity index 73% rename from super_editor/test/super_editor/mutable_document_test.dart rename to super_editor/test/super_editor/infrastructure/mutable_document_test.dart index d9a3228cd2..92ebb40b4a 100644 --- a/super_editor/test/super_editor/mutable_document_test.dart +++ b/super_editor/test/super_editor/infrastructure/mutable_document_test.dart @@ -1,8 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/super_editor.dart'; -import 'test_documents.dart'; - void main() { group("MutableDocument", () { test("calculates a range from an upstream selection within a single node", () { @@ -10,7 +8,7 @@ void main() { nodes: [ ParagraphNode( id: "1", - text: AttributedText(text: "This is a paragraph of text."), + text: AttributedText("This is a paragraph of text."), ), ], ); @@ -43,7 +41,7 @@ void main() { nodes: [ ParagraphNode( id: "1", - text: AttributedText(text: "This is a paragraph of text."), + text: AttributedText("This is a paragraph of text."), ), ], ); @@ -74,9 +72,9 @@ void main() { group("getNodeIndexById returns the correct index", () { test("when creating a document", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; // Ensure the indices are correct when creating the document. expect(document.getNodeIndexById(firstNode.id), 0); @@ -86,13 +84,13 @@ void main() { test("when inserting a node at the beginning by index", () { final document = _createTwoParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; // Insert a new node at the beginning. final thirdNode = ParagraphNode( id: "3", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ); document.insertNodeAt(0, thirdNode); @@ -104,13 +102,13 @@ void main() { test("when inserting a node at the middle by index", () { final document = _createTwoParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; // Insert a new node between firstNode and secondNode. final thirdNode = ParagraphNode( id: "3", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ); document.insertNodeAt(1, thirdNode); @@ -122,13 +120,13 @@ void main() { test("when inserting a node at the end by index", () { final document = _createTwoParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; // Insert a new node at the end. final thirdNode = ParagraphNode( id: "3", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ); document.insertNodeAt(2, thirdNode); @@ -140,16 +138,16 @@ void main() { test("when inserting a node before the first node", () { final document = _createTwoParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; // Insert a new node at the beginning. final thirdNode = ParagraphNode( id: "3", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ); document.insertNodeBefore( - existingNode: firstNode, + existingNodeId: firstNode.id, newNode: thirdNode, ); @@ -161,16 +159,16 @@ void main() { test("when inserting a node before the last node", () { final document = _createTwoParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; // Insert a new node between the two nodes. final thirdNode = ParagraphNode( id: "3", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ); document.insertNodeBefore( - existingNode: secondNode, + existingNodeId: secondNode.id, newNode: thirdNode, ); @@ -182,16 +180,16 @@ void main() { test("when inserting a node after the first node", () { final document = _createTwoParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; // Insert a new node between the two nodes. final thirdNode = ParagraphNode( id: "3", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ); document.insertNodeAfter( - existingNode: firstNode, + existingNodeId: firstNode.id, newNode: thirdNode, ); @@ -203,16 +201,16 @@ void main() { test("when inserting a node after the last node", () { final document = _createTwoParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; // Insert a new node at the end. final thirdNode = ParagraphNode( id: "3", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ); document.insertNodeAfter( - existingNode: secondNode, + existingNodeId: secondNode.id, newNode: thirdNode, ); @@ -224,9 +222,9 @@ void main() { test("when moving a node from the beginning to the middle", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; document.moveNode( nodeId: firstNode.id, @@ -241,9 +239,9 @@ void main() { test("when moving a node from the middle to the end", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; document.moveNode( nodeId: secondNode.id, @@ -258,9 +256,9 @@ void main() { test("when moving a node from the end to the middle", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; document.moveNode( nodeId: thirdNode.id, @@ -275,9 +273,9 @@ void main() { test("when moving a node from the middle to the beginning", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; document.moveNode( nodeId: secondNode.id, @@ -292,11 +290,11 @@ void main() { test("when deleting a node at the beginning", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; - document.deleteNode(firstNode); + document.deleteNode(firstNode.id); // Ensure the indices are correct. expect(document.getNodeIndexById(firstNode.id), -1); @@ -306,11 +304,11 @@ void main() { test("when deleting a node at the middle", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; - document.deleteNode(secondNode); + document.deleteNode(secondNode.id); // Ensure the indices are correct. expect(document.getNodeIndexById(secondNode.id), -1); @@ -320,11 +318,11 @@ void main() { test("when deleting a node at the end", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; - document.deleteNode(thirdNode); + document.deleteNode(thirdNode.id); // Ensure the indices are correct. expect(document.getNodeIndexById(thirdNode.id), -1); @@ -334,18 +332,18 @@ void main() { test("when replacing a node at the beginning", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; final fourthNode = ParagraphNode( id: "4", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ); - document.replaceNode( - oldNode: firstNode, - newNode: fourthNode, + document.replaceNodeById( + firstNode.id, + fourthNode, ); // Ensure the indices are correct. @@ -357,18 +355,18 @@ void main() { test("when replacing a node at the middle", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; final fourthNode = ParagraphNode( id: "4", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ); - document.replaceNode( - oldNode: secondNode, - newNode: fourthNode, + document.replaceNodeById( + secondNode.id, + fourthNode, ); // Ensure the indices are correct. @@ -380,18 +378,18 @@ void main() { test("when replacing a node at the end", () { final document = _createThreeParagraphDoc(); - final firstNode = document.nodes[0]; - final secondNode = document.nodes[1]; - final thirdNode = document.nodes[2]; + final firstNode = document.getNodeAt(0)!; + final secondNode = document.getNodeAt(1)!; + final thirdNode = document.getNodeAt(2)!; final fourthNode = ParagraphNode( id: "4", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ); - document.replaceNode( - oldNode: thirdNode, - newNode: fourthNode, + document.replaceNodeById( + thirdNode.id, + fourthNode, ); // Ensure the indices are correct. @@ -409,11 +407,11 @@ MutableDocument _createTwoParagraphDoc() { nodes: [ ParagraphNode( id: "1", - text: AttributedText(text: "This is the first paragraph."), + text: AttributedText("This is the first paragraph."), ), ParagraphNode( id: "2", - text: AttributedText(text: "This is the second paragraph."), + text: AttributedText("This is the second paragraph."), ), ], ); @@ -424,15 +422,15 @@ MutableDocument _createThreeParagraphDoc() { nodes: [ ParagraphNode( id: "1", - text: AttributedText(text: "This is the first paragraph."), + text: AttributedText("This is the first paragraph."), ), ParagraphNode( id: "2", - text: AttributedText(text: "This is the second paragraph."), + text: AttributedText("This is the second paragraph."), ), ParagraphNode( id: "3", - text: AttributedText(text: "This is the third paragraph."), + text: AttributedText("This is the third paragraph."), ), ], ); diff --git a/super_editor/test/super_editor/mobile/mobile_long_press_selection_text_layout.png b/super_editor/test/super_editor/mobile/mobile_long_press_selection_text_layout.png new file mode 100644 index 0000000000..77ba70d8cf Binary files /dev/null and b/super_editor/test/super_editor/mobile/mobile_long_press_selection_text_layout.png differ diff --git a/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart b/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart new file mode 100644 index 0000000000..e23ac8e6bb --- /dev/null +++ b/super_editor/test/super_editor/mobile/super_editor_android_overlay_controls_test.dart @@ -0,0 +1,857 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_keyboard/test/keyboard_simulator.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +import '../../test_runners.dart'; +import '../../test_tools.dart'; + +void main() { + group("SuperEditor > Android > overlay controls >", () { + testWidgetsOnAndroid("hides all controls when placing the caret", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret. + await tester.tapInParagraph("1", 200); + + // Ensure all controls are hidden. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnAndroid("shows magnifier when dragging the caret", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret. + await tester.tapInParagraph("1", 200); + + // Press and drag the caret somewhere else in the paragraph. + final gesture = await tester.tapDownInParagraph("1", 200); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(24, 0)); + await tester.pump(); + } + + // Ensure magnifier is visible and toolbar is hidden. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(kTapMinTime); + }); + + testWidgetsOnAndroid("shows magnifier when dragging the collapsed handle", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret. + await tester.tapInParagraph("1", 200); + + // Press and drag the caret somewhere else in the paragraph. + final gesture = await tester.pressDownOnCollapsedMobileHandle(); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(24, 0)); + await tester.pump(); + } + + // Ensure magnifier is visible and toolbar is hidden. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(kTapMinTime); + }); + + testWidgetsOnAndroid("toggles toolbar upon tap on caret (with software keyboard)", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret at the beginning of the document. + await tester.tapInParagraph("1", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap the caret to show the toolbar. + await tester.tapInParagraph("1", 0); + await tester.pump(); + + // Ensure the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + + // Tap the caret to hide the toolbar. + await tester.tapOnCollapsedMobileHandle(); + await tester.pump(); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnAndroid("toggles toolbar upon tap on collapsed handle (with software keyboard)", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph("1", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap the drag handle to show the toolbar. + await tester.tapOnCollapsedMobileHandle(); + await tester.pump(); + + // Ensure the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + + // Tap the drag handle to hide the toolbar. + await tester.tapOnCollapsedMobileHandle(); + await tester.pump(); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnAndroid("hides toolbar when the IME connection closes (with software keyboard)", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret at the beginning of the document. + await tester.tapInParagraph("1", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap the caret to show the toolbar. + await tester.tapInParagraph("1", 0); + await tester.pump(); + + // Ensure the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + + // Take the IME connection away from Super Editor. The best we can do to + // simulate this is to move the focus somewhere else. In practice, this is + // how it actually occurs. It's not obvious under which circumstances the OS + // forcibly reclaims the IME, or how we should simulate that in tests. + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + await tester.pump(); + + // Ensure that the toolbar is hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnAndroid("toggles toolbar upon tap on caret (with hardware keyboard)", (tester) async { + await _pumpSingleParagraphApp(tester, simulateSoftwareKeyboardAppearance: false); + + // Place the caret at the beginning of the document. + await tester.tapInParagraph("1", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap the caret to show the toolbar. + await tester.tapInParagraph("1", 0); + await tester.pump(); + + // Ensure the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + + // Tap the caret to hide the toolbar. + await tester.tapOnCollapsedMobileHandle(); + await tester.pump(); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnAndroid("toggles toolbar upon tap on collapsed handle (with hardware keyboard)", (tester) async { + await _pumpSingleParagraphApp(tester, simulateSoftwareKeyboardAppearance: false); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph("1", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap the drag handle to show the toolbar. + await tester.tapOnCollapsedMobileHandle(); + await tester.pump(); + + // Ensure the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + + // Tap the drag handle to hide the toolbar. + await tester.tapOnCollapsedMobileHandle(); + await tester.pump(); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnAndroid("hides toolbar when the IME connection closes (with hardware keyboard)", (tester) async { + await _pumpSingleParagraphApp(tester, simulateSoftwareKeyboardAppearance: false); + + // Place the caret at the beginning of the document. + await tester.tapInParagraph("1", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap the caret to show the toolbar. + await tester.tapInParagraph("1", 0); + await tester.pump(); + + // Ensure the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + + // Take the IME connection away from Super Editor. The best we can do to + // simulate this is to move the focus somewhere else. In practice, this is + // how it actually occurs. It's not obvious under which circumstances the OS + // forcibly reclaims the IME, or how we should simulate that in tests. + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + await tester.pump(); + + // Ensure that the toolbar is hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnAndroid("hides toolbar when the user taps to move the caret", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph("1", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap the drag handle to show the toolbar. + await tester.tapOnCollapsedMobileHandle(); + await tester.pump(); + + // Ensure the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + + // Place the caret somewhere else in the paragraph. + // + // WARNING: We choose a position way beyond the start of the paragraph so that + // it's down multiple lines, below the toolbar. Otherwise, this type might accidentally + // activate a toolbar button instead of moving the selection. + await tester.placeCaretInParagraph("1", 200); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnAndroid("does not show toolbar upon first tap", (tester) async { + await tester // + .createDocument() + .withTwoEmptyParagraphs() + .pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph("1", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Wait for the collapsed handle to disappear so that it doesn't cover the + // line below. + await tester.pump(const Duration(seconds: 5)); + + // Place the caret at the beginning of the second paragraph, at the same offset. + await tester.placeCaretInParagraph("2", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnAndroid("shows toolbar when selection is expanded", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Select a word. + await tester.doubleTapInParagraph("1", 200); + + // Ensure toolbar is visible and magnifier is hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnAndroid("hides toolbar when tapping on expanded selection", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Select a word. + await tester.doubleTapInParagraph("1", 200); + + // Ensure toolbar is visible and magnifier is hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + + // Tap on the selected text. + await tester.tapInParagraph("1", 200); + + // Ensure that all controls are now hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnAndroid("shows toolbar when long pressing on an empty paragraph and hides it after typing", + (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + // The decision about showing the toolbar depends on the keyboard visibility. + // Simulate the keyboard being visible immediately after the IME is connected. + TestSuperKeyboard.install(id: '1', vsync: tester, keyboardAnimationTime: Duration.zero); + addTearDown(() => TestSuperKeyboard.uninstall('1')); + + // Ensure the toolbar is not visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Long press to show the toolbar. + final gesture = await tester.longPressDownInParagraph('1', 0); + + // Ensure the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + + // Release the finger. + await gesture.up(); + await tester.pump(); + + // Ensure the toolbar is still visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + + // Type a character to hide the toolbar. + await tester.typeImeText('a'); + + // Ensure the toolbar is not visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnAndroid("shows magnifier when dragging expanded handle", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Select a word. + await tester.doubleTapInParagraph("1", 250); + + // Press and drag upstream handle + final gesture = await tester.pressDownOnUpstreamMobileHandle(); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(-24, 0)); + await tester.pump(); + } + + // Ensure that the magnifier is visible. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(kTapMinTime); + }); + + testWidgetsOnAndroid("shows expanded handles when dragging to a collapsed selection", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Select the word "Lorem". + await tester.doubleTapInParagraph('1', 1); + + // Press the upstream drag handle and drag it downstream until "Lorem|" to collapse the selection. + final gesture = await tester.pressDownOnUpstreamMobileHandle(); + await gesture.moveBy(SuperEditorInspector.findDeltaBetweenCharactersInTextNode('1', 0, 5)); + await tester.pump(); + + // Ensure that the selection collapsed. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + ), + ), + ); + + // Find the rectangle for the selected character. + final documentLayout = SuperEditorInspector.findDocumentLayout(); + final selectedPositionRect = documentLayout.getRectForPosition( + const DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + )!; + + // Ensure that the drag handles are visible and in the correct location. + expect(SuperEditorInspector.findAllMobileDragHandles(), findsExactly(2)); + expect( + tester.getTopLeft(SuperEditorInspector.findMobileDownstreamDragHandle()), + offsetMoreOrLessEquals(documentLayout.getGlobalOffsetFromDocumentOffset(selectedPositionRect.bottomRight) - + Offset(AndroidSelectionHandle.defaultTouchRegionExpansion.left, 0)), + ); + expect( + tester.getTopRight(SuperEditorInspector.findMobileUpstreamDragHandle()), + offsetMoreOrLessEquals(documentLayout.getGlobalOffsetFromDocumentOffset(selectedPositionRect.bottomRight) + + Offset(AndroidSelectionHandle.defaultTouchRegionExpansion.right, 0)), + ); + + // Release the drag handle. + await gesture.up(); + await tester.pumpAndSettle(); + + // Ensure the expanded handles were hidden and the collapsed handle + // and the caret were displayed. + expect(SuperEditorInspector.findAllMobileDragHandles(), findsOneWidget); + expect(SuperEditorInspector.findMobileCaretDragHandle(), findsOneWidget); + expect(SuperEditorInspector.isCaretVisible(), isTrue); + }); + + testWidgetsOnAndroid("shows expanded handles when expanding the selection", (tester) async { + final context = await _pumpSingleParagraphApp(tester); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("1", 0); + await tester.pump(); + + // Ensure the collapsed handle is visible and the expanded handles aren't visible. + expect(SuperEditorInspector.findMobileCaretDragHandle(), findsOneWidget); + expect(SuperEditorInspector.findMobileExpandedDragHandles(), findsNothing); + + // Select all of the text. + context.findEditContext().commonOps.selectAll(); + await tester.pump(); + + // Ensure the handles are visible and the collapsed handle isn't visible. + expect(SuperEditorInspector.findMobileExpandedDragHandles(), findsNWidgets(2)); + expect(SuperEditorInspector.findMobileCaretDragHandle(), findsNothing); + }); + + testWidgetsOnAndroid("hides expanded handles and toolbar when deleting an expanded selection", (tester) async { + // Configure BlinkController to animate, otherwise it won't blink. We want to make sure + // the caret blinks after deleting the content. + BlinkController.indeterminateAnimationsEnabled = true; + addTearDown(() => BlinkController.indeterminateAnimationsEnabled = false); + + await _pumpSingleParagraphApp(tester); + + // Double tap to select "Lorem". + await tester.doubleTapInParagraph("1", 1); + await tester.pump(); + + // Ensure the toolbar and the drag handles are visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + expect(SuperEditorInspector.findMobileExpandedDragHandles(), findsNWidgets(2)); + + // Press backspace to delete the word "Lorem" while the expanded handles are visible. + await tester.ime.backspace(getter: imeClientGetter); + + // Ensure the toolbar and the drag handles were hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + expect(SuperEditorInspector.findMobileExpandedDragHandles(), findsNothing); + + // Ensure caret is blinking. + + expect(SuperEditorInspector.isCaretVisible(), true); + + // Duration to switch between visible and invisible. + final flashPeriod = SuperEditorInspector.caretFlashPeriod(); + + // Trigger a frame with an ellapsed time equal to the flashPeriod, + // so the caret should change from visible to invisible. + await tester.pump(flashPeriod); + + // Ensure caret is invisible after the flash period. + expect(SuperEditorInspector.isCaretVisible(), false); + + // Trigger another frame to make caret visible again. + await tester.pump(flashPeriod); + + // Ensure caret is visible. + expect(SuperEditorInspector.isCaretVisible(), true); + }); + + testWidgetsOnAndroid("allows customizing the collapsed handle", (tester) async { + // Use a key different from the provided by the builder to make sure our handle + // is used instead of the default one. + final collapsedFinderKey = GlobalKey(); + + await tester // + .createDocument() + .withSingleParagraph() + .withAndroidCollapsedHandleBuilder( + ( + BuildContext context, { + required Key handleKey, + required LeaderLink focalPoint, + required DocumentHandleGestureDelegate gestureDelegate, + required bool shouldShow, + }) { + return SizedBox( + key: collapsedFinderKey, + width: 20, + height: 20, + child: Container( + key: handleKey, + ), + ); + }, + ).pump(); + + // Place the caret at the beginning of the document to show the collapsed handle. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the custom handle is used. + expect(find.byKey(collapsedFinderKey), findsOneWidget); + }); + + testWidgetsOnAndroid("allows customizing the expanded handles", (tester) async { + // Use keys different from the provided by the builder to make sure our handles + // are used instead of the default ones. + final upstreamFinderKey = GlobalKey(); + final downstreamFinderKey = GlobalKey(); + + await tester // + .createDocument() + .withSingleParagraph() + .withAndroidExpandedHandlesBuilder( + ( + BuildContext context, { + required Key upstreamHandleKey, + required LeaderLink upstreamFocalPoint, + required DocumentHandleGestureDelegate upstreamGestureDelegate, + required Key downstreamHandleKey, + required LeaderLink downstreamFocalPoint, + required DocumentHandleGestureDelegate downstreamGestureDelegate, + required bool shouldShow, + }) { + return Stack( + children: [ + SizedBox( + key: upstreamFinderKey, + width: 20, + height: 20, + child: Container( + key: upstreamHandleKey, + ), + ), + SizedBox( + key: downstreamFinderKey, + width: 20, + height: 20, + child: Container( + key: downstreamHandleKey, + ), + ), + ], + ); + }, + ).pump(); + + // Double tap to select the first word and show the expanded handles. + await tester.doubleTapInParagraph('1', 0); + + // Ensure the custom handles are used. + expect(find.byKey(upstreamFinderKey), findsOneWidget); + expect(find.byKey(downstreamFinderKey), findsOneWidget); + }); + + group('shows magnifier above the caret when dragging the collapsed handle', () { + testWidgetsOnAndroid('with an ancestor scrollable', (tester) async { + final scrollController = ScrollController(); + + // Pump the editor inside a CustomScrollView with a number of widgets + // above the editor, so we can check if the magnifier is positioned at the correct + // position, even if the editor isn't aligned with the top-left of the screen. + await tester + .createDocument() + .withSingleParagraph() + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + height: 300, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => Text('$index'), + childCount: 50, + ), + ), + superEditor, + ], + ), + ), + ), + ), + ) + .pump(); + + // Ensure the scrollview is scrollable. + expect(scrollController.position.maxScrollExtent, greaterThan(0.0)); + + // Jump to the end of the content. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); + + // Place the caret near the end of the document. + await tester.tapInParagraph("1", 440); + + // Press and drag the caret somewhere else in the paragraph. + final gesture = await tester.pressDownOnCollapsedMobileHandle(); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(24, 0)); + await tester.pump(); + } + + // Ensure that the magnifier appears above the caret. To check this, we make + // sure the bottom of the magnifier is above the top of the caret, and we make + // sure that the bottom of the magnifier is not unreasonable far above the caret. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + expect( + tester.getBottomLeft(SuperEditorInspector.findMobileMagnifier()).dy, + lessThan(tester.getTopLeft(SuperEditorInspector.findMobileCaret()).dy), + ); + expect( + tester.getTopLeft(SuperEditorInspector.findMobileCaret()).dy - + tester.getBottomLeft(SuperEditorInspector.findMobileMagnifier()).dy, + lessThan(20.0), + ); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(kTapMinTime); + }); + + testWidgetsOnAndroid('without an ancestor scrollable', (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .withEditorSize(const Size(300, 300)) + .pump(); + + // Ensure the editor is scrollable. + expect(scrollController.position.maxScrollExtent, greaterThan(0.0)); + + // Jump to the end of the content. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); + + // Place the caret near the end of the document. + await tester.tapInParagraph("1", 440); + await tester.pumpAndSettle(); + + // Press and drag the caret somewhere else in the paragraph. + final gesture = await tester.pressDownOnCollapsedMobileHandle(); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(24, 0)); + await tester.pump(); + } + + // Ensure that the magnifier appears above the caret. To check this, we make + // sure the bottom of the magnifier is above the top of the caret, and we make + // sure that the bottom of the magnifier is not unreasonable far above the caret. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + expect( + tester.getBottomLeft(SuperEditorInspector.findMobileMagnifier()).dy, + lessThan(tester.getTopLeft(SuperEditorInspector.findMobileCaret()).dy), + ); + expect( + tester.getTopLeft(SuperEditorInspector.findMobileCaret()).dy - + tester.getBottomLeft(SuperEditorInspector.findMobileMagnifier()).dy, + lessThan(20.0), + ); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(kTapMinTime); + }); + + testWidgetsOnAndroid('without an ancestor scrollable having widgets above the editor', (tester) async { + final scrollController = ScrollController(); + + // Pump a tree with another widget above the editor, + // so we can check if the magnifier is positioned at the correct + // position, even if the editor isn't aligned with the top-left of the screen. + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + height: 300, + child: Column( + children: [ + const SizedBox(height: 100), + Expanded(child: superEditor), + ], + ), + ), + ), + ), + ) + .pump(); + + // Ensure the editor is scrollable. + expect(scrollController.position.maxScrollExtent, greaterThan(0.0)); + + // Jump to the end of the content. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); + + // Place the caret near the end of the document. + await tester.tapInParagraph("1", 440); + await tester.pumpAndSettle(); + + // Press and drag the caret somewhere else in the paragraph. + final gesture = await tester.pressDownOnCollapsedMobileHandle(); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(24, 0)); + await tester.pump(); + } + + // Ensure that the magnifier appears above the caret. To check this, we make + // sure the bottom of the magnifier is above the top of the caret, and we make + // sure that the bottom of the magnifier is not unreasonable far above the caret. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + expect( + tester.getBottomLeft(SuperEditorInspector.findMobileMagnifier()).dy, + lessThan(tester.getTopLeft(SuperEditorInspector.findMobileCaret()).dy), + ); + expect( + tester.getTopLeft(SuperEditorInspector.findMobileCaret()).dy - + tester.getBottomLeft(SuperEditorInspector.findMobileMagnifier()).dy, + lessThan(20.0), + ); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(kTapMinTime); + }); + }); + + group("on device and web > shows", () { + testWidgetsOnAndroidDeviceAndWeb("caret", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Create a collapsed selection. + await tester.tapInParagraph("1", 1); + + // Ensure we have a collapsed selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isTrue); + + // Ensure caret (and only caret) is visible. + expect(SuperEditorInspector.findMobileCaret(), findsOneWidget); + expect(SuperEditorInspector.findMobileExpandedDragHandles(), findsNothing); + }); + + testWidgetsOnAndroidDeviceAndWeb("upstream and downstream handles", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure expanded handles are visible, but caret isn't. + expect(SuperEditorInspector.findMobileCaret(), findsNothing); + expect(SuperEditorInspector.findMobileUpstreamDragHandle(), findsOneWidget); + expect(SuperEditorInspector.findMobileDownstreamDragHandle(), findsOneWidget); + }); + }); + + group("on device > shows", () { + testWidgetsOnAndroid("the magnifier", (tester) async { + await _pumpSingleParagraphApp(tester); + + final gesture = await tester.longPressDownInParagraph("1", 1); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(-24, 0)); + await tester.pump(); + } + + // Ensure the magnifier is wanted AND visible. + expect(SuperEditorInspector.wantsMobileMagnifierToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + }); + + testWidgetsOnAndroid("the floating toolbar", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the toolbar is desired AND displayed. + expect(SuperEditorInspector.wantsMobileToolbarToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + }); + }); + + group("on web > shows", () { + testWidgetsOnWebAndroid("the magnifier", (tester) async { + // Explanation: On iOS, we defer some overlay controls to the mobile browser. + // This test is here to explicitly show that we don't defer those things to + // the mobile browser on Android. + await _pumpSingleParagraphApp(tester); + + // Long press and drag so that the magnifier appears. + final gesture = await tester.longPressDownInParagraph("1", 1); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(-24, 0)); + await tester.pump(); + } + + // Ensure the magnifier is desired AND displayed. + expect(SuperEditorInspector.wantsMobileMagnifierToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + }); + + testWidgetsOnWebAndroid("the floating toolbar", (tester) async { + // Explanation: On iOS, we defer some overlay controls to the mobile browser. + // This test is here to explicitly show that we don't defer those things to + // the mobile browser on Android. + await _pumpSingleParagraphApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the toolbar is desired AND displayed + expect(SuperEditorInspector.wantsMobileToolbarToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + }); + }); + }); +} + +Future _pumpSingleParagraphApp( + WidgetTester tester, { + bool simulateSoftwareKeyboardAppearance = true, +}) async { + return await tester + .createDocument() + // Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor... + .withSingleParagraph() + .simulateSoftwareKeyboardInsets(simulateSoftwareKeyboardAppearance) + .pump(); +} diff --git a/super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart b/super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart new file mode 100644 index 0000000000..bb2ac1db63 --- /dev/null +++ b/super_editor/test/super_editor/mobile/super_editor_android_selection_test.dart @@ -0,0 +1,911 @@ +import 'dart:ui'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/magnifier.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_tools.dart'; + +void main() { + group("SuperEditor mobile selection >", () { + group("Android >", () { + group("long press >", () { + testWidgetsOnAndroid("selects word under finger", (tester) async { + await _pumpAppWithLongText(tester); + + // Ensure that no overlay controls are visible. + _expectNoControlsAreVisible(); + + // Long press on the middle of "conse|ctetur" + await tester.longPressInParagraph("1", 33); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection(), _wordConsecteturSelection); + + // Ensure the drag handles and toolbar are visible, but the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("does nothing with hack global property", (tester) async { + disableLongPressSelectionForSuperlist = true; + addTearDown(() => disableLongPressSelectionForSuperlist = false); + + await _pumpAppWithLongText(tester); + + // Long press down on the middle of "conse|ctetur" + final gesture = await tester.longPressDownInParagraph("1", 33); + await tester.pump(); + + // Ensure that there's no selection. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Release the long-press. + await gesture.up(); + await tester.pump(); + + // Ensure that only the caret was placed, rather than an expanded selection due + // to a long press. + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isTrue); + }); + + testWidgetsOnAndroid("selects by word when dragging upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag upstream to the end of the previous word. + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -130 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + + // Now that we've started dragging, ensure the magnifier is visible and the + // toolbar is hidden. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsOneWidget); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + + // Now that the drag is done, ensure the handles and toolbar are visible and + // the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by character when dragging upstream in reverse", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag near the end of the upstream word. + // "Lorem i|psum dolor sit amet" + // ^ position 7 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -15.0; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + + // Drag in reverse toward the initial selection. + // + // Drag far enough to trigger a per-character selection, and then + // drag a little more to deselect some characters. + const downstreamDragDistance = 110 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that part of the upstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when jumping up a line and dragging upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "adi|piscing". + final gesture = await tester.longPressDownInParagraph("1", 42); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordAdipiscingSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag up one line to select "dolor". + const dragIncrementCount = 10; + const verticalDragDistance = -24 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(0, verticalDragDistance)); + await tester.pump(); + } + + // Ensure the selection begins at the end of "adipiscing" and goes to the + // beginning of "dolor", which is upstream. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorStart), + ), + ), + ); + + // Drag upstream to select the previous word. + const upstreamDragDistance = -80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordIpsumStart), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when dragging downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag downstream to the beginning of the next word. + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + // + // "Lorem ipsum dolor sit| amet" + // ^ position 21 + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Now that we've started dragging, ensure the magnifier is visible and the + // toolbar is hidden. + _expectOnlyMagnifier(); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + + // Now that the drag is done, ensure the handles and toolbar are visible and + // the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by character when dragging downstream in reverse", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag near the end of the downstream word. + // "Lorem ipsum dolor si|t amet" + // ^ position 20 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = 100 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Drag in reverse toward the initial selection. + // + // Drag far enough to trigger a per-character selection, and then + // drag a little more to deselect some characters. + const downstreamDragDistance = -40 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that part of the downstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 19), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when jumping down a line and dragging downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "adi|piscing". + final gesture = await tester.longPressDownInParagraph("1", 42); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordAdipiscingSelection); + + // Drag down one line to select "tempor". + const dragIncrementCount = 10; + const verticalDragDistance = 24 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(0, verticalDragDistance)); + await tester.pump(); + } + + // Ensure the selection begins at the start of "adipiscing" and goes to the + // end of "tempor", which is upstream. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordTemporEnd), + ), + ), + ); + + // Drag downstream to select the next word. + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordIncididuntEnd), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects an image and then by word when jumping down", (tester) async { + await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ImageNode(id: '1', imageUrl: ''), + ParagraphNode( + id: '2', + text: AttributedText('Lorem ipsum dolor'), + ) + ], + ), + ) + .withAddedComponents( + [ + const FakeImageComponentBuilder( + size: Size(100, 100), + ), + ], + ).pump(); + + // Long press near the top of the image. + final tapDownOffset = tester.getTopLeft(find.byType(ImageComponent)) + const Offset(0, 10); + final gesture = await tester.startGesture(tapDownOffset); + await tester.pump(kLongPressTimeout + kPressTimeout); + + // Ensure the image was selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: UpstreamDownstreamNodePosition.upstream()), + extent: DocumentPosition(nodeId: '1', nodePosition: UpstreamDownstreamNodePosition.downstream()), + ), + ); + + // Drag down from the image to the begining of the paragraph. + const dragIncrementCount = 10; + final verticalDragDistance = + Offset(0, (tester.getTopLeft(find.byType(TextComponent)).dy - tapDownOffset.dy) / dragIncrementCount); + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(verticalDragDistance); + await tester.pump(); + } + + // Ensure the selection begins at the image and goes to the end of "Lorem". + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: UpstreamDownstreamNodePosition.upstream()), + extent: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 5), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects an image and then by word when jumping up", (tester) async { + await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Lorem ipsum dolor'), + ), + ImageNode(id: '2', imageUrl: ''), + ], + ), + ) + .withAddedComponents( + [ + const FakeImageComponentBuilder( + size: Size(100, 100), + ), + ], + ).pump(); + + // Long press near the top of the image. + final tapDownOffset = tester.getTopLeft(find.byType(ImageComponent)) + const Offset(0, 10); + final gesture = await tester.startGesture(tapDownOffset); + await tester.pump(kLongPressTimeout + kPressTimeout); + + // Ensure the image was selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition(nodeId: '2', nodePosition: UpstreamDownstreamNodePosition.upstream()), + extent: DocumentPosition(nodeId: '2', nodePosition: UpstreamDownstreamNodePosition.downstream()), + ), + ); + + // Drag up from the image to the begining of the paragraph. + const dragIncrementCount = 10; + final verticalDragDistance = + Offset(0, (tester.getTopLeft(find.byType(TextComponent)).dy - tapDownOffset.dy) / dragIncrementCount); + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(verticalDragDistance); + await tester.pump(); + } + + // Ensure the selection begins at the image and goes to the beginning of the paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition(nodeId: '2', nodePosition: UpstreamDownstreamNodePosition.downstream()), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + }); + + group("expanded handle >", () { + testWidgetsOnAndroid("selects by word when dragging downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Double tap to select the word "dolor". + await tester.doubleTapInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag the downstream handle to the beginning of the downstream word. + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + final gesture = await tester.pressDownOnDownstreamMobileHandle(); + const dragIncrementCount = 10; + const downstreamDragDistance = 30 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + // + // "Lorem ipsum dolor sit| amet" + // ^ position 21 + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by character when dragging downstream in reverse", (tester) async { + await _pumpAppWithLongText(tester); + + // Double tap to select the word "do|lor". + await tester.doubleTapInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag near the end of the downstream word. + // "Lorem ipsum dolor si|t amet" + // ^ position 20 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + final gesture = await tester.pressDownOnDownstreamMobileHandle(); + const dragIncrementCount = 10; + const dragDistance = 60 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(dragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ), + ); + + // Drag in reverse toward the initial selection. + // + // Drag far enough to trigger a per-character selection, and then + // drag a little more to deselect some characters. + const upstreamDragDistance = -30 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that part of the downstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 19), + ), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when dragging upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Double tap to select the word "dolor". + await tester.doubleTapInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag upstream to the end of the upstream word. + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + final gesture = await tester.pressDownOnUpstreamMobileHandle(); + const dragIncrementCount = 10; + const upstreamDragDistance = -30 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by character when dragging upstream in reverse", (tester) async { + await _pumpAppWithLongText(tester); + + // Double tap to select the word "dolor". + await tester.doubleTapInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag near the end of the upstream word. + // "Lorem ip|sum dolor sit amet" + // ^ position 8 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + final gesture = await tester.pressDownOnUpstreamMobileHandle(); + const dragIncrementCount = 10; + const upstreamDragDistance = -75.0 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Drag in reverse toward the initial selection. + const downstreamDragDistance = 45 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that part of the upstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + }); + }); + }); +} + +// The test suite was originally laid out and calculated with: +// - physical size: 2400x1800 +// - device pixel ratio: 3.0 + +// 01) Lorem ipsum dolor sit amet, [0, 28] +// 02) consectetur adipiscing elit, sed [28, 61] +// 03) do eiusmod tempor incididunt ut [61, 93] +// 04) labore et dolore magna aliqua. +// 05) Ut enim ad minim veniam, quis +// 06) nostrud exercitation ullamco +// 07) laboris nisi ut aliquip ex ea +// 08) commodo consequat. Duis aute +// 09) irure dolor in reprehenderit in +// 10) voluptate velit esse cillum +// 11) dolore eu fugiat nulla pariatur. +// 12) Excepteur sint occaecat +// 13) cupidatat non proident, sunt in +// 14) culpa qui officia deserunt +// 15) mollit anim id est laborum. + +Future _pumpAppWithLongText(WidgetTester tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withAndroidToolbarBuilder( + (context, key, leaderLink) => AndroidTextEditingFloatingToolbar( + floatingToolbarKey: key, + ), + ) + .pump(); +} + +const _wordConsecteturSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 28), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), +); + +const _wordIpsumStart = 6; +// ignore: unused_element +const _wordIpsumEnd = 11; + +const _wordDolorStart = 12; +const _wordDolorEnd = 17; +const _wordDolorSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorEnd), + ), +); + +const _wordAdipiscingStart = 40; +const _wordAdipiscingEnd = 50; +const _wordAdipiscingSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), +); + +// ignore: unused_element +const _wordTemporStart = 72; +const _wordTemporEnd = 78; + +// ignore: unused_element +const _wordIncididuntStart = 79; +const _wordIncididuntEnd = 89; + +void _expectNoControlsAreVisible() { + expect(find.byType(AndroidSelectionHandle).hitTestable(), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} + +void _expectOnlyToolbar() { + expect(find.byType(AndroidSelectionHandle).hitTestable(), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOne); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} + +void _expectOnlyMagnifier() { + expect(find.byType(AndroidSelectionHandle).hitTestable(), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsOneWidget); +} + +void _expectHandlesAndToolbar() { + expect(find.byKey(DocumentKeys.upstreamHandle), findsOneWidget); + expect(find.byKey(DocumentKeys.downstreamHandle), findsOneWidget); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOne); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} diff --git a/super_editor/test/super_editor/mobile/super_editor_ios_overlay_controls_test.dart b/super_editor/test/super_editor/mobile/super_editor_ios_overlay_controls_test.dart new file mode 100644 index 0000000000..fdca491ac6 --- /dev/null +++ b/super_editor/test/super_editor/mobile/super_editor_ios_overlay_controls_test.dart @@ -0,0 +1,520 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_keyboard/test/keyboard_simulator.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +import '../../test_runners.dart'; +import '../../test_tools.dart'; + +void main() { + group("SuperEditor > iOS > overlay controls >", () { + testWidgetsOnIos("hides all controls when placing the caret", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret. + await tester.tapInParagraph("1", 200); + + // Ensure all controls are hidden. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnIos("toggles toolbar when tapping on caret (with software keyboard)", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret at the end of a word, because iOS snaps the caret + // to word boundaries by default. + await tester.tapInParagraph("1", 207); + + // Ensure all controls are hidden. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap again on the caret. + await tester.tapInParagraph("1", 207); + + // Ensure that the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + + // Tap on the caret again, to toggle the toolbar off. + await tester.tapInParagraph("1", 207); + + // Ensure that the toolbar is hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnIos("hides toolbar when IME connection is closed (with software keyboard)", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret at the end of a word, because iOS snaps the caret + // to word boundaries by default. + await tester.tapInParagraph("1", 207); + + // Ensure all controls are hidden. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap again on the caret. + await tester.tapInParagraph("1", 207); + + // Ensure that the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + + // Take the IME connection away from Super Editor. The best we can do to + // simulate this is to move the focus somewhere else. In practice, this is + // how it actually occurs. It's not obvious under which circumstances the OS + // forcibly reclaims the IME, or how we should simulate that in tests. + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + await tester.pump(); + + // Ensure that the toolbar is hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnIos("toggles toolbar when tapping on caret (with hardware keyboard)", (tester) async { + await _pumpSingleParagraphApp(tester, simulateSoftwareKeyboardAppearance: false); + + // Place the caret at the end of a word, because iOS snaps the caret + // to word boundaries by default. + await tester.tapInParagraph("1", 207); + + // Ensure all controls are hidden. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap again on the caret. + await tester.tapInParagraph("1", 207); + + // Ensure that the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + + // Tap on the caret again, to toggle the toolbar off. + await tester.tapInParagraph("1", 207); + + // Ensure that the toolbar is hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnIos("hides toolbar when IME connection is closed (with hardware keyboard)", (tester) async { + await _pumpSingleParagraphApp(tester, simulateSoftwareKeyboardAppearance: false); + + // Place the caret at the end of a word, because iOS snaps the caret + // to word boundaries by default. + await tester.tapInParagraph("1", 207); + + // Ensure all controls are hidden. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap again on the caret. + await tester.tapInParagraph("1", 207); + + // Ensure that the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + + // Take the IME connection away from Super Editor. The best we can do to + // simulate this is to move the focus somewhere else. In practice, this is + // how it actually occurs. It's not obvious under which circumstances the OS + // forcibly reclaims the IME, or how we should simulate that in tests. + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pump(); + await tester.pump(); + + // Ensure that the toolbar is hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnIos("shows magnifier when dragging the caret", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Place the caret. + await tester.tapInParagraph("1", 200); + + // Press and drag the caret somewhere else in the paragraph. + final gesture = await tester.tapDownInParagraph("1", 200); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(24, 0)); + await tester.pump(); + } + + // Ensure magnifier is visible and toolbar is hidden. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + }); + + testWidgetsOnIos("does not blink caret while dragging it", (tester) async { + BlinkController.indeterminateAnimationsEnabled = true; + addTearDown(() => BlinkController.indeterminateAnimationsEnabled = false); + + await _pumpSingleParagraphApp(tester); + + // Place the caret. + await tester.tapInParagraph("1", 200); + + // Press and drag the caret somewhere else in the paragraph. + final gesture = await tester.tapDownInParagraph("1", 200); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(24, 0)); + await tester.pump(); + } + + // Duration for the caret to switch between visible and invisible. + final flashPeriod = SuperEditorInspector.caretFlashPeriod(); + + // Ensure caret is visible. + expect(SuperEditorInspector.isCaretVisible(), isTrue); + + // Trigger a frame with an ellapsed time equal to the flashPeriod, + // so if the caret is blinking it will change from visible to invisible. + await tester.pump(flashPeriod); + + // Ensure caret is still visible after the flash period, which means it isn't blinking. + expect(SuperEditorInspector.isCaretVisible(), isTrue); + + // Trigger another frame. + await tester.pump(flashPeriod); + + // Ensure caret is still visible. + expect(SuperEditorInspector.isCaretVisible(), isTrue); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + }); + + testWidgetsOnIos("shows toolbar when selection is expanded", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Select a word. + await tester.doubleTapInParagraph("1", 200); + + // Ensure toolbar is visible and magnifier is hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnIos("hides toolbar when tapping on expanded selection", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Select a word. + await tester.doubleTapInParagraph("1", 200); + + // Ensure toolbar is visible and magnifier is hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + + // Tap on the selected text. + await tester.tapInParagraph("1", 200); + + // Ensure that all controls are now hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnIos("shows toolbar when long pressing on an empty paragraph and hides it after typing", + (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + // The decision about showing the toolbar depends on the keyboard visibility. + // Simulate the keyboard being visible immediately after the IME is connected. + TestSuperKeyboard.install(id: '1', vsync: tester, keyboardAnimationTime: Duration.zero); + addTearDown(() => TestSuperKeyboard.uninstall('1')); + + // Ensure the toolbar is not visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Long press, this shouldn't show the toolbar. + final gesture = await tester.longPressDownInParagraph('1', 0); + + // Ensure the toolbar is not visible yet. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Release the finger. + await gesture.up(); + await tester.pump(); + + // Ensure the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + + // Type a character to hide the toolbar. + await tester.typeImeText('a'); + + // Ensure the toolbar is not visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnIos("does not show toolbar upon first tap", (tester) async { + await tester // + .createDocument() + .withTwoEmptyParagraphs() + .pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph("1", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Place the caret at the beginning of the second paragraph, at the same offset. + await tester.placeCaretInParagraph("2", 0); + + // Ensure the toolbar isn't visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + + testWidgetsOnIos("shows magnifier when dragging expanded handle", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Select a word. + await tester.doubleTapInParagraph("1", 250); + + // Press and drag upstream handle + final gesture = await tester.pressDownOnUpstreamMobileHandle(); + for (int i = 0; i < 5; i += 1) { + await gesture.moveBy(const Offset(-24, 0)); + await tester.pump(); + } + + // Ensure that the magnifier is visible. + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + }); + + testWidgetsOnIos("hides expanded handles and toolbar when deleting an expanded selection", (tester) async { + // Configure BlinkController to animate, otherwise it won't blink. We want to make sure + // the caret blinks after deleting the content. + BlinkController.indeterminateAnimationsEnabled = true; + addTearDown(() => BlinkController.indeterminateAnimationsEnabled = false); + + await _pumpSingleParagraphApp(tester); + + // Double tap to select "Lorem". + await tester.doubleTapInParagraph("1", 1); + await tester.pump(); + + // Ensure the toolbar and the drag handles are visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + expect(SuperEditorInspector.findMobileExpandedDragHandles(), findsNWidgets(2)); + + // Press backspace to delete the word "Lorem" while the expanded handles are visible. + await tester.ime.backspace(getter: imeClientGetter); + + // Ensure the toolbar and the drag handles were hidden. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + expect(SuperEditorInspector.findMobileExpandedDragHandles(), findsNothing); + + // Ensure caret is blinking. + + expect(SuperEditorInspector.isCaretVisible(), true); + + // Duration to switch between visible and invisible. + final flashPeriod = SuperEditorInspector.caretFlashPeriod(); + + // Trigger a frame with an ellapsed time equal to the flashPeriod, + // so the caret should change from visible to invisible. + await tester.pump(flashPeriod); + + // Ensure caret is invisible after the flash period. + expect(SuperEditorInspector.isCaretVisible(), false); + + // Trigger another frame to make caret visible again. + await tester.pump(flashPeriod); + + // Ensure caret is visible. + expect(SuperEditorInspector.isCaretVisible(), true); + }); + + testWidgetsOnIos("keeps current selection when tapping on caret", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Tap at "consectetur|" to place the caret. + await tester.tapInParagraph("1", 39); + + // Ensure that the selection was placed at the end of the word. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), + )), + ); + + // Press and drag the caret to "con|sectetur" because dragging is the only way + // we can place the caret at the middle of a word when caret snapping is enabled. + final gesture = await tester.tapDownInParagraph("1", 39); + for (int i = 0; i < 7; i += 1) { + await gesture.moveBy(const Offset(-19, 0)); + await tester.pump(); + } + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(kDoubleTapTimeout); + + // Ensure that the selection moved to "con|sectetur". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 32), + ), + )), + ); + + // Ensure the toolbar is not visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Tap on the caret. + await tester.tapInParagraph("1", 32); + + // Ensure the selection was kept at "con|sectetur". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 32), + ), + )), + ); + + // Ensure the toolbar is visible. + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + }); + + group("on device and web > shows ", () { + testWidgetsOnIosDeviceAndWeb("caret", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Create a collapsed selection. + await tester.tapInParagraph("1", 1); + + // Ensure we have a collapsed selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isTrue); + + // Ensure caret (and only caret) is visible. + expect(SuperEditorInspector.findMobileCaret(), findsOneWidget); + expect(SuperEditorInspector.findMobileExpandedDragHandles(), findsNothing); + }); + + testWidgetsOnIosDeviceAndWeb("upstream and downstream handles", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure expanded handles are visible, but caret isn't. + expect(SuperEditorInspector.findMobileCaret(), findsNothing); + expect(SuperEditorInspector.findMobileUpstreamDragHandle(), findsOneWidget); + expect(SuperEditorInspector.findMobileDownstreamDragHandle(), findsOneWidget); + }); + }); + + group("on device >", () { + group("shows", () { + testWidgetsOnIos("the magnifier", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Long press, and hold, so that the magnifier appears. + await tester.longPressDownInParagraph("1", 1); + + // Ensure the magnifier is wanted AND visible. + expect(SuperEditorInspector.wantsMobileMagnifierToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isTrue); + }); + + testWidgetsOnIos("the floating toolbar", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the toolbar is desired AND displayed. + expect(SuperEditorInspector.wantsMobileToolbarToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isTrue); + }); + }); + }); + + group("on web >", () { + group("defers to browser to show", () { + testWidgetsOnWebIos("the magnifier", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Long press, and hold, so that the magnifier appears. + await tester.longPressDownInParagraph("1", 1); + + // Ensure the magnifier is desired, but not displayed. + expect(SuperEditorInspector.wantsMobileMagnifierToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnWebIos("the floating toolbar", (tester) async { + await _pumpSingleParagraphApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the toolbar is desired, but not displayed. + expect(SuperEditorInspector.wantsMobileToolbarToBeVisible(), isTrue); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + }); + }); + }); + }); +} + +Future _pumpSingleParagraphApp( + WidgetTester tester, { + bool simulateSoftwareKeyboardAppearance = true, +}) async { + await tester + .createDocument() + // Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor... + .withSingleParagraph() + .simulateSoftwareKeyboardInsets(simulateSoftwareKeyboardAppearance) + .useIosSelectionHeuristics(true) + .pump(); +} diff --git a/super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart b/super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart new file mode 100644 index 0000000000..5da55d4c28 --- /dev/null +++ b/super_editor/test/super_editor/mobile/super_editor_ios_selection_test.dart @@ -0,0 +1,701 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_tools.dart'; + +void main() { + group("SuperEditor mobile selection >", () { + group("iOS >", () { + group("on tap >", () { + testWidgetsOnIos("when beyond first character > places caret at end of word", (tester) async { + // Note: We pump the following text. + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + await _pumpAppWithLongText(tester); + + // Tap near the end of a word "consectet|ur". + await tester.tapInParagraph("1", 37); + await tester.pumpAndSettle(); + + // Ensure that the caret is at the end of the world "consectetur|". + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), + ), + ); + + // Tap near the middle of a word "adipi|scing". + await tester.tapInParagraph("1", 45); + await tester.pumpAndSettle(); + + // Ensure that the caret is at the end of the world "adipiscing|". + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 50), + ), + ), + ); + + // Tap near the beginning of a word "co|nsectetur". + await tester.tapInParagraph("1", 30); + await tester.pumpAndSettle(); + + // Ensure that the caret is at the end of the word "consectetur|". + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), + ), + ); + }); + + testWidgetsOnIos("when near first character > places caret at start of word", (tester) async { + // Note: We pump the following text. + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + await _pumpAppWithLongText(tester); + + // Tap just before first character of word " |consectetur". + await tester.tapInParagraph("1", 28); + await tester.pumpAndSettle(); + + // Ensure that the caret is at the start of the world "|consectetur". + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 28), + ), + ), + ); + + // Tap just after the start of the word " a|dipiscing". + await tester.tapInParagraph("1", 41); + await tester.pumpAndSettle(); + + // Ensure that the caret is at the start of the word " |adipiscing". + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 40), + ), + ), + ); + }); + }); + + group("long press >", () { + testWidgetsOnIos("selects word under finger", (tester) async { + await _pumpAppWithLongText(tester); + + // Ensure that no overlay controls are visible. + _expectNoControlsAreVisible(); + + // Long press on the middle of "conse|ctetur". + await tester.longPressInParagraph("1", 33); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection(), _wordConsecteturSelection); + + // Ensure the drag handles and toolbar are visible, but the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnIos("does nothing with hack global property", (tester) async { + disableLongPressSelectionForSuperlist = true; + addTearDown(() => disableLongPressSelectionForSuperlist = false); + + await _pumpAppWithLongText(tester); + + // Long press down on the middle of "conse|ctetur". + final gesture = await tester.longPressDownInParagraph("1", 33); + await tester.pump(); + + // Ensure that there's no selection. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Release the long-press. + await gesture.up(); + await tester.pump(); + + // Ensure that only the caret was placed, rather than an expanded selection due + // to a long press. + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isTrue); + }); + + testWidgetsOnIos("over handle does nothing", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + await tester.longPressInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Long-press near the upstream handle, but just before the selected word. + await tester.longPressInParagraph("1", 11); + await tester.pumpAndSettle(); + + // Ensure that the selection didn't change. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Long-press near the downstream handle, but just after the selected word. + await tester.longPressInParagraph("1", 18); + await tester.pumpAndSettle(); + + // Ensure that the selection didn't change. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + }); + + testWidgetsOnIos("selects by word when dragging upstream and then back downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the drag handles and magnifier are visible, but the toolbar isn't. + _expectHandlesAndMagnifier(); + + // Drag upstream to the end of the previous word. + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -130 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Drag back towards the original long-press offset. + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that only the original word is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + // Note: when we move the selection back the other way, the word calculation + // decided to include the leading space, which is why we pass a different + // selection here. + nodePosition: TextNodePosition(offset: 11), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + }); + + testWidgetsOnIos("selects by word when dragging downstream and then back upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the drag handles and magnifier are visible, but the toolbar isn't. + _expectHandlesAndMagnifier(); + + // Drag downstream to the beginning of the next word. + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Drag back towards the original long-press offset. + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const upstreamDragDistance = -40 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that only the original word is selected. + expect(SuperEditorInspector.findDocumentSelection(), _wordDolorSelection); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + }); + + testWidgetsOnIos("selects an image and then by word when jumping down", (tester) async { + await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ImageNode(id: '1', imageUrl: ''), + ParagraphNode( + id: '2', + text: AttributedText('Lorem ipsum dolor'), + ) + ], + ), + ) + .withAddedComponents( + [ + const FakeImageComponentBuilder( + size: Size(100, 100), + ), + ], + ).pump(); + + // Long press near the top of the image. + final tapDownOffset = tester.getTopLeft(find.byType(ImageComponent)) + const Offset(0, 10); + final gesture = await tester.startGesture(tapDownOffset); + await tester.pump(kLongPressTimeout + kPressTimeout); + + // Ensure the image was selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: UpstreamDownstreamNodePosition.upstream()), + extent: DocumentPosition(nodeId: '1', nodePosition: UpstreamDownstreamNodePosition.downstream()), + ), + ); + + // Drag down from the image to the begining of the paragraph. + const dragIncrementCount = 10; + final verticalDragDistance = + Offset(0, (tester.getTopLeft(find.byType(TextComponent)).dy - tapDownOffset.dy) / dragIncrementCount); + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(verticalDragDistance); + await tester.pump(); + } + + // Ensure the selection begins at the image and goes to the end of "Lorem". + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: UpstreamDownstreamNodePosition.upstream()), + extent: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 5), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnIos("selects an image and then by word when jumping up", (tester) async { + await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Lorem ipsum dolor'), + ), + ImageNode(id: '2', imageUrl: ''), + ], + ), + ) + .withAddedComponents( + [ + const FakeImageComponentBuilder( + size: Size(100, 100), + ), + ], + ).pump(); + + // Long press near the top of the image. + final tapDownOffset = tester.getTopLeft(find.byType(ImageComponent)) + const Offset(0, 10); + final gesture = await tester.startGesture(tapDownOffset); + await tester.pump(kLongPressTimeout + kPressTimeout); + + // Ensure the image was selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition(nodeId: '2', nodePosition: UpstreamDownstreamNodePosition.upstream()), + extent: DocumentPosition(nodeId: '2', nodePosition: UpstreamDownstreamNodePosition.downstream()), + ), + ); + + // Drag up from the image to the begining of the paragraph. + const dragIncrementCount = 10; + final verticalDragDistance = + Offset(0, (tester.getTopLeft(find.byType(TextComponent)).dy - tapDownOffset.dy) / dragIncrementCount); + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(verticalDragDistance); + await tester.pump(); + } + + // Ensure the selection starts at the beginning of the paragraph and goes to the end of the image. + // + // On iOS, the selection ends up normalized, where the position the appears first in the document + // is considered to be the selection base. Therefore, even though we are dragging upstream, + // the paragraph is the base of the selection. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition(nodeId: '2', nodePosition: UpstreamDownstreamNodePosition.downstream()), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + }); + + group("horizontal drag", () { + testWidgetsOnIos("does not cause editor to scroll", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withLongDoc() + .withScrollController(scrollController) + .pump(); + + // Start dragging horizontally. + final gesture = await tester.startGesture( + tester.getCenter(find.byType(SuperEditor)), + ); + + // Drag horizontally. + for (int i = 1; i < 10; i += 1) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + + // Ensure that dragging doesn't cause the editor to scroll. + expect(scrollController.offset, 0); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); + + group("vertical drag", () { + testWidgetsOnIos("scrolls the editor after a horizontal drag", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withLongDoc() + .withScrollController(scrollController) + .pump(); + + // Start dragging horizontally. + final gesture = await tester.startGesture( + tester.getCenter(find.byType(SuperEditor)), + ); + + // Drag horizontally. + for (int i = 1; i < 10; i += 1) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + + // Ensure that dragging doesn't cause the editor to scroll. + expect(scrollController.offset, 0); + + // Drag vertically. + for (int i = 1; i < 10; i += 1) { + await gesture.moveBy(const Offset(0, -10)); + await tester.pump(); + } + + // Ensure that the editor scrolled up. + expect(scrollController.offset, greaterThan(0.0)); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); + + testWidgetsOnIos("converts entire paragraph selection to entire document selection", (tester) async { + // This test locks the behavior of a workaround for https://github.com/superlistapp/super_editor/issues/2579. + // + // When using the native text selection toolbar to select all text in a document, the IME sends a delta + // that selects only the text of the currently selected nodes. This is because we only send the text of the + // currently selected nodes to the IME. This test ensures that we select the entire document when we + // receive a delta that selects an entire node. + + await tester // + .createDocument() + .withCustomContent(MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('First paragraph'), + ), + ParagraphNode( + id: '2', + text: AttributedText('Second paragraph'), + ), + ], + )) + .pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph('1', 0); + + // Simulate the user pressing "Select all" on the native text selection toolbar. + // Since we send only the text of the currently selected nodes to the IME, this results in a delta + // that selects only the first paragraph. + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. First paragraph', + selection: TextSelection(baseOffset: 0, extentOffset: 17), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Ensure that we selected the entire document. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 16), + ), + ), + ); + }); + }); + + group('within ancestor scrollable', () { + testWidgetsOnIos("expands selection when dragging horizontally", (tester) async { + final testContext = await tester + .createDocument() + .fromMarkdown( + ''' +SuperEditor containing a +paragraph that spans +multiple lines.''', + ) + .insideCustomScrollView() + .pump(); + + final paragraphNode = testContext.document.first as ParagraphNode; + + // Double tap to select "SuperEditor". + await tester.doubleTapInParagraph(paragraphNode.id, 0); + + // Drag from "SuperEdito|r" a distance long enough to go through the entire first line. + await tester.dragSelectDocumentFromPositionByOffset( + from: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 10), + ), + delta: const Offset(300, 0), + ); + + // Ensure the first line is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection( + base: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 24), + ), + ), + ), + ); + }); + + testWidgetsOnIos("expands selection when dragging vertically", (tester) async { + final testContext = await tester + .createDocument() + .fromMarkdown( + ''' +SuperEditor containing a +paragraph that spans +multiple lines.''', + ) + .insideCustomScrollView() + .pump(); + + final paragraphNode = testContext.document.first as ParagraphNode; + + // Double tap to select "SuperEditor". + await tester.doubleTapInParagraph(paragraphNode.id, 0); + + // Drag from "SuperEdito|r" a distance long enough to go to the last line. + await tester.dragSelectDocumentFromPositionByOffset( + from: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 10), + ), + delta: const Offset(0, 40), + ); + + // Ensure the selection starts at the beginning and end at "multiple l|ines". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection( + base: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 57), + ), + ), + ), + ); + }); + }); + }); +} + +// The test suite was originally laid out and calculated with: +// - physical size: 2400x1800 +// - device pixel ratio: 3.0 + +Future _pumpAppWithLongText(WidgetTester tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .useIosSelectionHeuristics(true) + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => + IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint)) + .pump(); +} + +const _wordConsecteturSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 28), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), +); + +const _wordDolorSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), +); + +void _expectNoControlsAreVisible() { + expect(find.byType(IOSSelectionHandle), findsNothing); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsNothing); +} + +void _expectHandlesAndMagnifier() { + expect(find.byType(IOSSelectionHandle), findsNWidgets(2)); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsOneWidget); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); +} + +void _expectHandlesAndToolbar() { + expect(find.byType(IOSSelectionHandle), findsNWidgets(2)); + expect(find.byType(IOSTextEditingFloatingToolbar), findsOneWidget); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsNothing); +} diff --git a/super_editor/test/super_editor/mobile/super_editor_ios_swipe_to_pop_test.dart b/super_editor/test/super_editor/mobile/super_editor_ios_swipe_to_pop_test.dart new file mode 100644 index 0000000000..cfa28860c2 --- /dev/null +++ b/super_editor/test/super_editor/mobile/super_editor_ios_swipe_to_pop_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperEditor', () { + testWidgetsOnIos('keeps current selection and does not show mobile controls when swipping to pop', (tester) async { + // Run the test with a fixed size so we know how much we need to swipe + // to pop the page. + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(300, 600); + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + // Pump an app with two routes to simulate a swipe-to-pop gesture. + // + // The app pushes the SuperEditor route automatically after the first frame. + await tester + .createDocument() + .withSingleParagraph() + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: _AutoPushRoute( + route: MaterialPageRoute( + builder: (context) => Scaffold( + body: superEditor, + ), + ), + ), + ), + ) + .pump(); + + // Wait until the editor route is displayed. + await tester.pumpAndSettle(); + + // Ensure there is no selection. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Start dragging approximately from the top left corner of the editor. + final gesture = await tester.startGesture( + tester.getTopLeft(find.byType(SuperEditor)) + const Offset(10, 10), + ); + + // Move a little bit to start the swipe to pop gesture. + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + + // Move to the right side of the screen to trigger the route pop. + await gesture.moveBy(const Offset(200, 0)); + await tester.pump(); + + // Let the long press timer resolve. + await tester.pump(kLongPressTimeout); + + // Ensure there is still no selection and the magnifier is not displayed. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + expect(SuperEditorInspector.findMobileMagnifier(), findsNothing); + expect(SuperEditorInspector.isMobileToolbarVisible(), isFalse); + + // Release the gesture. + await gesture.up(); + await tester.pumpAndSettle(); + + // Ensure that the route was popped. + expect(find.byType(SuperEditor), findsNothing); + }); + }); +} + +/// Displays a placeholder and automatically pushes the given [route] +/// after the first frame. +class _AutoPushRoute extends StatefulWidget { + const _AutoPushRoute({ + required this.route, + }); + + final Route route; + + @override + State<_AutoPushRoute> createState() => _AutoPushRouteState(); +} + +class _AutoPushRouteState extends State<_AutoPushRoute> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((duration) { + if (!mounted) { + return; + } + + Navigator.of(context).push(widget.route); + }); + } + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/super_editor/test/super_editor/super_editor_ime_ownership_test.dart b/super_editor/test/super_editor/super_editor_ime_ownership_test.dart new file mode 100644 index 0000000000..fdc9275af8 --- /dev/null +++ b/super_editor/test/super_editor/super_editor_ime_ownership_test.dart @@ -0,0 +1,240 @@ +import 'dart:async'; + +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/default_document_editor.dart'; +import 'package:super_editor/src/default_editor/document_ime/shared_ime.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; + +void main() { + group("Super Editor > IME ownership >", () { + testWidgets("releases ownership when it loses focus", (tester) async { + final focusNode = FocusNode(debugLabel: "test-editor"); + final editor1 = createDefaultDocumentEditor(); + await _pumpScaffold( + tester, + SuperEditor( + focusNode: focusNode, + editor: editor1, + inputRole: _roleVariant.currentValue, + ), + ); + + expect(SuperIme.instance.isOwned, isFalse); + + // Focus the editor. We do this directly with the `FocusNode` instead of tapping + // on the editor because there are many cases where developers give focus to the editor + // in this same way. + focusNode.requestFocus(); + await tester.pump(); + + expect(SuperIme.instance.isOwned, isTrue); + + // Take focus away from the editor. We expect the editor to give up the IME. + focusNode.unfocus(); + await tester.pump(); + + expect(SuperIme.instance.isOwned, isFalse); + }); + + testWidgets( + "does not clear selection, or unfocus, when IME is claimed by a different SuperEditor with the same role", + (tester) async { + final focusNode = FocusNode(debugLabel: "test-editor"); + final editor = createDefaultDocumentEditor(document: _emptyParagraph); + + await _pumpScaffold( + tester, + SuperEditor( + // We use a GlobalKey to force Flutter to throw away the tree and replace it. + key: GlobalKey(debugLabel: 'first-editor-tree'), + focusNode: focusNode, + editor: editor, + inputRole: "Chat", + ), + ); + + expect(focusNode.hasPrimaryFocus, isFalse); + expect(editor.composer.selection, isNull); + expect(SuperIme.instance.isOwned, isFalse); + + // Place the caret, and focus the editor. + await tester.tapInParagraph("1", 0); + + // Ensure that the editor now owns the IME. + expect(focusNode.hasPrimaryFocus, isTrue); + expect(editor.composer.selection, isNotNull); + expect(SuperIme.instance.isOwned, isTrue); + expect(SuperIme.instance.owner?.role, "Chat"); + final owner1 = SuperIme.instance.owner; + + // Pump a new widget tree, but still with a SuperEditor playing the same role. + await _pumpScaffold( + tester, + SuperEditor( + // We use a GlobalKey to force Flutter to throw away the tree and replace it. + key: GlobalKey(debugLabel: 'second-editor-tree'), + focusNode: focusNode, + editor: editor, + inputRole: "Chat", + ), + ); + await tester.pumpAndSettle(); + + // Ensure that the editor state hasn't changed: still has selection, still focused, + // IME still owned. BUT, the IME owner has changed instance. + // + // Note: This test was added for issue #2962 (https://github.com/Flutter-Bounty-Hunters/super_editor/issues/2962). + // Before that, when replacing one SuperEditor with another, even with the same role, the + // SuperEditor being disposed would clear the selection and unfocus, causing the IME to close. + expect(focusNode.hasPrimaryFocus, isTrue); + expect(editor.composer.selection, isNotNull); + expect(SuperIme.instance.isOwned, isTrue); + expect(SuperIme.instance.owner?.role, "Chat"); + expect(SuperIme.instance.owner, isNot(owner1)); + }, + ); + + group("catches duplicate roles in the same build >", () { + // Note about timing: This group tests when multiple editors `build()` in the same + // frame. If a given editor already exists, it might not need to `build()` in the + // same frame as another editor. That case is tested in a different group. + + testWidgets("throws exception on 2+", (tester) async { + final editor1 = createDefaultDocumentEditor(); + final editor2 = createDefaultDocumentEditor(); + + // Ensure that we can pump a single editor with a role. + await _pumpScaffold( + tester, + SuperEditor( + editor: editor1, + inputRole: _roleVariant.currentValue, + ), + ); + + // Expect that when we pump two editors with the same role, we get an exception. + final errors = await _captureFlutterErrors( + () => _pumpScaffold( + tester, + Column( + children: [ + Expanded( + child: SuperEditor( + editor: editor1, + inputRole: _roleVariant.currentValue, + ), + ), + Expanded( + child: SuperEditor( + editor: editor2, + // This is the same role as above, which isn't allowed. + inputRole: _roleVariant.currentValue, + ), + ), + ], + ), + ), + ); + + expect(errors.length, 1); + expect(errors.first.exception, isA()); + expect(errors.first.exception.toString(), startsWith("Exception: Found 2 duplicate input IDs this frame:")); + }, variant: _roleVariant); + }); + + group("catches duplicate roles in subsequent builds >", () { + // Note about timing: Multiple editors might run `build()` in the same frame, + // or one editor might already exist and not need to run build, but then a second + // editor builds in another area of the tree. This group tests this timing situation. + + testWidgets("throws exception on 2+", (tester) async { + final editor1 = createDefaultDocumentEditor(); + final editor2 = createDefaultDocumentEditor(); + final buildSecondEditor = ValueNotifier(false); + + // Ensure that we can pump a single editor with a role. + await _pumpScaffold( + tester, + Column( + children: [ + Expanded( + child: SuperEditor( + editor: editor1, + inputRole: _roleVariant.currentValue, + ), + ), + ListenableBuilder( + listenable: buildSecondEditor, + builder: (context, child) { + if (!buildSecondEditor.value) { + return const SizedBox(); + } + + return Expanded( + child: SuperEditor( + editor: editor2, + // This is the same role as above, which isn't allowed. + inputRole: _roleVariant.currentValue, + ), + ); + }), + ], + ), + ); + + // Flip the signal to show the second editor. This should result in the second + // editor building, but the first shouldn't re-run build. + buildSecondEditor.value = true; + + // Pump a frame to let Flutter build what it wants. We expect to capture an exception + // during this pump. + final errors = await _captureFlutterErrors( + () async => await tester.pump(), + ); + + expect(errors.length, 1); + expect(errors.first.exception, isA()); + expect(errors.first.exception.toString(), startsWith("Exception: Found 2 duplicate input IDs this frame:")); + }, variant: _roleVariant); + }); + }); +} + +final _roleVariant = ValueVariant({null, "Chat"}); + +Future _pumpScaffold(WidgetTester tester, Widget child) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: child, + ), + ), + ); +} + +final _emptyParagraph = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText()), + ], +); + +FutureOr> _captureFlutterErrors(FutureOr Function() test) async { + final errors = []; + + final originalOnError = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + errors.add(details); + }; + + await test(); + + // Restore the original handler to avoid affecting other tests + FlutterError.onError = originalOnError; + + return errors; +} diff --git a/super_editor/test/super_editor/super_editor_shrinkwrap_test.dart b/super_editor/test/super_editor/super_editor_shrinkwrap_test.dart new file mode 100644 index 0000000000..6ea18991bf --- /dev/null +++ b/super_editor/test/super_editor/super_editor_shrinkwrap_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group('SuperEditor', () { + testWidgetsOnAllPlatforms('can layout with shrinkwrap in a column', (tester) async { + final composer = MutableDocumentComposer(); + final docEditor = createDefaultDocumentEditor( + document: MutableDocument.empty(), + composer: composer, + ); + // This must not fail with infinite height constraints. + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Column( + children: [ + SuperEditor( + editor: docEditor, + shrinkWrap: true, + ), + ], + ), + ), + )); + }); + }); +} diff --git a/super_editor/test/super_editor/super_editor_undo_redo_test.dart b/super_editor/test/super_editor/super_editor_undo_redo_test.dart new file mode 100644 index 0000000000..a28bda80d0 --- /dev/null +++ b/super_editor/test/super_editor/super_editor_undo_redo_test.dart @@ -0,0 +1,528 @@ +import 'package:clock/clock.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group("Super Editor > undo redo >", () { + testWidgets("can be disabled", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(false) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type some text that we'll attempt to undo. + await tester.typeImeText("a"); + + // Ensure we entered the "a". + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "a"); + + // Try to run undo. + await tester.pressCmdZ(tester); + + // Ensure that the text was unchanged. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "a"); + }); + + group("text insertion >", () { + testWidgets("insert a word", (tester) async { + final document = deserializeMarkdownToDocument("Hello world"); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer, isHistoryEnabled: true); + final paragraphId = document.first.id; + + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraphId, + nodePosition: const TextNodePosition(offset: 6), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ) + ]); + + editor.execute([ + InsertTextRequest( + documentPosition: DocumentPosition( + nodeId: paragraphId, + nodePosition: const TextNodePosition(offset: 6), + ), + textToInsert: "another", + attributions: {}, + ), + ]); + + expect(serializeDocumentToMarkdown(document), "Hello another world"); + expect( + composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraphId, + nodePosition: const TextNodePosition(offset: 13), + ), + ), + ); + + // Undo the event. + editor.undo(); + + expect(serializeDocumentToMarkdown(document), "Hello world"); + expect( + composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraphId, + nodePosition: const TextNodePosition(offset: 6), + ), + ), + ); + + // Redo the event. + editor.redo(); + + expect(serializeDocumentToMarkdown(document), "Hello another world"); + expect( + composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraphId, + nodePosition: const TextNodePosition(offset: 13), + ), + ), + ); + }); + + testWidgetsOnMac("type by character", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(true) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type characters. + await tester.typeImeText("Hello"); + + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Hello"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 5), + ), + ), + ); + + // --- Undo character insertions --- + await tester.pressCmdZ(tester); + _expectDocumentWithCaret("Hell", "1", 4); + + await tester.pressCmdZ(tester); + _expectDocumentWithCaret("Hel", "1", 3); + + await tester.pressCmdZ(tester); + _expectDocumentWithCaret("He", "1", 2); + + await tester.pressCmdZ(tester); + _expectDocumentWithCaret("H", "1", 1); + + await tester.pressCmdZ(tester); + _expectDocumentWithCaret("", "1", 0); + + //----- Redo Changes ---- + await tester.pressCmdShiftZ(tester); + _expectDocumentWithCaret("H", "1", 1); + + await tester.pressCmdShiftZ(tester); + _expectDocumentWithCaret("He", "1", 2); + + await tester.pressCmdShiftZ(tester); + _expectDocumentWithCaret("Hel", "1", 3); + + await tester.pressCmdShiftZ(tester); + _expectDocumentWithCaret("Hell", "1", 4); + + await tester.pressCmdShiftZ(tester); + _expectDocumentWithCaret("Hello", "1", 5); + }); + + testWidgetsOnMac("undo when typing after an image", (tester) async { + // A reported bug found that when inserting a paragraph after an image, typing some + // text, and then undo'ing the text, the paragraph's text duplicates during the + // undo operation: https://github.com/superlistapp/super_editor/issues/2164 + // TODO: The root cause of this problem was mutability of DocumentNode's. Delete this test after completing: https://github.com/superlistapp/super_editor/issues/2166 + final testContext = await tester + .createDocument() // + .withCustomContent(MutableDocument( + nodes: [ + ImageNode(id: "1", imageUrl: "https://fakeimage.com/myimage.png"), + ], + )) + .withComponentBuilders([ + const FakeImageComponentBuilder(size: Size(1000, 400)), + ...defaultComponentBuilders, + ]) + .enableHistory(true) + .autoFocus(true) + .pump(); + + await tester.tapAtDocumentPosition( + const DocumentPosition(nodeId: "1", nodePosition: UpstreamDownstreamNodePosition.downstream()), + ); + + // Press enter to insert a new paragraph. + await tester.pressEnter(); + + // Ensure we inserted a paragraph. + expect(testContext.document.nodeCount, 2); + expect(testContext.document.getNodeAt(0), isA()); + expect(testContext.document.getNodeAt(1), isA()); + + // Type some text. + await tester.pressKey(LogicalKeyboardKey.keyA); + + // Wait long enough to avoid combining actions into a single transaction. + await tester.pump(const Duration(seconds: 2)); + + // Type more text. + await tester.pressKey(LogicalKeyboardKey.keyB); + + // Ensure we inserted the text. + expect((testContext.document.getNodeAt(1) as TextNode).text.toPlainText(), "ab"); + + // Undo the text insertion. + // TODO: remove `tester` reference after updating flutter_test_robots + await tester.pressCmdZ(tester); + + // Ensure that the paragraph removed the last entered character. + expect((testContext.document.getNodeAt(1) as TextNode).text.toPlainText(), "a"); + }); + }); + + group("content conversions >", () { + testWidgetsOnMac("paragraph to header", (tester) async { + final editContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(true) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type text that causes a conversion to a header node. + await tester.typeImeText("# "); + + // Ensure that the paragraph is now a header. + final document = editContext.document; + var paragraph = document.first as ParagraphNode; + expect(paragraph.metadata['blockType'], header1Attribution); + expect(SuperEditorInspector.findTextInComponent(document.first.id).toPlainText(), ""); + + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the header attribution is gone. + paragraph = document.first as ParagraphNode; + expect(paragraph.metadata['blockType'], paragraphAttribution); + expect(SuperEditorInspector.findTextInComponent(document.first.id).toPlainText(), "# "); + }); + + testWidgetsOnMac("dashes to em dash", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(true) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type text that causes a conversion to an "em" dash. + await tester.typeImeText("--"); + + // Ensure that the double dashes are now an "em" dash. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "—"); + + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the em dash was reverted to the regular dashes. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "--"); + + // Continue typing. + await tester.typeImeText(" "); + + // Ensure that the dashes weren't reconverted into an em dash. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "-- "); + }); + + testWidgetsOnMac("paragraph to list item", (tester) async { + final editContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(true) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type text that causes a conversion to a list item node. + await tester.typeImeText("1. "); + + // Ensure that the paragraph is now a list item. + final document = editContext.document; + var node = document.first as TextNode; + expect(node, isA()); + expect(SuperEditorInspector.findTextInComponent(document.first.id).toPlainText(), ""); + + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the node is back to a paragraph. + node = document.first as TextNode; + expect(node, isA()); + expect(SuperEditorInspector.findTextInComponent(document.first.id).toPlainText(), "1. "); + }); + + testWidgetsOnMac("url to a link", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(true) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type text that causes a conversion to a link. + await tester.typeImeText("google.com "); + + // Ensure that the URL is now linkified. + expect( + SuperEditorInspector.findTextInComponent("1").getAttributionSpansByFilter((a) => a is LinkAttribution), + { + const AttributionSpan( + attribution: LinkAttribution("https://google.com"), + start: 0, + end: 9, + ), + }, + ); + + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the URL is no longer linkified. + expect( + SuperEditorInspector.findTextInComponent("1").getAttributionSpansByFilter((a) => a is LinkAttribution), + const {}, + ); + }); + + testWidgetsOnMac("paragraph to horizontal rule", (tester) async { + final editContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(true) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + await tester.typeImeText("--- "); + expect(editContext.document.first, isA()); + + await tester.pressCmdZ(tester); + await tester.pump(); + + expect(editContext.document.first, isA()); + expect(SuperEditorInspector.findTextInComponent(editContext.document.first.id).toPlainText(), "—- "); + }); + }); + + testWidgetsOnMac("pasted content", (tester) async { + final editContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(true) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Paste multiple nodes of content. + tester.simulateClipboard(); + await tester.setSimulatedClipboardContent(''' +This is paragraph 1 +This is paragraph 2 +This is paragraph 3'''); + await tester.pressCmdV(); + + // Ensure the pasted content was applied as expected. + final document = editContext.document; + expect(document.nodeCount, 3); + expect(SuperEditorInspector.findTextInComponent(document.getNodeAt(0)!.id).toPlainText(), "This is paragraph 1"); + expect(SuperEditorInspector.findTextInComponent(document.getNodeAt(1)!.id).toPlainText(), "This is paragraph 2"); + expect(SuperEditorInspector.findTextInComponent(document.getNodeAt(2)!.id).toPlainText(), "This is paragraph 3"); + + // Undo the paste. + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure we're back to a single empty paragraph. + expect(document.nodeCount, 1); + expect(SuperEditorInspector.findTextInComponent(document.getNodeAt(0)!.id).toPlainText(), ""); + + // Redo the paste + // TODO: remove WidgetTester as required argument to this robot method + await tester.pressCmdShiftZ(tester); + await tester.pump(); + + // Ensure the pasted content was applied as expected. + expect(document.nodeCount, 3); + expect(SuperEditorInspector.findTextInComponent(document.getNodeAt(0)!.id).toPlainText(), "This is paragraph 1"); + expect(SuperEditorInspector.findTextInComponent(document.getNodeAt(1)!.id).toPlainText(), "This is paragraph 2"); + expect(SuperEditorInspector.findTextInComponent(document.getNodeAt(2)!.id).toPlainText(), "This is paragraph 3"); + }); + + group("transaction grouping >", () { + group("text merging >", () { + testWidgetsOnMac("merges rapidly inserted text", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(true) + .withHistoryGroupingPolicy(const MergeRapidTextInputPolicy()) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type characters quickly. + await tester.typeImeText("Hello"); + + // Ensure our typed text exists. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Hello"); + + // Undo the typing. + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the whole word was undone. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), ""); + }); + + testWidgetsOnMac("separates text typed later", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(true) + .withHistoryGroupingPolicy(const MergeRapidTextInputPolicy()) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + await withClock(Clock(() => DateTime(2024, 05, 26, 12, 0, 0, 0)), () async { + // Type characters quickly. + await tester.typeImeText("Hel"); + }); + await withClock(Clock(() => DateTime(2024, 05, 26, 12, 0, 0, 150)), () async { + // Type characters quickly. + await tester.typeImeText("lo "); + }); + + // Wait a bit. + await tester.pump(const Duration(seconds: 3)); + + await withClock(Clock(() => DateTime(2024, 05, 26, 12, 0, 3, 0)), () async { + // Type characters quickly. + await tester.typeImeText("World!"); + }); + + // Ensure our typed text exists. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Hello World!"); + + // Undo the typing. + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the text typed later was removed, but the text typed earlier + // remains. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Hello "); + }); + }); + + group("selection and composing >", () { + testWidgetsOnMac("merges transactions with only selection and composing changes", (tester) async { + final testContext = await tester // + .createDocument() + .withLongDoc() + .enableHistory(true) + .withHistoryGroupingPolicy(defaultMergePolicy) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Ensure we start with one history transaction for placing the caret. + final editor = testContext.editor; + expect(editor.history.length, 1); + + // Move the selection around a few times. + await tester.placeCaretInParagraph("2", 5); + + await tester.placeCaretInParagraph("3", 3); + + await tester.placeCaretInParagraph("4", 0); + + // Ensure that all selection changes were merged into the initial transaction. + expect(editor.history.length, 1); + }); + + testWidgetsOnMac("does not merge transactions when non-selection changes are present", (tester) async { + final testContext = await tester // + .createDocument() + .withLongDoc() + .enableHistory(true) + .withHistoryGroupingPolicy(defaultMergePolicy) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Ensure we start with one history transaction for placing the caret. + final editor = testContext.editor; + expect(editor.history.length, 1); + + // Type a few characters. + await tester.typeImeText("Hello "); + + // Move caret to start of paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Type a few more characters. + await tester.typeImeText("World "); + + // Ensure we have 4 transactions: selection, typing+selection, typing. + expect(editor.history.length, 3); + }); + }); + }); + }); +} + +void _expectDocumentWithCaret(String documentContent, String caretNodeId, int caretOffset) { + expect(serializeDocumentToMarkdown(SuperEditorInspector.findDocument()!), documentContent); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: caretNodeId, + nodePosition: TextNodePosition(offset: caretOffset), + ), + ), + ); +} diff --git a/super_editor/test/super_editor/supereditor_ancestor_scrollable_test.dart b/super_editor/test/super_editor/supereditor_ancestor_scrollable_test.dart deleted file mode 100644 index 178376284b..0000000000 --- a/super_editor/test/super_editor/supereditor_ancestor_scrollable_test.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_editor/super_editor_test.dart'; - -import '../test_tools.dart'; -import 'document_test_tools.dart'; - -void main() { - group('SuperEditor respects horizontal scrolling', () { - testWidgetsOnAllPlatforms('inside a TabBar', (tester) async { - final tabController = TabController(length: 2, vsync: tester); - final scrollController = ScrollController(); - - // Pump a SuperEditor with a small maxHeight, so adding lines - // will cause the editor to scroll. - await tester - .createDocument() - .withSingleEmptyParagraph() - .withInputSource(DocumentInputSource.ime) - .withScrollController(scrollController) - .withCustomWidgetTreeBuilder( - (superEditor) => MaterialApp( - home: ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 300, - maxHeight: 100, - ), - child: Scaffold( - appBar: AppBar( - bottom: TabBar( - controller: tabController, - tabs: const [ - Tab(text: 'Tab 1'), - Tab(text: 'Tab 2'), - ], - ), - ), - body: TabBarView( - controller: tabController, - children: [ - superEditor, - const SizedBox(), - ], - ), - ), - ), - ), - ) - .pump(); - - // Select the editor. - await tester.placeCaretInParagraph('1', 0); - - // Add new lines so the content will cause editor to scroll - await _addNewLines(tester, count: 40); - await tester.pumpAndSettle(); - - // Ensure SuperEditor has scrolled - expect(scrollController.offset, greaterThan(0)); - - // Ensure that scrolling didn't cause a tab change - expect(tabController.index, equals(0)); - }); - - testWidgetsOnAllPlatforms('inside a horizontal ListView', (tester) async { - final listScrollController = ScrollController(); - final editorScrollController = ScrollController(); - - // Pump a SuperEditor with a small maxHeight, so adding lines - // will cause the editor to scroll. - await tester - .createDocument() - .withSingleEmptyParagraph() - .withInputSource(DocumentInputSource.ime) - .withScrollController(editorScrollController) - .withCustomWidgetTreeBuilder( - (superEditor) => MaterialApp( - home: Scaffold( - body: ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 300, - maxHeight: 100, - maxWidth: 300, - ), - child: ListView( - scrollDirection: Axis.horizontal, - controller: listScrollController, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 100), - child: superEditor, - ), - ...List.generate(20, (index) => Text('Text $index')), - ], - ), - ), - ), - ), - ) - .pump(); - - // Select the editor. - await tester.placeCaretInParagraph('1', 0); - - // Add new lines so the content will cause editor to scroll - await _addNewLines(tester, count: 40); - await tester.pumpAndSettle(); - - // Ensure SuperEditor has scrolled - expect(editorScrollController.offset, greaterThan(0)); - - // Ensure that scrolling didn't scroll the ListView - expect(listScrollController.position.pixels, equals(0)); - }); - }); -} - -/// Adds [count] new lines using IME actions -Future _addNewLines( - WidgetTester tester, { - required int count, -}) async { - for (int i = 0; i < count; i++) { - await tester.testTextInput.receiveAction(TextInputAction.newline); - await tester.pump(); - } -} diff --git a/super_editor/test/super_editor/supereditor_attributions_test.dart b/super_editor/test/super_editor/supereditor_attributions_test.dart new file mode 100644 index 0000000000..08117c473e --- /dev/null +++ b/super_editor/test/super_editor/supereditor_attributions_test.dart @@ -0,0 +1,1984 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import 'test_documents.dart'; + +void main() { + group("SuperEditor >", () { + group("applies attributions >", () { + group("when continuing existing attributions >", () { + group("by placing caret >", () { + // Bold is a stand-in for any value-based attribution extension, e.g., + // italics, underline, strikethrough. + testWidgetsOnAllPlatforms("bold", (tester) async { + await tester // + .createDocument() + .fromMarkdown("A **bold** text") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "bold|". + await tester.placeCaretInParagraph(doc.first.id, 6); + + // Type at an offset that should expand the bold attribution. + await tester.typeImeText("er"); + + // Ensure the bold attribution was applied to the inserted text. + expect(doc, equalsMarkdown("A **bolder** text")); + }); + + // Text color is a stand-in for any type-based attribution, e.g., + // background color. + testWidgetsOnAllPlatforms("text color", (tester) async { + final testContext = await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'Color text', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 4, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ), + ], + ), + ) + .pump(); + + final document = testContext.document; + + // Place the caret at "Color|". + await tester.placeCaretInParagraph(document.first.id, 5); + + // Type text that should expand the color attribution. + await tester.typeImeText("s"); + + // Ensure the color attribution was applied to the inserted text. + final text = SuperEditorInspector.findTextInComponent(document.first.id); + expect(text.toPlainText(), "Colors text"); + expect( + text.spans, + AttributedSpans(attributions: [ + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 5, + markerType: SpanMarkerType.end, + ), + ]), + ); + }); + }); + + group("by deleting characters and typing again >", () { + // Bold is a stand-in for any value-based attribution extension, e.g., + // italics, underline, strikethrough. + testWidgetsOnAllPlatforms("bold", (tester) async { + await tester // + .createDocument() + .fromMarkdown("A **bold** text") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at " text|". + await tester.placeCaretInParagraph(doc.first.id, 11); + + // Delete all the back to "bold|". + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Type text that should expand the bold attribution. + await tester.typeImeText("er"); + + // Ensure the bold attribution was applied to the inserted text. + expect(doc, equalsMarkdown("A **bolder**")); + }); + + // Text color is a stand-in for any type-based attribution, e.g., + // background color. + testWidgetsOnAllPlatforms("text color", (tester) async { + final testContext = await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'Color text', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 4, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ), + ], + ), + ) + .pump(); + + final document = testContext.document; + + // Place the caret at " text|". + await tester.placeCaretInParagraph(document.first.id, 10); + + // Delete all the back to "Color|". + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Type at a character that should expand the color attribution. + await tester.typeImeText("s"); + + // Ensure the color attribution was applied to the inserted text. + final text = SuperEditorInspector.findTextInComponent(document.first.id); + expect(text.toPlainText(), "Colors"); + expect( + text.spans, + AttributedSpans(attributions: [ + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 5, + markerType: SpanMarkerType.end, + ), + ]), + ); + }); + }); + }); + + group("when selecting by tapping", () { + testWidgetsOnAllPlatforms("and typing at the end of the attributed text", (tester) async { + await tester // + .createDocument() + .fromMarkdown("A **bold** text") + .withInputSource(TextInputSource.ime) + .pump(); + + final document = SuperEditorInspector.findDocument()!; + + // Place the caret at "bold|". + await tester.placeCaretInParagraph(document.first.id, 6); + + // Type at an offset that should expand the bold attribution. + await tester.typeImeText("er"); + + // Place the caret at "text|". + await tester.placeCaretInParagraph(document.first.id, 13); + + // Type at an offset that shouldn't expand any attributions. + await tester.typeImeText("."); + + // Ensure the bold attribution was applied to the inserted text. + expect(document, equalsMarkdown("A **bolder** text.")); + }); + + testWidgetsOnAllPlatforms("and typing at the middle of the attributed text", (tester) async { + await tester // + .createDocument() + .fromMarkdown("A **bld** text") + .withInputSource(TextInputSource.ime) + .pump(); + + final document = SuperEditorInspector.findDocument()!; + + // Place the caret at b|ld. + await tester.placeCaretInParagraph(document.first.id, 3); + + // Type at an offset that should expand the bold attribution. + await tester.typeImeText("o"); + + // Place the caret at A|. + await tester.placeCaretInParagraph(document.first.id, 1); + + // Type at an offset that shouldn't expand any attributions. + await tester.typeImeText("nother"); + + // Ensure the bold attribution was applied to the inserted text. + expect(document, equalsMarkdown("Another **bold** text")); + }); + + testWidgetsOnAllPlatforms("and typing at the middle of a link", (tester) async { + await tester // + .createDocument() + .fromMarkdown("[This is a link](https://google.com) to google") + .withInputSource(TextInputSource.ime) + .pump(); + + final document = SuperEditorInspector.findDocument()!; + + // Place the caret at This is a|. + await tester.placeCaretInParagraph(document.first.id, 9); + + // Type at an offset that should expand the link attribution. + await tester.typeImeText("nother"); + + // Place the caret at google|. + await tester.placeCaretInParagraph(document.first.id, 30); + + // Type at an offset that shouldn't expand any attributions. + await tester.typeImeText("."); + + // Ensure the link attribution was applied to the inserted text. + expect(document, equalsMarkdown("[This is another link](https://google.com) to google.")); + }); + }); + + group("when selecting by the keyboard", () { + testWidgetsOnAllPlatforms("and typing at the end of the attributed text", (tester) async { + await tester // + .createDocument() + .fromMarkdown("A **bold** text") + .withInputSource(TextInputSource.ime) + .pump(); + + final document = SuperEditorInspector.findDocument()!; + + // Place the caret at |text. + await tester.placeCaretInParagraph(document.first.id, 7); + + // Press left arrow to place the caret at bold|. + await tester.pressLeftArrow(); + + // Type at an offset that should expand the bold attribution. + await tester.typeImeText("er"); + + // Press right arrow to place the caret at |text. + await tester.pressRightArrow(); + + // Type at an offset that shouldn't expand any attributions. + await tester.typeImeText("new "); + + // Ensure the bold attribution was applied to the inserted text. + expect(document, equalsMarkdown("A **bolder** new text")); + }); + + testWidgetsOnAllPlatforms("and typing at the middle of the attributed text", (tester) async { + await tester // + .createDocument() + .fromMarkdown("A **bld** text") + .withInputSource(TextInputSource.ime) + .pump(); + + final document = SuperEditorInspector.findDocument()!; + + // Place the caret at A|. + await tester.placeCaretInParagraph(document.first.id, 1); + + // Press right arrow twice to place the caret at b|ld. + await tester.pressRightArrow(); + await tester.pressRightArrow(); + + // Type at an offset that should expand the bold attribution. + await tester.typeImeText("o"); + + // Pres right arrow three times to place the caret at bold |text. + await tester.pressRightArrow(); + await tester.pressRightArrow(); + await tester.pressRightArrow(); + + // Type at an offset that shouldn't expand any attributions. + await tester.typeImeText("new "); + + // Ensure the bold attribution was applied to the inserted text. + expect(document, equalsMarkdown("A **bold** new text")); + }); + + testWidgetsOnAllPlatforms("and typing at the middle of a link", (tester) async { + await tester // + .createDocument() + .fromMarkdown("[This is a link](https://google.com) to google") + .withInputSource(TextInputSource.ime) + .pump(); + + final document = SuperEditorInspector.findDocument()!; + + // Place the caret at |to google. + await tester.placeCaretInParagraph(document.first.id, 15); + + // Press left arrow twice to place caret at lin|k. + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + + // Typing at this offset should expand the link attribution. + await tester.typeImeText("n"); + + // Press right arrow twice to place caret at |to google. + await tester.pressRightArrow(); + await tester.pressRightArrow(); + + // Typing at this offset shouldn't expand any attributions. + await tester.typeImeText("pointing "); + + // Ensure the link attribution was applied to the inserted text. + expect(document, equalsMarkdown("[This is a linnk](https://google.com) pointing to google")); + }); + }); + + group("when collapsing the selection", () { + testWidgetsOnMac("by keyboard and typing at the end of the attributed text", (tester) async { + await tester // + .createDocument() + .fromMarkdown("A bold text") + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Double tap to select the word "bold". + await tester.doubleTapInParagraph(doc.first.id, 4); + + // Press command + b to apply bold attribution to the selected text. + await tester.pressCmdB(); + + // Press right arrow to place the caret at the end of the word "bold". + await tester.pressRightArrow(); + + // Type "er" so "bold" becomes "bolder". + await tester.typeImeText("er"); + + // Ensure the bold attribution was applied to the inserted text. + expect(doc, equalsMarkdown("A **bolder** text")); + }); + + testWidgetsOnMac("by tapping and typing at the end of the attributed text", (tester) async { + await tester // + .createDocument() + .fromMarkdown("A bold text") + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Double tap to select the word "bold". + await tester.doubleTapInParagraph(doc.first.id, 4); + + // Press command + b to apply bold attribution to the selected text. + await tester.pressCmdB(); + + // Place the caret at "bold|". + await tester.placeCaretInParagraph(doc.first.id, 6); + + // Type "er" so "bold" becomes "bolder". + await tester.typeImeText("er"); + + // Ensure the bold attribution was applied to the inserted text. + expect(doc, equalsMarkdown("A **bolder** text")); + }); + }); + + group("when a single node is selected", () { + testWidgetsOnAllPlatforms("toggles attribution throughout a node", (tester) async { + final context = await tester // + .createDocument() + .withCustomContent( + singleParagraphDocShortText(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure markers are empty. + expect( + SuperEditorInspector.findTextInComponent("1").spans.markers, + isEmpty, + ); + + var firstNode = document.getNodeById("1")!.asTextNode; + editor.toggleAttributionsForDocumentSelection( + firstNode.selectionBetween(0, firstNode.text.length), + {boldAttribution}, + ); + + // Ensure attribution was applied throughout the selection. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**", + ), + ); + + firstNode = document.getNodeById("1")!.asTextNode; + editor.toggleAttributionsForDocumentSelection( + firstNode.selectionBetween(0, firstNode.text.length), + {boldAttribution}, + ); + + // Ensure bold attribution was removed from the selection. + expect( + SuperEditorInspector.findTextInComponent("1").spans.markers, + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("toggles attribution on a partial node selection", (tester) async { + final context = await tester // + .createDocument() + .withCustomContent( + singleParagraphDocShortText(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + var firstNode = document.getNodeById("1")! as TextNode; + + // Ensure markers are empty. + expect( + firstNode.text.spans.markers, + isEmpty, + ); + + editor.toggleAttributionsForDocumentSelection( + firstNode.selectionBetween(0, 17), + {boldAttribution}, + ); + + // Ensure attribution was applied to the selection. + expect( + document, + equalsMarkdown( + "**This is the first** node in a document.", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + firstNode.selectionBetween(0, 17), + {boldAttribution}, + ); + + // Ensure bold attribution was removed from the selection. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers, + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("toggles an attribution within a sub-range of an existing same attribution", + (tester) async { + final context = await tester // + .createDocument() + .withCustomContent( + singleParagraphDocAllBold(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure bold attribution is present. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**", + ), + ); + + var firstNode = document.getNodeById("1")!.asTextNode; + + editor.toggleAttributionsForDocumentSelection( + firstNode.selectionBetween(0, 17), + {boldAttribution}, + ); + + // Ensure bold attribution is removed from the selection. + expect( + document, + equalsMarkdown( + "This is the first** node in a document.**", + ), + ); + + firstNode = document.getNodeById("1")!.asTextNode; + editor.toggleAttributionsForDocumentSelection( + firstNode.selectionBetween(0, 17), + {boldAttribution}, + ); + + // Ensure bold attribution is applied throughout the node. + expect( + document, + equalsMarkdown( + "**This is the first**** node in a document.**", + ), + ); + }); + + testWidgetsOnAllPlatforms("toggles a different attribution within a sub-range of another existing attribution", + (tester) async { + final context = await tester // + .createDocument() + .withCustomContent( + singleParagraphDocAllBold(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure bold attribution is present. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**", + ), + ); + + var firstNode = document.getNodeById("1")!.asTextNode; + + editor.toggleAttributionsForDocumentSelection( + firstNode.selectionBetween(0, 17), + {italicsAttribution}, + ); + + // Ensure italic attribution is applied to the selection. + expect( + document, + equalsMarkdown( + "***This is the first* node in a document.**", + ), + ); + + firstNode = document.getNodeById("1")!.asTextNode; + editor.toggleAttributionsForDocumentSelection( + firstNode.selectionBetween(0, 17), + {italicsAttribution}, + ); + + // Ensure bold attribution is applied throughout the node. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**", + ), + ); + }); + + testWidgetsOnAllPlatforms("toggles multiple attributions throughout a node", (tester) async { + final context = await tester // + .createDocument() + .withCustomContent( + singleParagraphDocShortText(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + var firstNode = document.getNodeById("1")!.asTextNode; + + // Ensure markers are empty. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers, + isEmpty, + ); + + editor.toggleAttributionsForDocumentSelection( + firstNode.selectionBetween(0, firstNode.text.length), + {italicsAttribution, boldAttribution}, + ); + + // Ensure both bold and italic attributions were applied throughout the node. + expect( + document, + equalsMarkdown( + "***This is the first node in a document.***", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + firstNode.selectionBetween(0, firstNode.text.length), + {boldAttribution, italicsAttribution}, + ); + + // Ensure both bold and italic attributions are removed from the node. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers, + isEmpty, + ); + }); + }); + + group("when multiple nodes are selected", () { + testWidgetsOnAllPlatforms("toggles attribution throughout multiple nodes", (tester) async { + final context = await tester // + .createDocument() + .withCustomContent( + twoParagraphDoc(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure markers are empty for both nodes. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers.isEmpty && + document.getNodeById("2")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution is applied throughout both nodes. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**\n\n**This is the second node in a document.**", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution was removed from both nodes. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers.isEmpty && + document.getNodeById("2")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + }); + + testWidgetsOnAllPlatforms( + "toggles an attribution across nodes with the attribution applied throughout first node", (tester) async { + final context = await tester // + .createDocument() + .withCustomContent( + _paragraphFullBoldThenParagraph(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure bold attribution is applied throughout the first node. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**\n\nThis is the second node in a document.", + ), + ); + + editor.execute([ + ToggleTextAttributionsRequest( + documentRange: DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + attributions: {boldAttribution}, + ) + ]); + + // Ensure bold attribution is applied throughout both nodes. + // + // The toggled attribution already existed across the selection. In + // such cases, the attribution is applied throughout the selection without removing it from + // any of the node selections that already have it. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**\n\n**This is the second node in a document.**", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution was removed from both nodes. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + expect( + document.getNodeById("2")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + }); + + testWidgetsOnAllPlatforms( + "toggles an attribution across nodes with the attribution applied partially within first node", + (tester) async { + final context = await tester // + .createDocument() + .withCustomContent( + _paragraphPartiallyBoldThenParagraph(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure bold attribution is applied partially to the first node. + expect( + document, + equalsMarkdown( + "**This is the first** node in a document.\n\nThis is the second node in a document.", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution is applied throughout the both nodes. + // + // The toggled attribution already existed across the selection.In + // such cases, the attribution is applied throughout the selection without removing it from + // any of the node selections that already have it. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**\n\n**This is the second node in a document.**", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution was removed from both nodes. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + expect( + document.getNodeById("2")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + }); + + testWidgetsOnAllPlatforms( + "toggles an attribution when selection spans multiple nodes and starts at the end of the first selected node", + (tester) async { + // Test situations where the selection starts after the last character of the first selected node. See #1948 + // for more information. + + final context = await tester // + .createDocument() + .fromMarkdown("First node\n\nSecond node") + .pump(); + + final editor = context.editor; + final document = context.document; + + // Apply the bold attribution, starting after the last character of the first node. + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeAt(0)!.endDocumentPosition, + extent: document.getNodeAt(1)!.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution is applied only to the second node. Since the selection starts at the + // end of the first node, there's no text there to apply the attribution to. + expect( + document, + equalsMarkdown( + "First node\n\n**Second node**", + ), + ); + + // Remove the bold attribution, starting after the last character of the first node. + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeAt(0)!.endDocumentPosition, + extent: document.getNodeAt(1)!.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution was removed. + expect(document.getNodeAt(1)!.asTextNode.text.spans.markers.isEmpty, true); + }); + + testWidgetsOnAllPlatforms( + "toggles an attribution when selection spans multiple nodes and ends at the beginning of the last selected node", + (tester) async { + // Test situations where the selection ends before the first character of the last selected node. See #1948 + // for more information. + + final context = await tester // + .createDocument() + .fromMarkdown("First node\n\nSecond node") + .pump(); + + final editor = context.editor; + final document = context.document; + + // Apply the bold attribution, with a selection that start at the beginning of the first node and ends + // before the first character of the second node. + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeAt(0)!.beginningDocumentPosition, + extent: document.getNodeAt(1)!.beginningDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution is applied only to the first node. Since the selection ends before the first + // character of the second node, there's no text there to apply the attribution to. + expect( + document, + equalsMarkdown( + "**First node**\n\nSecond node", + ), + ); + + // Remove the bold attribution, with a selection that start at the beginning of the first node and ends + // before the first character of the second node. + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeAt(0)!.beginningDocumentPosition, + extent: document.getNodeAt(1)!.beginningDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution was removed. + final secondNode = document.getNodeAt(1)!.asTextNode; + expect(secondNode.text.spans.markers.isEmpty, true); + }); + + testWidgetsOnAllPlatforms( + "toggles an attribution across nodes with the attribution applied throughout and partially within first and second node respectively", + (tester) async { + final TestDocumentContext context = await tester // + .createDocument() + .withCustomContent( + _paragraphFullyBoldThenParagraphPartiallyBold(), + ) + .pump(); + + final Editor editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure bold attribution is applied partially to first node and + // throughout the second node. + expect( + document, + equalsMarkdown( + "**This is the first** node in a document.\n\n**This is the second node in a document.**", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.asTextNode.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution is applied throughout the both nodes. + // + // The toggled attribution already existed across the selection. In + // such cases, the attribution is applied throughout the selection without removing it from + // any of the node selections that already have it. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**\n\n**This is the second node in a document.**", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.asTextNode.beginningDocumentPosition, + extent: document.getNodeById("2")!.asTextNode.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution was removed from both nodes. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers.isEmpty && + document.getNodeById("2")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + }); + + testWidgetsOnAllPlatforms( + "toggles an attribution across nodes with the attribution applied partially within all nodes", + (tester) async { + final TestDocumentContext context = await tester // + .createDocument() + .withCustomContent( + _paragraphPartiallyBoldThenParagraphPartiallyBold(), + ) + .pump(); + + final Editor editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure bold attribution is applied partially across both nodes. + expect( + document, + equalsMarkdown( + "**This is the first** node in a document.\n\n**This is the second** node in a document.", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution is applied throughout the both nodes. + // + // The toggled attribution already existed across the selection. In + // such cases, the attribution is applied throughout the selection without removing it from + // any of the node selections that already have it. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**\n\n**This is the second node in a document.**", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + {boldAttribution}, + ); + + // Ensure bold attribution was removed from both nodes. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers.isEmpty && + document.getNodeById("2")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + }); + + testWidgetsOnAllPlatforms( + "toggles a different attribution across nodes with an existing attribution applied throughout them", + (tester) async { + final TestDocumentContext context = await tester // + .createDocument() + .withCustomContent( + twoParagraphDocAllBold(), + ) + .pump(); + + final Editor editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure bold attribution is applied throughout both nodes. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**\n\n**This is the second node in a document.**", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + {italicsAttribution}, + ); + + // Ensure both bold and italic attributions were applied throughout the selection. + expect( + document, + equalsMarkdown( + "***This is the first node in a document.***\n\n***This is the second node in a document.***", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + {italicsAttribution}, + ); + + // Ensure italic attribution was removed from both nodes. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**\n\n**This is the second node in a document.**", + ), + ); + }); + + testWidgetsOnAllPlatforms( + "toggles a different attribution partially across nodes with an existing attribution applied throughout them", + (tester) async { + final TestDocumentContext context = await tester // + .createDocument() + .withCustomContent( + twoParagraphDocAllBold(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure bold attribution is applied throughout the selection. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**\n\n**This is the second node in a document.**", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.asTextNode.positionAt(18), + ), + {italicsAttribution}, + ); + + // Ensure both bold and italic attributions were applied throughout + // the selection. + expect( + document, + equalsMarkdown( + "***This is the first node in a document.***\n\n***This is the second* node in a document.**", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.asTextNode.positionAt(18), + ), + {italicsAttribution}, + ); + + // Ensure italic attribution was removed from the selection while keeping the bold + // attribution. + expect( + document, + equalsMarkdown( + "**This is the first node in a document.**\n\n**This is the second node in a document.**", + ), + ); + }); + + testWidgetsOnAllPlatforms("toggles multiple attributions throughout multiple nodes", (tester) async { + final TestDocumentContext context = await tester // + .createDocument() + .withCustomContent( + twoParagraphDoc(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure markers are empty for both nodes. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers.isEmpty && + document.getNodeById("2")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + { + italicsAttribution, + boldAttribution, + }, + ); + + // Ensure both bold and italic attributions were applied throughout the selection. + expect( + document, + equalsMarkdown( + "***This is the first node in a document.***\n\n***This is the second node in a document.***", + ), + ); + + // Toggle bold attribution for both nodes. + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.beginningDocumentPosition, + extent: document.getNodeById("2")!.endDocumentPosition, + ), + {boldAttribution, italicsAttribution}, + ); + + // Ensure markers are empty for both nodes. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers.isEmpty && + document.getNodeById("2")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + }); + + testWidgetsOnAllPlatforms( + "toggles attribution for a selection going halfway from first node and halfway within second node", + (tester) async { + final TestDocumentContext context = await tester // + .createDocument() + .withCustomContent( + twoParagraphDoc(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure markers are empty for both nodes. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers.isEmpty && + document.getNodeById("2")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.asTextNode.positionAt(18), + extent: document.getNodeById("2")!.asTextNode.positionAt(18), + ), + {boldAttribution}, + ); + + // Ensure bold attribution was applied. + expect( + document, + equalsMarkdown( + "This is the first **node in a document.**\n\n**This is the second** node in a document.", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.asTextNode.positionAt(18), + extent: document.getNodeById("2")!.asTextNode.positionAt(18), + ), + {boldAttribution}, + ); + + // Ensure markers are empty for both nodes. + expect( + document.getNodeById("1")!.asTextNode.text.spans.markers.isEmpty && + document.getNodeById("2")!.asTextNode.text.spans.markers.isEmpty, + true, + ); + }); + + testWidgetsOnAllPlatforms( + "toggles attribution for a selection going halfway in first node till the halfway into the third node", + (tester) async { + final TestDocumentContext context = await tester // + .createDocument() + .withCustomContent( + threeParagraphDoc(), + ) + .pump(); + + final editor = context.editor; + final document = SuperEditorInspector.findDocument()!; + + // Ensure no attributions are present. + expect( + document, + equalsMarkdown( + "This is the first node in a document.\n\nThis is the second node in a document.\n\nThis is the third node in a document.", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.asTextNode.positionAt(18), + extent: document.getNodeById("3")!.asTextNode.positionAt(18), + ), + {boldAttribution}, + ); + + // Ensure bold attributions were applied. + expect( + document, + equalsMarkdown( + "This is the first **node in a document.**\n\n**This is the second node in a document.**\n\n**This is the third **node in a document.", + ), + ); + + editor.toggleAttributionsForDocumentSelection( + DocumentSelection( + base: document.getNodeById("1")!.asTextNode.positionAt(18), + extent: document.getNodeById("3")!.asTextNode.positionAt(18), + ), + {boldAttribution}, + ); + + // Ensure no attributions are present. + expect( + document, + equalsMarkdown( + "This is the first node in a document.\n\nThis is the second node in a document.\n\nThis is the third node in a document.", + ), + ); + }); + }); + + group("applies color attributions", () { + testWidgetsOnAllPlatforms("to full text", (tester) async { + await tester // + .createDocument() + .withCustomContent( + singleParagraphFullColor(), + ) + .pump(); + + // Ensure the text is colored orange. + expect( + SuperEditorInspector.findRichTextInParagraph("1") + .getSpanForPosition( + const TextPosition(offset: 0), + ) + ?.style + ?.color, + Colors.orange, + ); + expect( + SuperEditorInspector.findRichTextInParagraph("1") + .getSpanForPosition( + TextPosition(offset: SuperEditorInspector.findTextInComponent("1").length - 1), + ) + ?.style + ?.color, + Colors.orange, + ); + }); + + testWidgetsOnAllPlatforms("to partial text", (tester) async { + await tester // + .createDocument() + .withCustomContent( + singleParagraphWithPartialColor(), + ) + .pump(); + + // Ensure the first span is colored black. + expect( + SuperEditorInspector.findRichTextInParagraph("1") + .getSpanForPosition(const TextPosition(offset: 0))! + .style! + .color, + Colors.black, + ); + + // Ensure the second span is colored orange. + expect( + SuperEditorInspector.findRichTextInParagraph("1") + .getSpanForPosition(const TextPosition(offset: 5))! + .style! + .color, + Colors.orange, + ); + }); + }); + + group("doesn't apply attributions", () { + testWidgetsOnAllPlatforms("when typing before the start of the attributed text", (tester) async { + await tester // + .createDocument() + .fromMarkdown("A **bold** text") + .withInputSource(TextInputSource.ime) + .pump(); + + final document = SuperEditorInspector.findDocument()!; + + // Place the caret at |bold. + await tester.placeCaretInParagraph(document.first.id, 2); + + // Type some letters. + await tester.typeImeText("very "); + + // Ensure the bold attribution wasn't applied to the inserted text. + expect(document, equalsMarkdown("A very **bold** text")); + }); + }); + + group("doesn't clear attributions", () { + testWidgetsOnAllPlatforms("when changing the selection affinity", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown("This text should be") + .withInputSource(TextInputSource.ime) + .pump(); + + final document = context.findEditContext().document; + final composer = context.findEditContext().composer; + + // Place the caret at the end of the paragraph. + await tester.placeCaretInParagraph(document.first.id, 19); + + // Toggle the bold attribution. + composer.preferences.toggleStyle(boldAttribution); + await tester.pump(); + + // Ensure we have an upstream selection. + expect((composer.selection!.extent.nodePosition as TextNodePosition).affinity, TextAffinity.upstream); + + // Simulate the IME sending us a selection at the same offset + // but with a different affinity. + await tester.ime.sendDeltas( + [ + const TextEditingDeltaNonTextUpdate( + oldText: ". This text should be", + selection: TextSelection.collapsed(offset: 21, affinity: TextAffinity.downstream), + composing: TextRange.empty, + ), + ], + getter: imeClientGetter, + ); + + // Type text at the end of the paragraph. + await tester.typeImeText(" bold"); + + // Ensure the bold attribution is applied. + expect(document, equalsMarkdown("This text should be** bold**")); + }); + }); + + testWidgetsOnArbitraryDesktop( + "and reports attribution change events when an attribution is added, removed, and toggled", (tester) async { + final context = await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + // Select the first word. + await tester.doubleTapInParagraph("1", 1); + + // Listen for change events. + final editor = context.editor; + List? changes; + editor.addListener(FunctionalEditListener((List changeList) { + changes = changeList; + })); + + // Apply bold attribution. + await tester.pressCmdB(); + + // Ensure that the change event includes the added attribution. + expect(changes, isNotNull); + expect( + changes, + [ + DocumentEdit( + AttributionChangeEvent( + nodeId: "1", + change: AttributionChange.added, + attributions: {boldAttribution}, + range: const SpanRange(0, 4), + ), + ), + ], + ); + + // Expand the selection to include the second word. + await tester.pressShiftAltRightArrow(); + + // Toggle bold, which should add bold only to the second word. + await tester.pressCmdB(); + + // Ensure that the bold change is reported across the entire selected range. + // + // NOTE: There's an argument to be made that this change event should only + // include the range of text that was previously not bold, and now is + // bold. E.g., given the text "Lorem ipsum", because "Lorem" is already + // bold, the event should report " ipsum" as adding bold. I chose not to + // worry about that distinction because at the moment it doesn't seem that + // we need the distinction, and it would require more rework to achieve that + // distinction. If it's eventually needed, it would be reasonable to + // implement that approach, instead. + expect( + changes, + [ + DocumentEdit( + AttributionChangeEvent( + nodeId: "1", + change: AttributionChange.added, + attributions: {boldAttribution}, + range: const SpanRange(0, 10), + ), + ), + ], + ); + + // Remove the bold attribution by toggling bold again. + await tester.pressCmdB(); + + // Ensure that we received a removal change event. + expect( + changes, + [ + DocumentEdit( + AttributionChangeEvent( + nodeId: "1", + change: AttributionChange.removed, + attributions: {boldAttribution}, + range: const SpanRange(0, 10), + ), + ), + ], + ); + }); + }); + + testWidgetsOnArbitraryDesktop('overwrites spans with different colors', (tester) async { + // Pump an editor with a single paragraph with blue color across the entire paragraph. + final testContext = await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'blue orange pink', + _createAttributedSpansForAttribution( + attribution: const ColorAttribution(Colors.blue), + startOffset: 0, + endOffset: 15, + ), + ), + ) + ], + ), + ) + .pump(); + + // Apply orange color to the word "orange". + testContext.editor.execute([ + AddTextAttributionsRequest( + documentRange: _creatSingleNodeTextRange('1', 5, 11), + attributions: {const ColorAttribution(Colors.orange)}, + ), + ]); + await tester.pump(); + + // Apply pink color to the word "pink" by toggling the attribution. + testContext.editor.execute([ + ToggleTextAttributionsRequest( + documentRange: _creatSingleNodeTextRange('1', 11, 16), + attributions: {const ColorAttribution(Colors.pink)}, + ), + ]); + await tester.pump(); + + // Ensure the spans were overwritten. + expect( + SuperEditorInspector.findTextInComponent('1').spans, + AttributedSpans( + attributions: [ + ..._createSpanMarkersForAttribution( + attribution: const ColorAttribution(Colors.blue), + startOffset: 0, + endOffset: 4, + ), + ..._createSpanMarkersForAttribution( + attribution: const ColorAttribution(Colors.orange), + startOffset: 5, + endOffset: 10, + ), + ..._createSpanMarkersForAttribution( + attribution: const ColorAttribution(Colors.pink), + startOffset: 11, + endOffset: 15, + ), + ], + ), + ); + }); + + testWidgetsOnArbitraryDesktop('overwrites spans with different background colors', (tester) async { + // Pump an editor with a single paragraph with blue background color across the entire paragraph. + final testContext = await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'blue orange pink', + _createAttributedSpansForAttribution( + attribution: const BackgroundColorAttribution(Colors.blue), + startOffset: 0, + endOffset: 15, + ), + ), + ) + ], + ), + ) + .pump(); + + // Apply orange color to the word "orange". + testContext.editor.execute([ + AddTextAttributionsRequest( + documentRange: _creatSingleNodeTextRange('1', 5, 11), + attributions: {const BackgroundColorAttribution(Colors.orange)}, + ), + ]); + await tester.pump(); + + // Apply pink color to the word "pink" by toggling the attribution. + testContext.editor.execute([ + ToggleTextAttributionsRequest( + documentRange: _creatSingleNodeTextRange('1', 11, 16), + attributions: {const BackgroundColorAttribution(Colors.pink)}, + ), + ]); + await tester.pump(); + + // Ensure the spans were overwritten. + expect( + SuperEditorInspector.findTextInComponent('1').spans, + AttributedSpans( + attributions: [ + ..._createSpanMarkersForAttribution( + attribution: const BackgroundColorAttribution(Colors.blue), + startOffset: 0, + endOffset: 4, + ), + ..._createSpanMarkersForAttribution( + attribution: const BackgroundColorAttribution(Colors.orange), + startOffset: 5, + endOffset: 10, + ), + ..._createSpanMarkersForAttribution( + attribution: const BackgroundColorAttribution(Colors.pink), + startOffset: 11, + endOffset: 15, + ), + ], + ), + ); + }); + + testWidgetsOnArbitraryDesktop('overwrites spans with different font sizes', (tester) async { + // Pump an editor with a single paragraph with 16px font size across the entire paragraph. + final testContext = await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + '16px 18px 14px', + _createAttributedSpansForAttribution( + attribution: const FontSizeAttribution(16), + startOffset: 0, + endOffset: 13, + ), + ), + ) + ], + ), + ) + .pump(); + + // Apply 18px font size to the text "18px". + testContext.editor.execute([ + AddTextAttributionsRequest( + documentRange: _creatSingleNodeTextRange('1', 5, 9), + attributions: {const FontSizeAttribution(18)}, + ), + ]); + await tester.pump(); + + // Apply 14px color to the text "14px" by toggling the attribution. + testContext.editor.execute([ + ToggleTextAttributionsRequest( + documentRange: _creatSingleNodeTextRange('1', 9, 14), + attributions: {const FontSizeAttribution(14)}, + ), + ]); + await tester.pump(); + + // Ensure the spans were overwritten. + expect( + SuperEditorInspector.findTextInComponent('1').spans, + AttributedSpans( + attributions: [ + ..._createSpanMarkersForAttribution( + attribution: const FontSizeAttribution(16), + startOffset: 0, + endOffset: 4, + ), + ..._createSpanMarkersForAttribution( + attribution: const FontSizeAttribution(18), + startOffset: 5, + endOffset: 8, + ), + ..._createSpanMarkersForAttribution( + attribution: const FontSizeAttribution(14), + startOffset: 9, + endOffset: 13, + ), + ], + ), + ); + }); + + testWidgetsOnArbitraryDesktop('adds and removes attributions with placeholders', (tester) async { + // A test that locks down a fix for bug: https://github.com/superlistapp/super_editor/issues/2776 + // The problem was a behavior in the command that was incorrectly copying the `AttributedText` + // when the `AttributedText` has placeholders. + final testContext = await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'this [] orange [] that', + null, + { + 6: 'first', + 17: 'second', + }, + ), + ) + ], + ), + ) + .pump(); + + // Apply orange color to the word "orange". + testContext.editor.execute([ + AddTextAttributionsRequest( + documentRange: _creatSingleNodeTextRange('1', 5, 11), + attributions: {const ColorAttribution(Colors.orange)}, + ), + ]); + await tester.pump(); + + // Ensure the plain text value didn't change, the attribution was applied, and the placeholders remain. + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(includePlaceholders: false), + 'this [] orange [] that', + ); + + expect( + SuperEditorInspector.findTextInComponent('1').spans, + AttributedSpans( + attributions: [ + ..._createSpanMarkersForAttribution( + attribution: const ColorAttribution(Colors.orange), + startOffset: 5, + endOffset: 10, + ), + ], + ), + ); + + expect( + SuperEditorInspector.findTextInComponent('1').placeholders, + { + 6: 'first', + 17: 'second', + }, + ); + + // Now test the removal command by removing the attribution. + testContext.editor.execute([ + RemoveTextAttributionsRequest( + documentRange: _creatSingleNodeTextRange('1', 5, 11), + attributions: {const ColorAttribution(Colors.orange)}, + ), + ]); + await tester.pump(); + + // Ensure the plain text value didn't change, the attribution was removed, and the placeholders remain. + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(includePlaceholders: false), + 'this [] orange [] that', + ); + + expect( + SuperEditorInspector.findTextInComponent('1').spans, + AttributedSpans(), + ); + + expect( + SuperEditorInspector.findTextInComponent('1').placeholders, + { + 6: 'first', + 17: 'second', + }, + ); + }); + }); +} + +MutableDocument _paragraphFullBoldThenParagraph() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This is the first node in a document.", + _createAttributedSpansForAttribution( + attribution: boldAttribution, + startOffset: 0, + endOffset: 36, + ), + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + "This is the second node in a document.", + ), + ), + ], + ); + +MutableDocument _paragraphPartiallyBoldThenParagraph() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This is the first node in a document.", + _createAttributedSpansForAttribution( + attribution: boldAttribution, + startOffset: 0, + endOffset: 16, + ), + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + "This is the second node in a document.", + ), + ), + ], + ); + +MutableDocument _paragraphFullyBoldThenParagraphPartiallyBold() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This is the first node in a document.", + _createAttributedSpansForAttribution( + attribution: boldAttribution, + startOffset: 0, + endOffset: 16, + ), + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + "This is the second node in a document.", + _createAttributedSpansForAttribution( + attribution: boldAttribution, + startOffset: 0, + endOffset: 37, + ), + ), + ), + ], + ); + +MutableDocument _paragraphPartiallyBoldThenParagraphPartiallyBold() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This is the first node in a document.", + _createAttributedSpansForAttribution( + attribution: boldAttribution, + startOffset: 0, + endOffset: 16, + ), + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + "This is the second node in a document.", + _createAttributedSpansForAttribution( + attribution: boldAttribution, + startOffset: 0, + endOffset: 17, + ), + ), + ), + ], + ); + +extension _GetDocumentPosition on DocumentNode { + DocumentPosition get beginningDocumentPosition { + return DocumentPosition( + nodeId: id, + nodePosition: beginningPosition, + ); + } + + DocumentPosition get endDocumentPosition { + return DocumentPosition( + nodeId: id, + nodePosition: endPosition, + ); + } +} + +extension _ToggleAttributions on Editor { + /// Toggles given [attributions] for the [documentSelection]. + void toggleAttributionsForDocumentSelection( + DocumentSelection documentSelection, + Set attributions, + ) { + return execute([ + ToggleTextAttributionsRequest( + documentRange: documentSelection, + attributions: attributions, + ) + ]); + } +} + +/// Creates an [AttributedSpans] for the [attribution] starting at [startOffset] +/// and ending at [endOffset]. +AttributedSpans _createAttributedSpansForAttribution({ + required Attribution attribution, + required int startOffset, + required int endOffset, +}) { + return AttributedSpans( + attributions: [ + SpanMarker( + attribution: attribution, + offset: startOffset, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: attribution, + offset: endOffset, + markerType: SpanMarkerType.end, + ), + ], + ); +} + +/// Creates start and end markers for the [attribution], starting at [startOffset] +/// and ending at [endOffset]. +List _createSpanMarkersForAttribution({ + required Attribution attribution, + required int startOffset, + required int endOffset, +}) { + return [ + SpanMarker( + attribution: attribution, + offset: startOffset, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: attribution, + offset: endOffset, + markerType: SpanMarkerType.end, + ), + ]; +} + +/// Creates a [DocumentRange] that starts at node [nodeId] at [start] and +/// ends at [nodeId] at [end]. +DocumentRange _creatSingleNodeTextRange(String nodeId, int start, int end) { + return DocumentRange( + start: DocumentPosition( + nodeId: nodeId, + nodePosition: TextNodePosition(offset: start), + ), + end: DocumentPosition( + nodeId: nodeId, + nodePosition: TextNodePosition(offset: end), + ), + ); +} diff --git a/super_editor/test/src/default_editor/caret_test.dart b/super_editor/test/super_editor/supereditor_caret_test.dart similarity index 52% rename from super_editor/test/src/default_editor/caret_test.dart rename to super_editor/test/super_editor/supereditor_caret_test.dart index 4dafa188b9..2265f32d78 100644 --- a/super_editor/test/src/default_editor/caret_test.dart +++ b/super_editor/test/super_editor/supereditor_caret_test.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/src/default_editor/document_gestures_touch_android.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; - -import '../../super_editor/document_test_tools.dart'; -import '../../test_tools.dart'; +import 'package:super_text_layout/super_text_layout.dart'; void main() { group("SuperEditor", () { @@ -13,7 +11,8 @@ void main() { // text position sits at a location that should move to a different line when the available space // is reduced. const textPosition = TextPosition(offset: 46); - final tapPosition = DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: textPosition.offset)); + final documentPosition = DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: textPosition.offset)); + final tapPosition = documentPosition; group('text affinity', () { // Use a relatively small size to make sure we have a line break. @@ -41,19 +40,21 @@ void main() { final lineBreakOffset = SuperEditorInspector.findOffsetOfLineBreak('1'); // Find the coordinates of the caret at the end of the first line (line break offset w/ upstream affinity). - await tester.pump(kTapTimeout * 2); // Simulate a pause to avoid a double tap. await tester.placeCaretInParagraph('1', lineBreakOffset, affinity: TextAffinity.upstream); final upstreamCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); - // Find the coordinates of the caret at the start of the second line (line break offset w/ downstream affinity). - await tester.pump(kTapTimeout * 2); // Simulate a pause to avoid a double tap. - await tester.placeCaretInParagraph('1', lineBreakOffset, affinity: TextAffinity.downstream); - final downstreamCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); - // The upstream caret should be at the same y and greater x than the caret at the start of the paragraph. expect(upstreamCaretOffset.dx, greaterThan(startOfFirstLineCaretOffset.dx + xExpectBuffer)); expect(upstreamCaretOffset.dy, startOfFirstLineCaretOffset.dy); + // Tap on another character, because tapping on the same character shows the toolbar + // instead of changing the selection. + await tester.placeCaretInParagraph('1', 3); + + // Find the coordinates of the caret at the start of the second line (line break offset w/ downstream affinity). + await tester.placeCaretInParagraph('1', lineBreakOffset, affinity: TextAffinity.downstream); + final downstreamCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); + // The downstream caret should be at the same x and greater y than the caret at the start of the paragraph. expect(downstreamCaretOffset.dx, startOfFirstLineCaretOffset.dx); expect(downstreamCaretOffset.dy, greaterThan(startOfFirstLineCaretOffset.dy + yExpectBuffer)); @@ -76,8 +77,11 @@ void main() { final downstreamCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); final downstreamSelection = SuperEditorInspector.findDocumentSelection(); + // Tap on another character, because tapping on the same character shows the toolbar + // instead of changing the selection. + await tester.placeCaretInParagraph('1', 0); + // Place the caret at the same offset but with an upstream affinity. - await tester.pump(kTapTimeout * 2); // Simulate a pause to avoid a double tap. await tester.placeCaretInParagraph('1', textOffset, affinity: TextAffinity.upstream); final upstreamCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); final upstreamSelection = SuperEditorInspector.findDocumentSelection(); @@ -94,76 +98,66 @@ void main() { const screenSizeBigger = Size(1000.0, 400.0); const screenSizeSmaller = Size(250.0, 400.0); - testWidgets('moves caret to next line when available width contracts', (WidgetTester tester) async { - tester.binding.window - ..devicePixelRatioTestValue = 1.0 + testWidgetsOnDesktop('moves caret to next line when available width contracts', (WidgetTester tester) async { + tester.view + ..devicePixelRatio = 1.0 ..platformDispatcher.textScaleFactorTestValue = 1.0 - ..physicalSizeTestValue = screenSizeBigger; + ..physicalSize = screenSizeBigger; final docKey = GlobalKey(); - await tester.pumpWidget( - _createTestApp( - gestureMode: DocumentGestureMode.mouse, - docKey: docKey, - ), + await _pumpScaffold( + tester, + gestureMode: DocumentGestureMode.mouse, + docKey: docKey, ); await tester.pumpAndSettle(); // Place caret at a position that will move to the next line when the width contracts - final tapOffset = _getOffsetForPosition(docKey, tapPosition); - await tester.tapAt(tapOffset); - await tester.pumpAndSettle(); + await tester.tapAtDocumentPosition(tapPosition); + await tester.pump(); - // Ensure that the caret is displayed at the correct (x,y) in the document before resizing the window final initialCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); - final expectedInitialCaretOffset = _computeExpectedDesktopCaretOffset(tester, textPosition); - expect(initialCaretOffset, expectedInitialCaretOffset); // Make the window more narrow, pushing the caret text position down a line. await _resizeWindow( tester: tester, frameCount: 60, initialScreenSize: screenSizeBigger, finalScreenSize: screenSizeSmaller); - // Ensure that after resizing the window, the caret updated its (x,y) to match the text - // position that was pushed down to the next line. + // Ensure that the caret jumped down at least a line height. It probably jumped + // down multiple lines. final finalCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); - final expectedFinalCaretOffset = _computeExpectedDesktopCaretOffset(tester, textPosition); - expect(finalCaretOffset, expectedFinalCaretOffset); + final lineHeight = _computeLineHeight(documentPosition); + expect(finalCaretOffset.dy - initialCaretOffset.dy, greaterThan(lineHeight)); }); - testWidgets('moves caret to preceding line when available width expands', (WidgetTester tester) async { - tester.binding.window - ..devicePixelRatioTestValue = 1.0 + testWidgetsOnDesktop('moves caret to preceding line when available width expands', (WidgetTester tester) async { + tester.view + ..devicePixelRatio = 1.0 ..platformDispatcher.textScaleFactorTestValue = 1.0 - ..physicalSizeTestValue = screenSizeSmaller; + ..physicalSize = screenSizeSmaller; final docKey = GlobalKey(); - await tester.pumpWidget( - _createTestApp( - gestureMode: DocumentGestureMode.mouse, - docKey: docKey, - ), + await _pumpScaffold( + tester, + gestureMode: DocumentGestureMode.mouse, + docKey: docKey, ); await tester.pumpAndSettle(); // Place caret at a position that will move to the preceding line when the width expands - final tapOffset = _getOffsetForPosition(docKey, tapPosition); - await tester.tapAt(tapOffset); - await tester.pumpAndSettle(); + await tester.tapAtDocumentPosition(tapPosition); + await tester.pump(); - // Ensure that the caret is displayed at the correct (x,y) in the document before resizing the window final initialCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); - final expectedInitialCaretOffset = _computeExpectedDesktopCaretOffset(tester, textPosition); - expect(initialCaretOffset, expectedInitialCaretOffset); // Make the window wider, pushing the caret text position up a line. await _resizeWindow( tester: tester, frameCount: 60, initialScreenSize: screenSizeSmaller, finalScreenSize: screenSizeBigger); - // Ensure that after resizing the window, the caret updated its (x,y) to match the text - // position that was pushed up to the preceding line. + // Ensure that the caret jumped up at least a line height. It probably jumped + // down multiple lines. final finalCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); - final expectedFinalCaretOffset = _computeExpectedDesktopCaretOffset(tester, textPosition); - expect(finalCaretOffset, expectedFinalCaretOffset); + final lineHeight = _computeLineHeight(documentPosition); + expect(finalCaretOffset.dy - initialCaretOffset.dy, lessThan(-lineHeight)); }); }); @@ -173,221 +167,242 @@ void main() { group('on Android', () { testWidgets('from portrait to landscape updates caret position', (WidgetTester tester) async { - tester.binding.window - ..devicePixelRatioTestValue = 1.0 + tester.view + ..devicePixelRatio = 1.0 ..platformDispatcher.textScaleFactorTestValue = 1.0 - ..physicalSizeTestValue = screenSizePortrait; + ..physicalSize = screenSizePortrait; final docKey = GlobalKey(); - await tester.pumpWidget( - _createTestApp( - gestureMode: DocumentGestureMode.android, - docKey: docKey, - ), + await _pumpScaffold( + tester, + gestureMode: DocumentGestureMode.android, + docKey: docKey, ); await tester.pumpAndSettle(); // Place caret at a position that will move to the preceding line when the width expands - final tapOffset = _getOffsetForPosition(docKey, tapPosition); - await tester.tapAt(tapOffset); - await tester.pumpAndSettle(); + await tester.tapAtDocumentPosition(tapPosition); + await tester.pump(); - // Ensure that the caret is displayed at the correct (x,y) in the document before phone rotation - final initialCaretOffset = _getCurrentAndroidCaretOffset(tester); - final expectedInitialCaretOffset = _computeExpectedMobileCaretOffset(tester, docKey, tapPosition); - expect(initialCaretOffset, expectedInitialCaretOffset); + final initialCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); // Make the window wider, pushing the caret text position up a line. - tester.binding.window.physicalSizeTestValue = screenSizeLandscape; + tester.view.physicalSize = screenSizeLandscape; await tester.pumpAndSettle(); - // Ensure that after rotating the phone, the caret updated its (x,y) to match the text - // position that was pushed up to the preceding line. - final finalCaretOffset = _getCurrentAndroidCaretOffset(tester); - final expectedFinalCaretOffset = _computeExpectedMobileCaretOffset(tester, docKey, tapPosition); - expect(finalCaretOffset, expectedFinalCaretOffset); + // Ensure that the caret jumped up a line. + // + // We check for a caret movement that's more-or-less equal to a line height, because + // the caret isn't necessarily the same height as the line. + final finalCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); + final lineHeight = _computeLineHeight(documentPosition); + expect(finalCaretOffset.dy - initialCaretOffset.dy, moreOrLessEquals(-lineHeight, epsilon: 3)); }); testWidgets('from landscape to portrait updates caret position', (WidgetTester tester) async { - tester.binding.window - ..devicePixelRatioTestValue = 1.0 + tester.view + ..devicePixelRatio = 1.0 ..platformDispatcher.textScaleFactorTestValue = 1.0 - ..physicalSizeTestValue = screenSizeLandscape; + ..physicalSize = screenSizeLandscape; final docKey = GlobalKey(); - await tester.pumpWidget( - _createTestApp( - gestureMode: DocumentGestureMode.android, - docKey: docKey, - ), + await _pumpScaffold( + tester, + gestureMode: DocumentGestureMode.android, + docKey: docKey, ); await tester.pumpAndSettle(); // Place caret at a position that will move to the next line when the width contracts - final tapOffset = _getOffsetForPosition(docKey, tapPosition); - await tester.tapAt(tapOffset); - await tester.pumpAndSettle(); + await tester.tapAtDocumentPosition(tapPosition); + await tester.pump(); // Ensure that the caret is displayed at the correct (x,y) in the document before phone rotation - final initialCaretOffset = _getCurrentAndroidCaretOffset(tester); - final expectedInitialCaretOffset = _computeExpectedMobileCaretOffset(tester, docKey, tapPosition); + final initialCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); + final expectedInitialCaretOffset = + _computeExpectedMobileCaretOffsetInDocumentLayout(tester, docKey, tapPosition); expect(initialCaretOffset, expectedInitialCaretOffset); // Make the window more narrow, pushing the caret text position up a line. - tester.binding.window.physicalSizeTestValue = screenSizePortrait; + tester.view.physicalSize = screenSizePortrait; await tester.pumpAndSettle(); // Ensure that after rotating the phone, the caret updated its (x,y) to match the text // position that was pushed down to the next line. - final finalCaretOffset = _getCurrentAndroidCaretOffset(tester); - final expectedFinalCaretOffset = _computeExpectedMobileCaretOffset(tester, docKey, tapPosition); + final finalCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); + final expectedFinalCaretOffset = + _computeExpectedMobileCaretOffsetInDocumentLayout(tester, docKey, tapPosition); expect(finalCaretOffset, expectedFinalCaretOffset); }); }); group('on iOS', () { - testWidgets('from portrait to landscape updates caret position', (WidgetTester tester) async { - tester.binding.window - ..devicePixelRatioTestValue = 1.0 + testWidgetsOnIos('from portrait to landscape updates caret position', (WidgetTester tester) async { + tester.view + ..devicePixelRatio = 1.0 ..platformDispatcher.textScaleFactorTestValue = 1.0 - ..physicalSizeTestValue = screenSizePortrait; + ..physicalSize = screenSizePortrait; final docKey = GlobalKey(); - await tester.pumpWidget( - _createTestApp( - gestureMode: DocumentGestureMode.iOS, - docKey: docKey, - ), + await _pumpScaffold( + tester, + gestureMode: DocumentGestureMode.iOS, + docKey: docKey, ); await tester.pumpAndSettle(); // Place caret at a position that will move to the preceding line when the width expands - final tapOffset = _getOffsetForPosition(docKey, tapPosition); - await tester.tapAt(tapOffset); - await tester.pumpAndSettle(); + await tester.tapAtDocumentPosition(tapPosition); + await tester.pump(); // Ensure that the caret is displayed at the correct (x,y) in the document before phone rotation - final initialOffset = _getIosCurrentCaretOffset(tester); - final expectedInitialCaretOffset = _computeExpectedMobileCaretOffset(tester, docKey, tapPosition); + final initialOffset = SuperEditorInspector.findCaretOffsetInDocument(); + final expectedInitialCaretOffset = + _computeExpectedMobileCaretOffsetInDocumentLayout(tester, docKey, tapPosition); expect(initialOffset, expectedInitialCaretOffset); // Make the window wider, pushing the caret text position up a line. - tester.binding.window.physicalSizeTestValue = screenSizeLandscape; + tester.view.physicalSize = screenSizeLandscape; await tester.pumpAndSettle(); // Ensure that after rotating the phone, the caret updated its (x,y) to match the text // position that was pushed up to the preceding line. - final finalCaretOffset = _getIosCurrentCaretOffset(tester); - final expectedFinalCaretOffset = _computeExpectedMobileCaretOffset(tester, docKey, tapPosition); + final finalCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); + final expectedFinalCaretOffset = + _computeExpectedMobileCaretOffsetInDocumentLayout(tester, docKey, tapPosition); expect(finalCaretOffset, expectedFinalCaretOffset); }); - testWidgets('from landscape to portrait updates caret position', (WidgetTester tester) async { - tester.binding.window - ..devicePixelRatioTestValue = 1.0 + testWidgetsOnIos('from landscape to portrait updates caret position', (WidgetTester tester) async { + tester.view + ..devicePixelRatio = 1.0 ..platformDispatcher.textScaleFactorTestValue = 1.0 - ..physicalSizeTestValue = screenSizeLandscape; + ..physicalSize = screenSizeLandscape; final docKey = GlobalKey(); - await tester.pumpWidget( - _createTestApp( - gestureMode: DocumentGestureMode.iOS, - docKey: docKey, - ), + await _pumpScaffold( + tester, + gestureMode: DocumentGestureMode.iOS, + docKey: docKey, ); await tester.pumpAndSettle(); // Place caret at a position that will move to the next line when the width contracts - final tapOffset = _getOffsetForPosition(docKey, tapPosition); - await tester.tapAt(tapOffset); - await tester.pumpAndSettle(); + await tester.tapAtDocumentPosition(tapPosition); + await tester.pump(); // Ensure that the caret is displayed at the correct (x,y) in the document before phone rotation - final initialOffset = _getIosCurrentCaretOffset(tester); - final expectedInitialCaretOffset = _computeExpectedMobileCaretOffset(tester, docKey, tapPosition); + final initialOffset = SuperEditorInspector.findCaretOffsetInDocument(); + final expectedInitialCaretOffset = + _computeExpectedMobileCaretOffsetInDocumentLayout(tester, docKey, tapPosition); expect(initialOffset, expectedInitialCaretOffset); // Make the window more narrow, pushing the caret text position down a line. - tester.binding.window.physicalSizeTestValue = screenSizePortrait; + tester.view.physicalSize = screenSizePortrait; await tester.pumpAndSettle(); // Ensure that after rotating the phone, the caret updated its (x,y) to match the text // position that was pushed down to the next line. - final finalCaretOffset = _getIosCurrentCaretOffset(tester); - final expectedFinalCaretOffset = _computeExpectedMobileCaretOffset(tester, docKey, tapPosition); + final finalCaretOffset = SuperEditorInspector.findCaretOffsetInDocument(); + final expectedFinalCaretOffset = + _computeExpectedMobileCaretOffsetInDocumentLayout(tester, docKey, tapPosition); expect(finalCaretOffset, expectedFinalCaretOffset); }); }); }); - }); -} -Widget _createTestApp({required DocumentGestureMode gestureMode, required GlobalKey docKey}) { - final editor = _createTestDocEditor(); - return MaterialApp( - home: Scaffold( - body: SuperEditor( - documentLayoutKey: docKey, - editor: editor, - gestureMode: gestureMode, - ), - ), - ); -} + testWidgetsOnAllPlatforms('blinks the caret when the user places the caret with a single tap', (tester) async { + // Configure BlinkController to animate, otherwise it won't blink. + BlinkController.indeterminateAnimationsEnabled = true; + addTearDown(() => BlinkController.indeterminateAnimationsEnabled = false); -/// Compute the center (x,y) for the given document [position] -Offset _getOffsetForPosition(GlobalKey docKey, DocumentPosition position) { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBox = docLayout.getRectForPosition(position); - return docBox.localToGlobal(characterBox!.center); -} + await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); -/// Find the caret in the widget tree and return it's (x,y) -/// -/// Should be used only when the document gesture mode is equal to [DocumentGestureMode.android] -/// -/// The reason for having different implementations is that depending on the gesture mode, -/// the widget that holds the caret offset is different -Offset _getCurrentAndroidCaretOffset(WidgetTester tester) { - final controls = - tester.widget(find.byType(AndroidDocumentTouchEditingControls).last); - return controls.editingController.caretTop!; -} + // Tap to place the caret at the beginning of the document. + // We don't use the robot method here because it calls pumpAndSettle, + // which causes a pumpAndSettle timeout, because we are constantly + // scheduling frames. + await tester.tap(find.byType(SuperEditor)); + await tester.pump(); -/// Find the caret in the widget tree and return it's (x,y) -/// -/// Should be used only when the document gesture mode is equal to [DocumentGestureMode.iOS] -/// -/// The reason for having different implementations is that depending on the gesture mode, -/// the widget that holds the caret offset is different -Offset _getIosCurrentCaretOffset(WidgetTester tester) { - final controls = tester.widget(find.byType(IosDocumentTouchEditingControls).last); - return controls.editingController.caretTop!; + // Ensure caret is visible. + expect(SuperEditorInspector.isCaretVisible(), true); + + // Duration to switch between visible and invisible. + final flashPeriod = SuperEditorInspector.caretFlashPeriod(); + + // Trigger a frame with an ellapsed time equal to the flashPeriod, + // so the caret should change from visible to invisible. + await tester.pump(flashPeriod); + + // Ensure caret is invisible after the flash period. + expect(SuperEditorInspector.isCaretVisible(), false); + + // Trigger another frame to make caret visible again. + await tester.pump(flashPeriod); + + // Ensure caret is visible. + expect(SuperEditorInspector.isCaretVisible(), true); + }); + + testWidgetsOnAllPlatforms('hides caret during expanded selection when configured that way', (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .withCaretPolicies( + displayCaretWithExpandedSelection: false, + ) + .pump(); + + // Place the caret in the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Ensure caret is visible. + expect(SuperEditorInspector.isCaretVisible(), true); + + // Go from a collapsed selection to an expanded selection. + await tester.doubleTapInParagraph("1", 2); + + // Ensure the selection is expanded. + expect(SuperEditorInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the caret is no longer visible. + expect(SuperEditorInspector.isCaretVisible(), false); + }); + }); } -/// Given a [textPosition], compute the expected (x,y) for the caret -/// -/// Should be used only when the document gesture mode is equal to [DocumentGestureMode.mouse] -Offset _computeExpectedDesktopCaretOffset(WidgetTester tester, TextPosition textPosition) { - return SuperEditorInspector.calculateOffsetForCaret(DocumentPosition( - nodeId: "1", - nodePosition: TextNodePosition.fromTextPosition(textPosition), - )); +Future _pumpScaffold( + WidgetTester tester, { + required DocumentGestureMode gestureMode, + required GlobalKey docKey, +}) async { + return await tester + .createDocument() + .withCustomContent(_createTestDocument()) + .withGestureMode(gestureMode) + .withLayoutKey(docKey) + .pump(); } -/// Given a [textPosition], compute the expected (x,y) for the caret +/// Given a [textPosition], compute the expected (x,y) for the caret within the document layout. /// /// Should be used only when the document gesture mode is equal to [DocumentGestureMode.android] /// or [DocumentGestureMode.iOS] -Offset _computeExpectedMobileCaretOffset(WidgetTester tester, GlobalKey docKey, DocumentPosition documentPosition) { +Offset _computeExpectedMobileCaretOffsetInDocumentLayout( + WidgetTester tester, GlobalKey docKey, DocumentPosition documentPosition) { final docLayout = docKey.currentState as DocumentLayout; final extentRect = docLayout.getRectForPosition(documentPosition)!; return Offset(extentRect.left, extentRect.top); } -DocumentEditor _createTestDocEditor() { - return DocumentEditor(document: _createTestDocument()); +double _computeLineHeight(DocumentPosition documentPosition) { + final docLayout = SuperEditorInspector.findDocumentLayout(); + final extentCharacterRect = docLayout.getRectForPosition(documentPosition)!; + return extentCharacterRect.height; } MutableDocument _createTestDocument() { @@ -396,8 +411,7 @@ MutableDocument _createTestDocument() { ParagraphNode( id: '1', text: AttributedText( - text: - "Super Editor is a toolkit to help you build document editors, document layouts, text fields, and more.", + "Super Editor is a toolkit to help you build document editors, document layouts, text fields, and more.", ), ) ], @@ -420,7 +434,7 @@ Future _resizeWindow({ resizedWidth += widthShrinkPerFrame; resizedHeight += heightShrinkPerFrame; final currentScreenSize = (initialScreenSize - Offset(resizedWidth, resizedHeight)) as Size; - tester.binding.window.physicalSizeTestValue = currentScreenSize; + tester.view.physicalSize = currentScreenSize; await tester.pumpAndSettle(); } } diff --git a/super_editor/test/super_editor/supereditor_component_selection_test.dart b/super_editor/test/super_editor/supereditor_component_selection_test.dart index 7524f3d137..6c6d95618f 100644 --- a/super_editor/test/super_editor/supereditor_component_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_component_selection_test.dart @@ -1,12 +1,10 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; -import '../test_tools.dart'; -import 'document_test_tools.dart'; import 'test_documents.dart'; /// This test suite illustrates the difference between interacting with @@ -164,6 +162,37 @@ void main() { ), ); }); + + testWidgetsOnArbitraryDesktop("defined by the app receives selection color", (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('Paragraph 1')), + _ButtonNode(id: '2'), + ParagraphNode(id: '3', text: AttributedText('Paragraph 3')), + ]), + ) + .withAddedComponents( + [const _ButtonComponentBuilder()], + ) + .withSelectionStyles( + const SelectionStyles(selectionColor: Colors.red), + ) + .pump(); + + // Drag to select all content. + await tester.dragSelectDocumentFromPositionByOffset( + from: const DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + delta: const Offset(0, 100), + ); + + // Ensure the selection color from the selection style was applied. + expect( + tester.widget(find.byType(SelectableBox)).selectionColor, + Colors.red, + ); + }); }); group("Unselectable component", () { @@ -549,28 +578,16 @@ Future _pumpEditorWithUnselectableHrsAndFakeToolbar( WidgetTester tester, { required GlobalKey toolbarKey, }) async { - final editor = DocumentEditor( - document: paragraphThenHrThenParagraphDoc(), - ); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: SuperEditor( - editor: editor, - gestureMode: debugDefaultTargetPlatformOverride == TargetPlatform.android - ? DocumentGestureMode.android - : DocumentGestureMode.iOS, - androidToolbarBuilder: (_) => SizedBox(key: toolbarKey), - iOSToolbarBuilder: (_) => SizedBox(key: toolbarKey), - componentBuilders: [ - const _UnselectableHrComponentBuilder(), - ...defaultComponentBuilders, - ], - ), - ), - ), - ); + await tester // + .createDocument() + .withCustomContent(paragraphThenHrThenParagraphDoc()) + .withComponentBuilders(const [ + _UnselectableHrComponentBuilder(), + ...defaultComponentBuilders, + ]) + .withAndroidToolbarBuilder((context, mobileToolbarKey, focalPoint) => SizedBox(key: toolbarKey)) + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => SizedBox(key: toolbarKey)) + .pump(); } /// SuperEditor [ComponentBuilder] that builds a horizontal rule that is @@ -608,22 +625,164 @@ class _UnselectableHorizontalRuleComponent extends StatelessWidget { @override Widget build(BuildContext context) { - return BoxComponent( - key: componentKey, - isVisuallySelectable: false, - child: const Divider( - color: Color(0xFF000000), - thickness: 1.0, + return IgnorePointer( + child: BoxComponent( + key: componentKey, + isVisuallySelectable: false, + child: const Divider( + color: Color(0xFF000000), + thickness: 1.0, + ), ), ); } } +/// A [DocumentNode] used to display a button. +@immutable +class _ButtonNode extends BlockNode { + _ButtonNode({ + required this.id, + }); + + @override + final String id; + + @override + String? copyContent(dynamic selection) => ''; + + @override + _ButtonNode copyWithAddedMetadata(Map newProperties) { + return _ButtonNode(id: id); + } + + @override + _ButtonNode copyAndReplaceMetadata(Map newMetadata) { + return _ButtonNode(id: id); + } + + @override + DocumentNode copy() { + return _ButtonNode(id: id); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _ButtonNode && // + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} + +class _ButtonViewModel extends SingleColumnLayoutComponentViewModel with SelectionAwareViewModelMixin { + _ButtonViewModel({ + required String nodeId, + double? maxWidth, + EdgeInsetsGeometry padding = EdgeInsets.zero, + DocumentNodeSelection? selection, + Color selectionColor = Colors.transparent, + }) : super(nodeId: nodeId, createdAt: null, maxWidth: maxWidth, padding: padding) { + this.selection = selection; + this.selectionColor = selectionColor; + } + + @override + _ButtonViewModel copy() { + return _ButtonViewModel( + nodeId: nodeId, + maxWidth: maxWidth, + padding: padding, + selection: selection, + selectionColor: selectionColor, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + super == other && + other is _ButtonViewModel && + runtimeType == other.runtimeType && + nodeId == other.nodeId && + selection == other.selection && + selectionColor == other.selectionColor; + + @override + int get hashCode => super.hashCode ^ nodeId.hashCode ^ selection.hashCode ^ selectionColor.hashCode; +} + +class _ButtonComponent extends StatelessWidget { + const _ButtonComponent({ + Key? key, + required this.componentKey, + this.selectionColor = Colors.blue, + this.selection, + }) : super(key: key); + + final GlobalKey componentKey; + final Color selectionColor; + final DocumentNodeSelection? selection; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: SelectableBox( + selection: selection?.nodeSelection as UpstreamDownstreamNodeSelection?, + selectionColor: selectionColor, + child: BoxComponent( + key: componentKey, + child: const SizedBox(), + ), + ), + ), + Center( + child: ElevatedButton( + onPressed: () {}, + child: const Text('My Button'), + ), + ), + ], + ); + } +} + +class _ButtonComponentBuilder implements ComponentBuilder { + const _ButtonComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + if (node is! _ButtonNode) { + return null; + } + + return _ButtonViewModel(nodeId: node.id); + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! _ButtonViewModel) { + return null; + } + + return _ButtonComponent( + componentKey: componentContext.componentKey, + selection: componentViewModel.selection, + selectionColor: componentViewModel.selectionColor, + ); + } +} + final _testStylesheet = defaultStylesheet.copyWith( addRulesAfter: [ StyleRule(BlockSelector.all, (doc, node) { return { - "textStyle": const TextStyle( + Styles.textStyle: const TextStyle( fontSize: 12, ), }; diff --git a/super_editor/test/super_editor/supereditor_components_test.dart b/super_editor/test/super_editor/supereditor_components_test.dart index ffc7325cc6..6621804334 100644 --- a/super_editor/test/super_editor/supereditor_components_test.dart +++ b/super_editor/test/super_editor/supereditor_components_test.dart @@ -2,11 +2,10 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; -import '../test_tools.dart'; -import 'document_test_tools.dart'; import 'test_documents.dart'; void main() { @@ -16,7 +15,7 @@ void main() { await tester // .createDocument() .withSingleEmptyParagraph() - .withAddedComponents([const HintTextComponentBuilder()]) + .withAddedComponents([HintComponentBuilder("Hello", (_) => const TextStyle())]) .autoFocus(false) .pump(); @@ -78,55 +77,22 @@ void main() { expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); }); }); - }); -} - -class HintTextComponentBuilder implements ComponentBuilder { - const HintTextComponentBuilder(); - @override - SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { - // This component builder can work with the standard paragraph view model. - // We'll defer to the standard paragraph component builder to create it. - return null; - } - - @override - Widget? createComponent( - SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { - if (componentViewModel is! ParagraphComponentViewModel) { - return null; - } + testWidgetsOnArbitraryDesktop('does not crash when if finds an unkown node type', (tester) async { + // Pump an editor with a node that has no corresponding component builder. + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [_UnknownNode(id: '1')], + ), + ) + .pump(); - final textSelection = componentViewModel.selection; - - return TextWithHintComponent( - key: componentContext.componentKey, - text: componentViewModel.text, - textStyleBuilder: defaultStyleBuilder, - metadata: componentViewModel.blockType != null - ? { - 'blockType': componentViewModel.blockType, - } - : {}, - // This is the text displayed as a hint. - hintText: AttributedText( - text: 'this is hint text...', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: italicsAttribution, offset: 12, markerType: SpanMarkerType.start), - const SpanMarker(attribution: italicsAttribution, offset: 15, markerType: SpanMarkerType.end), - ], - ), - ), - // This is the function that selects styles for the hint text. - hintStyleBuilder: (Set attributions) => defaultStyleBuilder(attributions).copyWith( - color: const Color(0xFFDDDDDD), - ), - textSelection: textSelection, - selectionColor: componentViewModel.selectionColor, - ); - } + // Reaching this point means the editor did not crash because of the + // unkown node. + }); + }); } /// Pump a SuperEditor containing an image which will render as an 100x100 box @@ -147,7 +113,7 @@ Future _pumpImageTestApp( width: double.infinity, ).toMetadata(), ), - ...longTextDoc().nodes, + ...longTextDoc(), ], ), ) @@ -177,9 +143,34 @@ class _FakeImageComponentBuilder implements ComponentBuilder { return ImageComponent( componentKey: componentContext.componentKey, imageUrl: componentViewModel.imageUrl, - selection: componentViewModel.selection, + selection: componentViewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection?, selectionColor: componentViewModel.selectionColor, imageBuilder: (context, imageUrl) => const SizedBox(height: 100, width: 100), ); } } + +/// A [DocumentNode] without any content. +/// +/// Used to simulate an app-level node type that the editor +/// doesn't know about. +@immutable +class _UnknownNode extends BlockNode { + _UnknownNode({required this.id}); + + @override + final String id; + + @override + String? copyContent(NodeSelection selection) => ''; + + @override + _UnknownNode copyWithAddedMetadata(Map newProperties) { + return _UnknownNode(id: id); + } + + @override + _UnknownNode copyAndReplaceMetadata(Map newMetadata) { + return _UnknownNode(id: id); + } +} diff --git a/super_editor/test/super_editor/supereditor_content_deletion_test.dart b/super_editor/test/super_editor/supereditor_content_deletion_test.dart new file mode 100644 index 0000000000..6c3b187e51 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_content_deletion_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperEditor > content deletion >', () { + testWidgetsOnAllPlatforms('clears document', (tester) async { + final testContext = await tester // + .createDocument() + .withLongDoc() + .pump(); + + // Place the caret at an arbitraty node. We don't place the caret at the + // beginning of the document to make sure the selection will move + // to the beginning of the document after the deletion. + await tester.placeCaretInParagraph('2', 0); + + // Hold the state sent to the platform. + String? text; + int? selectionBase; + int? selectionExtent; + String? selectionAffinity; + int? composingBase; + int? composingExtent; + + // Intercept the setEditingState message sent to the platform. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + if (methodCall.method == 'TextInput.setEditingState') { + text = methodCall.arguments['text']; + selectionBase = methodCall.arguments['selectionBase']; + selectionExtent = methodCall.arguments['selectionExtent']; + selectionAffinity = methodCall.arguments['selectionAffinity']; + composingBase = methodCall.arguments["composingBase"]; + composingExtent = methodCall.arguments["composingExtent"]; + } + return null; + }, + ); + + // Delete all content. + testContext.editor.execute([const ClearDocumentRequest()]); + await tester.pump(); + + // Ensure the document was cleared and a new empty paragraph was added. + final document = testContext.document; + expect(document.length, equals(1)); + expect(document.first, isA()); + expect((document.first as ParagraphNode).text.text, equals('')); + + // Ensure the selection was moved to the end of the document. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.first.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + + // Ensure the composing region was cleared. + expect(testContext.composer.composingRegion.value, isNull); + + // Ensure the state was correctly sent to the platform. + expect(text, equals('. ')); + expect(selectionBase, equals(2)); + expect(selectionExtent, equals(2)); + expect(selectionAffinity, equals('TextAffinity.downstream')); + expect(composingBase, equals(-1)); + expect(composingExtent, equals(-1)); + + // Ensure the user can still type text. + await tester.typeImeText('Hello world!'); + expect((document.first as ParagraphNode).text.text, equals('Hello world!')); + }); + }); +} diff --git a/super_editor/test/super_editor/supereditor_content_insertion_test.dart b/super_editor/test/super_editor/supereditor_content_insertion_test.dart new file mode 100644 index 0000000000..b723110fc5 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_content_insertion_test.dart @@ -0,0 +1,611 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_inspector.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperEditor', () { + group('inserts an image', () { + testWidgetsOnAllPlatforms('when the selection sits at the beginning of a non-empty paragraph', (tester) async { + // Pump a widget with an arbitrary size for the images. + final context = await tester // + .createDocument() + .fromMarkdown("First paragraph") + .withAddedComponents( + [const FakeImageComponentBuilder(size: Size(100, 100))], + ).pump(); + + // Place caret at the beginning of the paragraph. + await tester.placeCaretInParagraph(context.findEditContext().document.first.id, 0); + + // Insert the image at the current selection. + context.findEditContext().commonOps.insertImage('http://image.fake'); + await tester.pumpAndSettle(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure that one node was inserted. + expect(doc.nodeCount, 2); + + // Ensure that the image was added. + expect(doc.getNodeAt(0)!, isA()); + + // Ensure that the paragraph node content remains unchanged, but is moved down. + expect(doc.getNodeAt(1)!, isA()); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), 'First paragraph'); + + // Ensure the selection was placed at the beginning of the paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.getNodeAt(1)!.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + testWidgetsOnAllPlatforms('when the selection sits at the middle of a paragraph', (tester) async { + // Pump a widget with an arbitrary size for the images. + final context = await tester // + .createDocument() + .fromMarkdown("Before the image after the image") + .withAddedComponents( + [const FakeImageComponentBuilder(size: Size(100, 100))], + ).pump(); + + // Place caret at "Before the image| after the image". + await tester.placeCaretInParagraph(context.findEditContext().document.first.id, 16); + + // Insert the image at the current selection. + context.findEditContext().commonOps.insertImage('http://image.fake'); + await tester.pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure that two nodes were inserted. + expect(doc.nodeCount, 3); + + // Ensure that the first node has the text from before the caret. + expect(doc.getNodeAt(0)!, isA()); + expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'Before the image'); + + // Ensure that the image was added. + expect(doc.getNodeAt(1)!, isA()); + + // Ensure that the last node has the text from after the caret. + expect(doc.getNodeAt(2)!, isA()); + expect((doc.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ' after the image'); + + // Ensure the selection was placed at the beginning of the last paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms('when a downstream selection sits at the end of a paragraph', (tester) async { + // Pump a widget with an arbitrary size for the images. + final context = await tester // + .createDocument() + .fromMarkdown("First paragraph") + .withAddedComponents( + [const FakeImageComponentBuilder(size: Size(100, 100))], + ).pump(); + + // Place caret at the end of the paragraph. + await tester.placeCaretInParagraph(context.findEditContext().document.first.id, 15); + + // Insert the image at the current selection. + context.findEditContext().commonOps.insertImage('http://image.fake'); + await tester.pumpAndSettle(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure that two nodes were inserted. + expect(doc.nodeCount, 3); + + // Ensure that the first node remains unchanged. + expect(doc.getNodeAt(0)!, isA()); + expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'First paragraph'); + + // Ensure that the image was added. + expect(doc.getNodeAt(1)!, isA()); + + // Ensure that an empty node was added after the image. + expect(doc.getNodeAt(2)!, isA()); + expect((doc.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ''); + + // Ensure the selection was placed at the beginning of the last paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms('when an upstream selection sits at the end of a paragraph', (tester) async { + // Pump a widget with an arbitrary size for the images. + final context = await tester // + .createDocument() + .fromMarkdown("""First paragraph + +Second paragraph"""). // + withAddedComponents( + [const FakeImageComponentBuilder(size: Size(100, 100))], + ).pump(); + + // Place caret at the end of the first paragraph by selecting the second paragraph and pressing left. + // + // This results in an upstream text affinity. + await tester.placeCaretInParagraph(context.findEditContext().document.last.id, 0); + await tester.pressLeftArrow(); + + // Insert the image at the current selection. + context.findEditContext().commonOps.insertImage('http://image.fake'); + await tester.pumpAndSettle(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure that two nodes were inserted. + expect(doc.nodeCount, 4); + + // Ensure that the first node remains unchanged. + expect(doc.getNodeAt(0)!, isA()); + expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'First paragraph'); + + // Ensure that the image was added. + expect(doc.getNodeAt(1)!, isA()); + + // Ensure that an empty node was added after the image. + expect(doc.getNodeAt(2)!, isA()); + expect((doc.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ''); + + // Ensure the selection was placed at the beginning of the newly created paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.getNodeAt(2)!.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms('when the selection sits at an empty paragraph', (tester) async { + // Pump a widget with an arbitrary size for the images. + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withAddedComponents( + [const FakeImageComponentBuilder(size: Size(100, 100))], + ).pump(); + + // Place caret at the empty paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Insert the image at the current selection. + context.findEditContext().commonOps.insertImage('http://image.fake'); + await tester.pumpAndSettle(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure that one node was inserted. + expect(doc.nodeCount, 2); + + // Ensure that the paragraph was converted to an image. + expect(doc.first, isA()); + + // Ensure that an empty node was added after the image. + expect(doc.getNodeAt(1)!, isA()); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ''); + + // Ensure the selection was placed at the empty paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.getNodeAt(1)!.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + }); + + group('inserts a horizontal rule', () { + testWidgetsOnAllPlatforms('when the selection sits at the beginning of a non-empty paragraph', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown("First paragraph") + .pump(); + + // Place caret at the beginning of the paragraph. + await tester.placeCaretInParagraph(context.findEditContext().document.first.id, 0); + + // Insert the horizontal rule at the current selection. + context.findEditContext().commonOps.insertHorizontalRule(); + await tester.pumpAndSettle(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure that one node was inserted. + expect(doc.nodeCount, 2); + + // Ensure that the horizontal rule was added. + expect(doc.getNodeAt(0)!, isA()); + + // Ensure that the paragraph node content remains unchanged, but is moved down. + expect(doc.getNodeAt(1)!, isA()); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), 'First paragraph'); + + // Ensure the selection was placed at the beginning of the paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.getNodeAt(1)!.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + testWidgetsOnAllPlatforms('when the selection sits at the middle of a paragraph', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown("Before the hr after the hr") + .pump(); + + // Place caret at "Before the hr| after the hr". + await tester.placeCaretInParagraph(context.findEditContext().document.first.id, 13); + + // Insert the horizontal rule at the current selection. + context.findEditContext().commonOps.insertHorizontalRule(); + await tester.pumpAndSettle(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure that two nodes were inserted. + expect(doc.nodeCount, 3); + + // Ensure that the first node has the text from before the caret. + expect(doc.getNodeAt(0)!, isA()); + expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'Before the hr'); + + // Ensure that the horizontal rule was added. + expect(doc.getNodeAt(1)!, isA()); + + // Ensure that the last node has the text from after the caret. + expect(doc.getNodeAt(2)!, isA()); + expect((doc.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ' after the hr'); + + // Ensure the selection was placed at the beginning of the last paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms('when a downstream selection sits at the end of a paragraph', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown("First paragraph") + .pump(); + + // Place caret at the end of the paragraph. + await tester.placeCaretInParagraph(context.findEditContext().document.first.id, 15); + + // Insert the horizontal rule at the current selection. + context.findEditContext().commonOps.insertHorizontalRule(); + await tester.pumpAndSettle(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure that two nodes were inserted. + expect(doc.nodeCount, 3); + + // Ensure that the first node remains unchanged. + expect(doc.getNodeAt(0)!, isA()); + expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'First paragraph'); + + // Ensure that the horizontal rule was added. + expect(doc.getNodeAt(1)!, isA()); + + // Ensure that an empty node was added at the end. + expect(doc.getNodeAt(2)!, isA()); + expect((doc.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ''); + + // Ensure the selection was placed at the beginning of the last paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms('when an upstream selection sits at the end of a paragraph', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown("""First paragraph + + Second paragraph""") // + .pump(); + + // Place caret at the end of the first paragraph by selecting the second paragraph and pressing left. + // + // This results in an upstream text affinity. + await tester.placeCaretInParagraph(context.findEditContext().document.last.id, 0); + await tester.pressLeftArrow(); + + // Insert the horizontal rule at the current selection. + context.findEditContext().commonOps.insertHorizontalRule(); + await tester.pumpAndSettle(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure that two nodes were inserted. + expect(doc.nodeCount, 4); + + // Ensure that the first node remains unchanged. + expect(doc.getNodeAt(0)!, isA()); + expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'First paragraph'); + + // Ensure that the horizontal rule was added. + expect(doc.getNodeAt(1)!, isA()); + + // Ensure that an empty node was added at the end. + expect(doc.getNodeAt(2)!, isA()); + expect((doc.getNodeAt(2)! as ParagraphNode).text.toPlainText(), ''); + + // Ensure the selection was placed at the beginning of the newly created paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.getNodeAt(2)!.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms('when the selection sits at an empty paragraph', (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + // Place caret at the empty paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Insert the horizontal rule at the current selection. + context.findEditContext().commonOps.insertHorizontalRule(); + await tester.pumpAndSettle(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure that one node was inserted. + expect(doc.nodeCount, 2); + + // Ensure the paragraph was converted to a horizontal rule. + expect(doc.first, isA()); + + // Ensure that an empty node was added after the horizontal rule. + expect(doc.getNodeAt(1)!, isA()); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ''); + + // Ensure that the selection was placed at the empty paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.getNodeAt(1)!.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + }); + + group('inserts a paragraph', () { + testWidgetsOnDesktop('when the user presses ENTER at the end of an image', (tester) async { + final testContext = await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ImageNode( + id: "img-node", + imageUrl: 'https://this.is.a.fake.image', + metadata: const SingleColumnLayoutComponentStyles( + width: double.infinity, + ).toMetadata(), + ), + ParagraphNode( + id: 'text-node', + text: AttributedText('Paragraph'), + ), + ], + ), + ) + .withAddedComponents([const FakeImageComponentBuilder(size: Size(100, 100))]) + .withEditorSize(const Size(300, 300)) + .pump(); + + // Place caret after the image by selecting the beginning of the paragraph and pressing left. + await tester.placeCaretInParagraph('text-node', 0); + await tester.pressLeftArrow(); + + // Ensure the selection was placed at the end of the image. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: 'img-node', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // Simulate pressing enter on a hardware keyboard. + await tester.pressEnter(); + + // Ensure an empty paragraph was inserted and the selection was placed on its beginning. + final doc = testContext.findEditContext().document; + expect(doc.nodeCount, 3); + expect(doc.getNodeAt(1)!, isA()); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ''); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: testContext.findEditContext().document.getNodeAt(1)!.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAndroid('when the user presses the newline button on the software keyboard at the end of an image', + (tester) async { + final testContext = await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ImageNode( + id: "img-node", + imageUrl: 'https://this.is.a.fake.image', + metadata: const SingleColumnLayoutComponentStyles( + width: double.infinity, + ).toMetadata(), + ), + ParagraphNode( + id: 'text-node', + text: AttributedText('Paragraph'), + ), + ], + ), + ) + .withAddedComponents([const FakeImageComponentBuilder(size: Size(100, 100))]) + .withEditorSize(const Size(300, 300)) + .pump(); + + // Place caret at the beginning of the paragraph. + await tester.placeCaretInParagraph('text-node', 0); + await tester.pressLeftArrow(); + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: 'img-node', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText('\n'); + + // Ensure an empty paragraph was inserted and the selection was placed on its beginning. + final doc = testContext.findEditContext().document; + expect(doc.nodeCount, 3); + expect(doc.getNodeAt(1)!, isA()); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ''); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: testContext.findEditContext().document.getNodeAt(1)!.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnIos('when the user presses the newline button on the software keyboard at the end of an image', + (tester) async { + final testContext = await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ImageNode( + id: "img-node", + imageUrl: 'https://this.is.a.fake.image', + metadata: const SingleColumnLayoutComponentStyles( + width: double.infinity, + ).toMetadata(), + ), + ParagraphNode( + id: 'text-node', + text: AttributedText('Paragraph'), + ), + ], + ), + ) + .withAddedComponents([const FakeImageComponentBuilder(size: Size(100, 100))]) + .withEditorSize(const Size(300, 300)) + .pump(); + + // Place caret at the beginning of the paragraph. + await tester.placeCaretInParagraph('text-node', 0); + await tester.pressLeftArrow(); + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: 'img-node', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + await tester.pump(); + + // Ensure an empty paragraph was inserted and the selection was placed on its beginning. + final doc = testContext.findEditContext().document; + expect(doc.nodeCount, 3); + expect(doc.getNodeAt(1)!, isA()); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ''); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: testContext.findEditContext().document.getNodeAt(1)!.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + }); + }); +} diff --git a/super_editor/test/super_editor/supereditor_copy_and_paste_test.dart b/super_editor/test/super_editor/supereditor_copy_and_paste_test.dart new file mode 100644 index 0000000000..2cdd6d93b7 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_copy_and_paste_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../test_runners.dart'; + +void main() { + group('SuperEditor copy and paste > ', () { + testWidgetsOnApple('pastes within a paragraph', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at the beginning of the empty document and + // add some text to give us a non-empty paragraph. + await tester.placeCaretInParagraph(doc.first.id, 0); + await tester.typeImeText("Pasted text: "); + + // Paste text into the paragraph. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent("This was pasted here"); + await tester.pressCmdV(); + + // Ensure that the text was pasted into the paragraph. + final nodeId = doc.first.id; + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Pasted text: This was pasted here"); + }); + + testWidgetsOnApple('pastes within a list item', (tester) async { + await tester // + .createDocument() + .fromMarkdown(" * Pasted text:") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(doc.first.id, 12); + await tester.typeImeText(" "); // <- manually add a space because Markdown strips it + + // Paste text into the paragraph. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent("This was pasted here"); + await tester.pressCmdV(); + + // Ensure that the text was pasted into the paragraph. + final nodeId = doc.first.id; + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Pasted text: This was pasted here"); + }); + + testAllInputsOnDesktop('pastes multiple paragraphs', ( + tester, { + required TextInputSource inputSource, + }) async { + final testContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(inputSource) + .pump(); + + // Place the caret at the empty paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Simulate pasting multiple lines. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent('''This is a paragraph +This is a second paragraph +This is the third paragraph'''); + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdV(); + } else { + await tester.pressCtlV(); + } + + // Ensure three paragraphs were created. + final doc = testContext.document; + expect(doc.nodeCount, 3); + expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'This is a paragraph'); + expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), 'This is a second paragraph'); + expect((doc.getNodeAt(2)! as ParagraphNode).text.toPlainText(), 'This is the third paragraph'); + }); + + testAllInputsOnAllPlatforms("paste retains node IDs when replayed during undo", ( + tester, { + required TextInputSource inputSource, + }) async { + final testContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(inputSource) + .pump(); + + // Place the caret at the empty paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Simulate pasting multiple lines. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent('''This is a paragraph +This is a second paragraph +This is the third paragraph'''); + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdV(); + } else { + await tester.pressCtlV(); + } + + // Gather the current node IDs in the document. + final originalNodeIds = testContext.document.toList().map((node) => node.id).toList(); + + // Pump enough time to separate the next text entry from the paste action. + await tester.pump(const Duration(seconds: 2)); + + // Type some text. + switch (inputSource) { + case TextInputSource.keyboard: + await tester.pressKey(LogicalKeyboardKey.keyA); + case TextInputSource.ime: + await tester.typeImeText("a"); + } + + // Undo the text insertion (this causes the paste command re-run). + testContext.editor.undo(); + + // Ensure that the node IDs in the document didn't change after re-running + // the paste command. + final newNodeIds = testContext.document.toList().map((node) => node.id).toList(); + expect(newNodeIds, originalNodeIds); + }); + + testWidgetsOnMac("paste command content does not mutate when document changes", (tester) async { + final testContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .enableHistory(true) + .pump(); + + // Place the caret at the empty paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Simulate pasting multiple lines. + tester + ..simulateClipboard() + ..setSimulatedClipboardContent('''This is a paragraph +This is a second paragraph +This is the third paragraph'''); + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdV(); + } else { + await tester.pressCtlV(); + } + + // Pump enough time to separate the next text entry from the paste action. + await tester.pump(const Duration(seconds: 2)); + await tester.typeImeText("a"); + + // Ensure that the "a" was inserted at the end of the final pasted paragraph. + expect( + (testContext.document.last as TextNode).text.toPlainText(), + "This is the third paragrapha", + ); + + // Run undo. + testContext.editor.undo(); + + // After undo, ensure that we no longer have the inserted "a". + // + // The undo operation works by replaying earlier commands, such as the paste command. + // The paste command internally stores the content that it inserted. This test ensures + // that the paste command's internal content wasn't mutated when we inserted the "a" + // into the document. Such mutation was part of bug https://github.com/superlistapp/super_editor/issues/2173 + expect( + (testContext.document.last as TextNode).text.toPlainText(), + "This is the third paragraph", + ); + }); + }); +} diff --git a/super_editor/test/super_editor/supereditor_floating_cursor_test.dart b/super_editor/test/super_editor/supereditor_floating_cursor_test.dart new file mode 100644 index 0000000000..18fc40e1b4 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_floating_cursor_test.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/blinking_caret.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperEditor', () { + group('floating cursor', () { + testWidgetsOnIos('hides caret when over text', (tester) async { + // Pump a SuperEditor which displays the content in a single line. + await tester // + .createDocument() + .fromMarkdown('This is a paragraph') + .withEditorSize(const Size(500, 500)) + .pump(); + + // Place caret at "|This is a paragraph". + await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.first.id, 0); + + // Ensure the caret is displayed. + expect(_caretFinder(), findsOneWidget); + + // Show the floating cursor. + await tester.startFloatingCursorGesture(); + await tester.pump(); + + // Move the floating cursor to the right. + // The floating cursor will be over the text. + await tester.updateFloatingCursorGesture(const Offset(50, 0)); + await tester.pump(); + + // Ensure the caret isn't displayed. + expect(_caretFinder(), findsNothing); + + // Move the floating cursor to the right. + // The floating cursor will be over the text. + await tester.updateFloatingCursorGesture(const Offset(100, 0)); + await tester.pump(); + + // Ensure the caret isn't displayed. + expect(_caretFinder(), findsNothing); + + // Move the floating cursor to the right. + // The floating cursor will be over the text. + await tester.updateFloatingCursorGesture(const Offset(175, 0)); + await tester.pump(); + + // Ensure the caret isn't displayed. + expect(_caretFinder(), findsNothing); + + // Release the floating cursor. + await tester.stopFloatingCursorGesture(); + await tester.pump(); + + // Ensure the caret is displayed. + expect(_caretFinder(), findsOneWidget); + }); + + testWidgetsOnIos('hides caret when near text', (tester) async { + // Pump a SuperEditor which displays the content in a single line. + await tester // + .createDocument() + .fromMarkdown('This is a paragraph') + .withEditorSize(const Size(500, 500)) + .pump(); + + // Place caret at the end of the text. + await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.first.id, 19); + + // Ensure the caret is displayed. + expect(_caretFinder(), findsOneWidget); + + // Show the floating cursor. + await tester.startFloatingCursorGesture(); + await tester.pump(); + + // Moves the floating cursor to the maximum distance before the grey caret is displayed. + await tester.updateFloatingCursorGesture(const Offset(30, 0)); + await tester.pump(); + + // Ensure the caret isn't displayed. + expect(_caretFinder(), findsNothing); + + // Release the floating cursor. + await tester.stopFloatingCursorGesture(); + await tester.pump(); + + // Ensure the caret is displayed. + expect(_caretFinder(), findsOneWidget); + }); + + testWidgetsOnIos('shows grey caret when far from text', (tester) async { + // Pump a SuperEditor which displays the content in a single line. + await tester // + .createDocument() + .fromMarkdown('This is a paragraph') + .withEditorSize(const Size(500, 500)) + .pump(); + + // Place caret at the end of the text. + await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.first.id, 19); + + // Show the floating cursor. + await tester.startFloatingCursorGesture(); + await tester.pump(); + + // Moves the floating cursor to the first pixel where the grey caret should be displayed. + await tester.updateFloatingCursorGesture(const Offset(31, 0)); + await tester.pump(); + + // Ensure the caret is displayed. + expect(_caretFinder(), findsOneWidget); + + // Ensure the caret is grey. + BlinkingCaret caret = tester.widget(_caretFinder()); + expect(caret.color, Colors.grey); + + // Release the floating cursor. + await tester.stopFloatingCursorGesture(); + await tester.pump(); + + // Ensure the caret is displayed. + expect(_caretFinder(), findsOneWidget); + + // Ensure the caret is the same colors as the theme's primary color. + caret = tester.widget(_caretFinder()); + + expect(caret.color, ThemeData().primaryColor); + }); + + testWidgetsOnIos('collapses an expanded selection', (tester) async { + final testContext = await tester // + .createDocument() + .fromMarkdown('This is a paragraph') + .pump(); + + final nodeId = testContext.document.first.id; + + // Double tap to select the word "This" + await tester.doubleTapInParagraph(nodeId, 0); + + // Ensure the word is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection( + base: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 4), + ), + ), + ); + + // Show the floating cursor. + await tester.startFloatingCursorGesture(); + await tester.pump(); + + // Ensure the selection collapsed. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 4), + ), + ), + ); + }); + + testWidgetsOnIos('moves selection between paragraphs', (tester) async { + final testContext = await tester // + .createDocument() + .fromMarkdown(''' +This is the first paragraph + +Second paragraph''') // + .pump(); + + // Place the caret at the end of the first paragraph. + await tester.placeCaretInParagraph(testContext.document.first.id, 27); + + // Show the floating cursor. + await tester.startFloatingCursorGesture(); + await tester.pump(); + + // Move the floating cursor down to the next paragraph. + await tester.updateFloatingCursorGesture(const Offset(0, 30)); + await tester.pump(); + + // Simulate iOS IME generating deltas as a result of moving the floating cursor. + // At this point, the selection already changed to the second paragraph, which is + // smaller than the selection offset reported in the delta. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: 'This is the first paragraph', + selection: TextSelection.collapsed(offset: 27), + composing: TextRange.empty, + ) + ], getter: imeClientGetter); + await tester.pump(); + + // Ensure the selection changed to the end of the second paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: testContext.document.last.id, + nodePosition: const TextNodePosition(offset: 16, affinity: TextAffinity.upstream), + ), + ), + ); + }); + }); + }); +} + +Finder _caretFinder() { + return find.byType(BlinkingCaret); +} diff --git a/super_editor/test/super_editor/supereditor_focus_test.dart b/super_editor/test/super_editor/supereditor_focus_test.dart new file mode 100644 index 0000000000..38206492af --- /dev/null +++ b/super_editor/test/super_editor/supereditor_focus_test.dart @@ -0,0 +1,752 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group('SuperEditor', () { + group('autofocus', () { + testWidgets('does not claim focus when autofocus is false', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .withSingleParagraph() + .withInputSource(inputAndGestureVariants.currentValue!.inputSource) + .withGestureMode(inputAndGestureVariants.currentValue!.gestureMode) + .autoFocus(false) + .pump(); + + expect(SuperEditorInspector.hasFocus(), false); + }, variant: inputAndGestureVariants); + + testWidgets('claims focus when autofocus is true', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .withSingleParagraph() + .withInputSource(inputAndGestureVariants.currentValue!.inputSource) + .withGestureMode(inputAndGestureVariants.currentValue!.gestureMode) + .autoFocus(true) + .pump(); + + expect(SuperEditorInspector.hasFocus(), true); + }, variant: inputAndGestureVariants); + + testWidgets('claims focus by gesture when autofocus is false', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .withSingleParagraph() + .withInputSource(inputAndGestureVariants.currentValue!.inputSource) + .withGestureMode(inputAndGestureVariants.currentValue!.gestureMode) + .autoFocus(false) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + expect(SuperEditorInspector.hasFocus(), true); + }, variant: inputAndGestureVariants); + }); + + group("restores selection after re-focus", () { + testWidgetsOnAllPlatforms("when selection is collapsed", (tester) async { + final focusNode = FocusNode(); + await _pumpFocusChangeLayoutWithSingleParagraph(tester, editorFocusNode: focusNode); + + // Place caret in the middle of a word. + await tester.placeCaretInParagraph('1', 8); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Focus the editor. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection was restored. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("when selection is expanded", (tester) async { + final editorFocusNode = FocusNode(); + final testContext = await _pumpFocusChangeLayoutWithSingleParagraph(tester, editorFocusNode: editorFocusNode); + + // Tap on editor to give it focus. + await tester.placeCaretInParagraph('1', 0); + + // Select some text. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Focus the editor. + editorFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection was restored. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + }); + }); + + group("throws away stale selection after re-focus", () { + group("with caret", () { + testWidgetsOnAllPlatforms("when content type changes", (tester) async { + final focusNode = FocusNode(); + final testContext = await _pumpFocusChangeLayoutWithSingleParagraph(tester, editorFocusNode: focusNode); + + // Place caret in the middle of a word. + await tester.placeCaretInParagraph('1', 8); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Replace the paragraph with a horizontal rule. + testContext.editor.execute([ + ReplaceNodeRequest( + existingNodeId: '1', + newNode: HorizontalRuleNode(id: '1'), + ), + ]); + + // Focus the editor. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection is cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + + testWidgetsOnAllPlatforms("when it no longer fits in text", (tester) async { + final focusNode = FocusNode(); + final testContext = await _pumpFocusChangeLayoutWithSingleParagraph(tester, editorFocusNode: focusNode); + + // Place caret in the middle of a word. + await tester.placeCaretInParagraph('1', 8); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Delete text to make selection invalid. + final textNode = testContext.document.first as TextNode; + testContext.editor.execute([ + DeleteContentRequest( + documentRange: DocumentRange( + start: DocumentPosition(nodeId: textNode.id, nodePosition: textNode.beginningPosition), + end: DocumentPosition(nodeId: textNode.id, nodePosition: textNode.endPosition), + ), + ), + ]); + + // Focus the editor. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection is cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + }); + + group("with expanded selection within a node", () { + testWidgetsOnAllPlatforms("when downstream no longer fits", (tester) async { + final editorFocusNode = FocusNode(); + final testContext = await _pumpFocusChangeLayoutWithSingleParagraph(tester, editorFocusNode: editorFocusNode); + + // Tap on editor to give it focus. + await tester.placeCaretInParagraph('1', 0); + + // Select some text. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Delete the text so that the selection extent is no longer valid. + final textNode = testContext.document.first as TextNode; + testContext.editor.execute([ + DeleteContentRequest( + documentRange: DocumentRange( + start: DocumentPosition(nodeId: textNode.id, nodePosition: textNode.beginningPosition), + end: DocumentPosition(nodeId: textNode.id, nodePosition: textNode.endPosition), + ), + ), + ]); + + // Focus the editor. + editorFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection is cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + + testWidgetsOnAllPlatforms("when content type changes", (tester) async { + final focusNode = FocusNode(); + final testContext = await _pumpFocusChangeLayoutWithSingleParagraph(tester, editorFocusNode: focusNode); + + // Tap on editor to give it focus. + await tester.placeCaretInParagraph('1', 0); + + // Select some text. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Replace the paragraph with a horizontal rule. + testContext.editor.execute([ + ReplaceNodeRequest( + existingNodeId: '1', + newNode: HorizontalRuleNode(id: '1'), + ), + ]); + + // Focus the editor. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection is cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + }); + + group("with expanded selection across nodes", () { + testWidgetsOnAllPlatforms("when the base and extent content type changes", (tester) async { + final focusNode = FocusNode(); + final testContext = await _pumpFocusChangeLayoutWithTwoParagraphs(tester, editorFocusNode: focusNode); + + // Tap on editor to give it focus. + await tester.placeCaretInParagraph('1', 0); + + // Select some text. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 8), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Replace the paragraphs with horizontal rules. + testContext.editor.execute([ + ReplaceNodeRequest( + existingNodeId: '1', + newNode: HorizontalRuleNode(id: '1'), + ), + ReplaceNodeRequest( + existingNodeId: '2', + newNode: HorizontalRuleNode(id: '2'), + ), + ]); + + // Focus the editor. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection is cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + + testWidgetsOnAllPlatforms("when the base content type changes", (tester) async { + final focusNode = FocusNode(); + final testContext = await _pumpFocusChangeLayoutWithTwoParagraphs(tester, editorFocusNode: focusNode); + + // Tap on editor to give it focus. + await tester.placeCaretInParagraph('1', 0); + + // Select some text. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 8), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Replace the base paragraph with a horizontal rule. + testContext.editor.execute([ + ReplaceNodeRequest( + existingNodeId: '1', + newNode: HorizontalRuleNode(id: '1'), + ), + ]); + + // Focus the editor. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection is cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + + testWidgetsOnAllPlatforms("when the extent content type changes", (tester) async { + final focusNode = FocusNode(); + final testContext = await _pumpFocusChangeLayoutWithTwoParagraphs(tester, editorFocusNode: focusNode); + + // Tap on editor to give it focus. + await tester.placeCaretInParagraph('1', 0); + + // Select some text. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 8), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Replace the extent paragraph with a horizontal rule. + testContext.editor.execute([ + ReplaceNodeRequest( + existingNodeId: '2', + newNode: HorizontalRuleNode(id: '2'), + ), + ]); + + // Focus the editor. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection is cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + + testWidgetsOnAllPlatforms("when the upstream no longer fits", (tester) async { + final focusNode = FocusNode(); + final testContext = await _pumpFocusChangeLayoutWithTwoParagraphs(tester, editorFocusNode: focusNode); + + // Tap on editor to give it focus. + await tester.placeCaretInParagraph('1', 0); + + // Select some text. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 1), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 8), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 1), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Delete the text in the upstream node. + final textNode = testContext.editor.context.document.first as TextNode; + testContext.editor.execute([ + DeleteContentRequest( + documentRange: DocumentRange( + start: DocumentPosition(nodeId: textNode.id, nodePosition: textNode.beginningPosition), + end: DocumentPosition(nodeId: textNode.id, nodePosition: textNode.endPosition), + ), + ), + ]); + + // Focus the editor. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection is cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + + testWidgetsOnAllPlatforms("when the downstream no longer fits", (tester) async { + final focusNode = FocusNode(); + final testContext = await _pumpFocusChangeLayoutWithTwoParagraphs(tester, editorFocusNode: focusNode); + + // Tap on editor to give it focus. + await tester.placeCaretInParagraph('1', 0); + + // Select some text. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 8), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Delete the text in the downstream node. + final textNode = testContext.editor.context.document.last as TextNode; + testContext.editor.execute([ + DeleteContentRequest( + documentRange: DocumentRange( + start: DocumentPosition(nodeId: textNode.id, nodePosition: textNode.beginningPosition), + end: DocumentPosition(nodeId: textNode.id, nodePosition: textNode.endPosition), + ), + ), + ]); + + // Focus the editor. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure selection is cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + }); + }); + }); +} + +Future _pumpFocusChangeLayoutWithSingleParagraph( + WidgetTester tester, { + required FocusNode editorFocusNode, + FocusNode? textFieldFocusNode, +}) async { + return await tester + .createDocument() + .withSingleParagraph() + .withInputSource(TextInputSource.ime) + .withFocusNode(editorFocusNode) + // We include StableTagPlugin because its reaction checks the selection change type, + // and at one point (#2792) Super Editor was reporting "place caret" when restoring + // an expanded selection. This broke the plugin. So while we don't actually care about + // this plugin for this test suite, we want to ensure that it doesn't throw any exceptions. + .withPlugin(StableTagPlugin()) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + // Add a textfield as a place to temporarily move focus. + TextField( + focusNode: textFieldFocusNode, + ), + Expanded(child: superEditor), + ], + ), + ), + ), + ) + .pump(); +} + +Future _pumpFocusChangeLayoutWithTwoParagraphs( + WidgetTester tester, { + required FocusNode editorFocusNode, + FocusNode? textFieldFocusNode, +}) async { + return await tester + .createDocument() + .withCustomContent(MutableDocument(nodes: [ + ParagraphNode(id: "1", text: AttributedText("Hello, world - 1")), + ParagraphNode(id: "2", text: AttributedText("Hello, world - 2")), + ])) + .withInputSource(TextInputSource.ime) + .withFocusNode(editorFocusNode) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + // Add a textfield as a place to temporarily move focus. + TextField( + focusNode: textFieldFocusNode, + ), + Expanded(child: superEditor), + ], + ), + ), + ), + ) + .pump(); +} diff --git a/super_editor/test/super_editor/supereditor_gestures_test.dart b/super_editor/test/super_editor/supereditor_gestures_test.dart index 790948262b..6ce9f77f32 100644 --- a/super_editor/test/super_editor/supereditor_gestures_test.dart +++ b/super_editor/test/super_editor/supereditor_gestures_test.dart @@ -1,11 +1,16 @@ import 'dart:ui'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/links.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; import '../test_tools.dart'; -import 'document_test_tools.dart'; void main() { group('SuperEditor gestures', () { @@ -31,7 +36,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: testContext.editContext.editor.document.nodes.first.id, + nodeId: testContext.findEditContext().document.first.id, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -60,7 +65,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: testContext.editContext.editor.document.nodes.first.id, + nodeId: testContext.findEditContext().document.first.id, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -89,7 +94,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: testContext.editContext.editor.document.nodes.first.id, + nodeId: testContext.findEditContext().document.first.id, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -118,7 +123,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: testContext.editContext.editor.document.nodes.first.id, + nodeId: testContext.findEditContext().document.first.id, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -147,7 +152,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: testContext.editContext.editor.document.nodes.first.id, + nodeId: testContext.findEditContext().document.first.id, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -172,7 +177,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: testContext.editContext.editor.document.nodes.last.id, + nodeId: testContext.findEditContext().document.last.id, nodePosition: const TextNodePosition(offset: 14), ), ), @@ -196,7 +201,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: testContext.editContext.editor.document.nodes.first.id, + nodeId: testContext.findEditContext().document.first.id, nodePosition: const TextNodePosition(offset: 0), ), ), @@ -223,7 +228,7 @@ spans multiple lines.''', .pump(); final document = SuperEditorInspector.findDocument()!; - final paragraphNode = document.nodes.first as ParagraphNode; + final paragraphNode = document.first as ParagraphNode; await tester.dragSelectDocumentFromPositionByOffset( from: DocumentPosition( @@ -269,7 +274,7 @@ spans multiple lines.''', .pump(); final document = SuperEditorInspector.findDocument()!; - final paragraphNode = document.nodes.first as ParagraphNode; + final paragraphNode = document.first as ParagraphNode; await tester.dragSelectDocumentFromPositionByOffset( from: DocumentPosition( @@ -317,8 +322,8 @@ spans multiple lines.''', .pump(); final document = SuperEditorInspector.findDocument()!; - final titleNode = document.nodes.first as ParagraphNode; - final paragraphNode = document.nodes[1] as ParagraphNode; + final titleNode = document.first as ParagraphNode; + final paragraphNode = document.getNodeAt(1)! as ParagraphNode; await tester.dragSelectDocumentFromPositionByOffset( from: DocumentPosition( @@ -366,8 +371,8 @@ spans multiple lines.''', .pump(); final document = SuperEditorInspector.findDocument()!; - final titleNode = document.nodes.first as ParagraphNode; - final paragraphNode = document.nodes[1] as ParagraphNode; + final titleNode = document.first as ParagraphNode; + final paragraphNode = document.getNodeAt(1)! as ParagraphNode; await tester.dragSelectDocumentFromPositionByOffset( from: DocumentPosition( @@ -428,13 +433,13 @@ spans multiple lines.''', await tester .createDocument() .withSingleParagraph() - .autoFocus(true) .withEditorSize(const Size(300, 700)) .withSelection( const DocumentSelection.collapsed( position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0))), ) .pump(); + final offsetOfLineBreak = SuperEditorInspector.findOffsetOfLineBreak('1'); // Tap to place the at the end of the first line @@ -452,30 +457,30 @@ spans multiple lines.''', ); }); - testWidgetsOnAndroid('configures default gesture mode (on Android)', (tester) async { + testWidgetsOnAndroid('configures default gesture mode', (tester) async { await tester // .createDocument() .withSingleParagraph() .pump(); // Tap to place caret. - await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.nodes.first.id, 0); + await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.first.id, 0); // Ensure the drag handle is displayed. expect(find.byType(AndroidSelectionHandle), findsOneWidget); }); - testWidgetsOnIos('configures default gesture mode (on iOS)', (tester) async { + testWidgetsOnIos('configures default gesture mode', (tester) async { await tester // .createDocument() .withSingleParagraph() .pump(); // Tap to place caret. - await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.nodes.first.id, 0); + await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.first.id, 0); // Ensure the drag handle is displayed. - expect(find.byType(IosDocumentTouchEditingControls), findsOneWidget); + expect(find.byType(IosFloatingToolbarOverlay), findsOneWidget); }); testWidgetsOnDesktop('configures default gesture mode', (tester) async { @@ -484,11 +489,249 @@ spans multiple lines.''', .withSingleParagraph() .pump(); - await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.nodes.first.id, 0); + await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.first.id, 0); // Ensure no drag handle is displayed. expect(find.byType(AndroidSelectionHandle), findsNothing); - expect(find.byType(IosDocumentTouchEditingControls), findsNothing); + expect(find.byType(IosFloatingToolbarOverlay), findsNothing); + }); + + testWidgetsOnDesktop('scrolls the content when dragging the scrollbar down', (tester) async { + final scrollController = ScrollController(); + await tester // + .createDocument() + .withSingleParagraph() + .withEditorSize(const Size(300, 300)) + .withScrollController(scrollController) + .pump(); + + // Ensure the editor didn't start scrolled. + expect(scrollController.position.pixels, 0.0); + + // Double tap to select "Lorem" to ensure the selection don't change + // when dragging the scrollbar. + await tester.doubleTapInParagraph('1', 0); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + extent: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + )), + ); + + // Find the approximate position of the scrollbar thumb. + final thumbLocation = tester.getTopRight(find.byType(SuperEditor)) + const Offset(-10, 10); + + // Hover to make the thumb visible with a duration long enough to run the fade in animation. + final testPointer = TestPointer(1, PointerDeviceKind.mouse); + + await tester.sendEventToBinding(testPointer.hover(thumbLocation, timeStamp: const Duration(seconds: 1))); + await tester.pumpAndSettle(); + + // Press the thumb. + await tester.sendEventToBinding(testPointer.down(thumbLocation)); + await tester.pump(kTapMinTime); + + // Move the thumb down. + await tester.sendEventToBinding(testPointer.move(thumbLocation + const Offset(0, 300))); + await tester.pump(); + + // Release the pointer. + await tester.sendEventToBinding(testPointer.up()); + await tester.pump(); + + // Ensure the content scrolled to the end of the document. + expect(scrollController.position.pixels, moreOrLessEquals(770.0)); + + // Ensure the selection didn't change. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + extent: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + )), + ); + }); + + testWidgetsOnDesktop('scrolls the content when dragging the scrollbar up', (tester) async { + final scrollController = ScrollController(); + await tester // + .createDocument() + .withSingleParagraph() + .withEditorSize(const Size(300, 300)) + .withScrollController(scrollController) + .pump(); + + // Double tap to select "Lorem" to ensure the selection don't change + // when dragging the scrollbar. + await tester.doubleTapInParagraph('1', 0); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + extent: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + )), + ); + + // Jump to the end of the document. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); + + // Find the approximate position of the scrollbar thumb. + final thumbLocation = tester.getBottomRight(find.byType(SuperEditor)) - const Offset(10, 10); + + // Hover to make the thumb visible with a duration long enough to run the fade in animation. + final testPointer = TestPointer(1, PointerDeviceKind.mouse); + + await tester.sendEventToBinding(testPointer.hover(thumbLocation, timeStamp: const Duration(seconds: 1))); + await tester.pumpAndSettle(); + + // Press the thumb. + await tester.sendEventToBinding(testPointer.down(thumbLocation)); + await tester.pump(kTapMinTime); + + // Move the thumb up. + await tester.sendEventToBinding(testPointer.move(thumbLocation - const Offset(0, 300))); + await tester.pump(); + + // Release the pointer. + await tester.sendEventToBinding(testPointer.up()); + await tester.pump(); + + // Ensure the content scrolled to the beginning of the document. + expect(scrollController.position.pixels, 0); + + // Ensure the selection didn't change. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + extent: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + ), + ), + ); + }); + + group("interaction mode", () { + group("when active", () { + testWidgetsOnAllPlatforms("launches URL on tap", (tester) async { + // Setup test version of UrlLauncher to log URL launches. + final testUrlLauncher = TestUrlLauncher(); + UrlLauncher.instance = testUrlLauncher; + addTearDown(() => UrlLauncher.instance = null); + + // Pump the UI. + final context = await tester // + .createDocument() + .withSingleParagraphAndLink() + .autoFocus(true) + .pump(); + + // Activate interaction mode. + if (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS) { + // On mobile, there's no hardware keyboard to easily activate + // interaction mode. In practice, app developers will decide + // when/how to activate interaction mode on mobile. Rather than + // add buttons in our test just for this purpose, we'll explicitly + // activate interaction mode. + context.findEditContext().editor.execute([ + const ChangeInteractionModeRequest(isInteractionModeDesired: true), + ]); + } else if (defaultTargetPlatform == TargetPlatform.macOS) { + // Press CMD to activate interaction mode on Mac. + await tester.sendKeyDownEvent(LogicalKeyboardKey.meta); + } else { + // Press CTRL to activate interaction mode on Windows and Linux. + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + } + + // Ensure that interaction mode is "on". + expect(context.findEditContext().composer.isInInteractionMode.value, isTrue); + + // Tap on the link. + await tester.tapInParagraph("1", 27); + + // Ensure that we tried to launch the URL. + expect(testUrlLauncher.urlLaunchLog.length, 1); + expect(testUrlLauncher.urlLaunchLog.first.toString(), "https://fake.url"); + }); + + testWidgetsOnAllPlatforms("launches different URLs on tap", (tester) async { + // Setup test version of UrlLauncher to log URL launches. + final testUrlLauncher = TestUrlLauncher(); + UrlLauncher.instance = testUrlLauncher; + addTearDown(() => UrlLauncher.instance = null); + + // Pump the UI. + final context = await tester // + .createDocument() + .fromMarkdown("[Google](https://google.com) and [Flutter](https://flutter.dev)") + .autoFocus(true) + .pump(); + + // Activate interaction mode. + if (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS) { + // On mobile, there's no hardware keyboard to easily activate + // interaction mode. In practice, app developers will decide + // when/how to activate interaction mode on mobile. Rather than + // add buttons in our test just for this purpose, we'll explicitly + // activate interaction mode. + context.findEditContext().editor.execute([ + const ChangeInteractionModeRequest(isInteractionModeDesired: true), + ]); + } else if (defaultTargetPlatform == TargetPlatform.macOS) { + // Press CMD to activate interaction mode on Mac. + await tester.sendKeyDownEvent(LogicalKeyboardKey.meta); + } else { + // Press CTRL to activate interaction mode on Windows and Linux. + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + } + + // Ensure that interaction mode is "on". + expect(context.findEditContext().composer.isInInteractionMode.value, isTrue); + + // Tap on the first link. + final textNode = context.document.first; + await tester.tapInParagraph(textNode.id, 3); + + // Ensure that we tried to launch the first URL. + expect(testUrlLauncher.urlLaunchLog.length, 1); + expect(testUrlLauncher.urlLaunchLog.first.toString(), "https://google.com"); + + // Tap on the second link. + await tester.tapInParagraph(textNode.id, 14); + + // Ensure that we tried to launch the second URL. + expect(testUrlLauncher.urlLaunchLog.length, 2); + expect(testUrlLauncher.urlLaunchLog.last.toString(), "https://flutter.dev"); + }); + }); + + group("when inactive", () { + testWidgetsOnAllPlatforms("doesn't launch URL on tap", (tester) async { + // Setup test version of UrlLauncher to log URL launches. + final testUrlLauncher = TestUrlLauncher(); + UrlLauncher.instance = testUrlLauncher; + addTearDown(() => UrlLauncher.instance = null); + + // Pump the UI. + final context = await tester // + .createDocument() + .withSingleParagraphAndLink() + .autoFocus(true) + .pump(); + + // Ensure that interaction mode is "off". + expect(context.findEditContext().composer.isInInteractionMode.value, isFalse); + + // Tap on the link. + await tester.tapInParagraph("1", 27); + + // Ensure that we DIDN'T try to launch the URL. + expect(testUrlLauncher.urlLaunchLog.length, 0); + }); + }); }); }); } diff --git a/super_editor/test/super_editor/supereditor_initialization_test.dart b/super_editor/test/super_editor/supereditor_initialization_test.dart new file mode 100644 index 0000000000..ab590c86eb --- /dev/null +++ b/super_editor/test/super_editor/supereditor_initialization_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +void main() { + group('SuperEditor > initialization >', () { + testWidgetsOnAllPlatforms('immediately shows and blinks the caret when autofocus is true', (tester) async { + // Configure BlinkController to animate, otherwise it won't blink. + BlinkController.indeterminateAnimationsEnabled = true; + addTearDown(() => BlinkController.indeterminateAnimationsEnabled = false); + + await tester // + .createDocument() + .withSingleEmptyParagraph() + .autoFocus(true) + .withSelection( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ) + .pump(); + + // Ensure caret is visible at start. + expect(SuperEditorInspector.isCaretVisible(), true); + + // Duration to switch between visible and invisible. + final flashPeriod = SuperEditorInspector.caretFlashPeriod(); + + // Trigger a frame with an ellapsed time equal to the flashPeriod, + // so the caret should change from visible to invisible. + await tester.pump(flashPeriod); + + // Ensure caret is invisible after the flash period. + expect(SuperEditorInspector.isCaretVisible(), false); + + // Trigger another frame to make caret visible again. + await tester.pump(flashPeriod); + + // Ensure caret is visible. + expect(SuperEditorInspector.isCaretVisible(), true); + }); + }); +} diff --git a/super_editor/test/super_editor/supereditor_inline_widgets_test.dart b/super_editor/test/super_editor/supereditor_inline_widgets_test.dart new file mode 100644 index 0000000000..e16fe8e97d --- /dev/null +++ b/super_editor/test/super_editor/supereditor_inline_widgets_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +void main() { + group('SuperEditor > inline widgets >', () { + testWidgetsOnAllPlatforms('does not invalidate layout when selection changes', (tester) async { + await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Hello, world!', null, { + 7: const _NamedPlaceHolder('world'), + }), + ), + ], + ), + ) + .useStylesheet( + defaultStylesheet.copyWith( + inlineWidgetBuilders: [_boxPlaceHolderBuilder], + ), + ) + .pump(); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Keep track of whether of not the layout was invalidated. + bool wasLayoutInvalidated = false; + + final renderParagraph = find + .byType(LayoutAwareRichText) // + .evaluate() + .first + .findRenderObject() as RenderLayoutAwareParagraph; + renderParagraph.onMarkNeedsLayout = () { + wasLayoutInvalidated = true; + }; + + // Place the selection somewhere else. + await tester.placeCaretInParagraph('1', 2); + + // Ensure the layout was not invalidated. + expect(wasLayoutInvalidated, isFalse); + }); + }); +} + +/// A builder that renders a [ColoredBox] for a [_NamedPlaceHolder]. +Widget? _boxPlaceHolderBuilder(BuildContext context, TextStyle textStyle, Object placeholder) { + if (placeholder is! _NamedPlaceHolder) { + return null; + } + + return KeyedSubtree( + key: ValueKey('placeholder-${placeholder.name}'), + child: LineHeight( + style: textStyle, + child: const SizedBox( + width: 24, + child: ColoredBox( + color: Colors.yellow, + ), + ), + ), + ); +} + +/// A placeholder that is identified by a name. +class _NamedPlaceHolder { + const _NamedPlaceHolder(this.name); + + final String name; + + @override + bool operator ==(Object other) => + identical(this, other) || other is _NamedPlaceHolder && runtimeType == other.runtimeType && name == other.name; + + @override + int get hashCode => name.hashCode; +} diff --git a/super_editor/test/super_editor/supereditor_input_ime_test.dart b/super_editor/test/super_editor/supereditor_input_ime_test.dart new file mode 100644 index 0000000000..034de696d0 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_input_ime_test.dart @@ -0,0 +1,2210 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +import '../test_runners.dart'; +import '../test_tools.dart'; + +void main() { + group('IME input', () { + group('types characters', () { + testWidgetsOnAllPlatforms('at the beginning of existing text', (tester) async { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("<- text here")), + ], + ); + + await tester // + .createDocument() + .withCustomContent(document) + .withInputSource(TextInputSource.ime) + .pump(); + + // Place caret at the beginning of the paragraph content. + await tester.placeCaretInParagraph("1", 0); + + // Type some text. + await tester.typeImeText("Hello"); + + // Ensure the text was typed. + expect((document.first as ParagraphNode).text.toPlainText(), "Hello<- text here"); + }); + + testWidgetsOnAllPlatforms('in the middle of existing text', (tester) async { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("text here -><---")), + ], + ); + + await tester // + .createDocument() + .withCustomContent(document) + .withInputSource(TextInputSource.ime) + .pump(); + + // Place caret at the beginning of the paragraph content. + await tester.placeCaretInParagraph("1", 12); + + // Type some text. + await tester.typeImeText("Hello"); + + // Ensure the text was typed. + expect((document.first as ParagraphNode).text.toPlainText(), "text here ->Hello<---"); + }); + + testWidgetsOnAllPlatforms('at the end of existing text', (tester) async { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("text here ->")), + ], + ); + + await tester // + .createDocument() + .withCustomContent(document) + .withInputSource(TextInputSource.ime) + .pump(); + + // Place caret at the beginning of the paragraph content. + await tester.placeCaretInParagraph("1", 12); + + // Type some text. + await tester.typeImeText("Hello"); + + // Ensure the text was typed. + expect((document.first as ParagraphNode).text.toPlainText(), "text here ->Hello"); + }); + }); + + testWidgetsOnAllPlatforms('allows apps to handle performAction in their own way', (tester) async { + final document = singleParagraphEmptyDoc(); + + int performActionCount = 0; + TextInputAction? performedAction; + final imeOverrides = _TestImeOverrides( + (action) { + performActionCount += 1; + performedAction = action; + }, + ); + + await tester // + .createDocument() + .withCustomContent(document) + .withInputSource(TextInputSource.ime) + .withImeOverrides(imeOverrides) + .pump(); + + // Place the caret in the document so that we open an IME connection. + await tester.placeCaretInParagraph("1", 0); + + // Simulate a "Newline" action from the platform. + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + const MethodCall( + "TextInputClient.performAction", + [-1, "TextInputAction.newline"], + ), + ), + null, + ); + + // Ensure that our override got the performAction call. + expect(performActionCount, 1); + expect(performedAction, TextInputAction.newline); + + // Ensure that the editor didn't receive the performAction call, and didn't + // insert a new node. + expect(document.nodeCount, 1); + }); + + testWidgetsOnAndroid('allows app to handle newline action', (tester) async { + // On Android, when the user presses an action button configured as TextInputAction.newline, + // instead of dispatching the action, the OS sends an insertion delta of '\n'. + // + // Then, the IME code that handles deltas translates this insertion into a performAction call. + // This test ensures that this performAction call honors the IME overrides. + + final document = singleParagraphEmptyDoc(); + + int performActionCount = 0; + TextInputAction? performedAction; + final imeOverrides = _TestImeOverrides( + (action) { + performActionCount += 1; + performedAction = action; + }, + ); + + await tester // + .createDocument() + .withCustomContent(document) + .withInputSource(TextInputSource.ime) + .withImeOverrides(imeOverrides) + .pump(); + + // Place the caret in the document so that we open an IME connection. + await tester.placeCaretInParagraph("1", 0); + + // Simulate the user pressing an action button that generates an insertion of a new line. + await tester.typeImeText('\n'); + + // Ensure that our override got the performAction call. + expect(performActionCount, 1); + expect(performedAction, TextInputAction.newline); + + // Ensure that the editor didn't receive the performAction call, and didn't + // insert a new node. + expect(document.nodeCount, 1); + }); + + testWidgetsOnMac('allows apps to handle selectors in their own way', (tester) async { + bool customHandlerCalled = false; + + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ParagraphNode(id: '1', text: AttributedText('First paragraph'))], + ), + ) + .withInputSource(TextInputSource.ime) + .withSelectorHandlers({ + MacOsSelectors.moveRight: (context) { + customHandlerCalled = true; + }, + }).pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph("1", 0); + + // Press right arrow key to trigger the MacOsSelectors.moveRight selector. + await tester.pressRightArrow(); + + // Ensure the custom handler was called. + expect(customHandlerCalled, isTrue); + + // Ensure that the editor didn't execute the default handler for the MacOsSelectors.moveRight selector. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms('applies list of deltas the way some IMEs report them', (tester) async { + // This test simulates an auto-correction scenario, + // where the IME sends multiple insertion deltas at once. + + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place caret at the start of the document. + await tester.placeCaretInParagraph('1', 0); + + // Send initial delta, insertion of 'Goi'. + await tester.ime.sendDeltas( + const [ + TextEditingDeltaNonTextUpdate( + oldText: '. ', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaInsertion( + oldText: '. ', + textInserted: 'Goi', + insertionOffset: 2, + selection: TextSelection.collapsed(offset: 5), + composing: TextRange(start: 2, end: 5), + ) + ], + getter: imeClientGetter, + ); + + // Simulate the auto-correction kicking in during the insertion of a '.'. + await tester.ime.sendDeltas( + const [ + // This delta represents the '.' typed by the user. + TextEditingDeltaInsertion( + oldText: '. Goi', + textInserted: '.', + insertionOffset: 3, + selection: TextSelection.collapsed(offset: 6), + composing: TextRange(start: -1, end: -1), + ), + // Deltas generated by the auto-correction. + // First, delete everything. + TextEditingDeltaDeletion( + oldText: '. Goi.', + deletedRange: TextRange(start: 2, end: 6), + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: -1, end: -1), + ), + // Insert the auto-corrected word. + TextEditingDeltaInsertion( + oldText: '. ', + textInserted: 'Going', + insertionOffset: 2, + selection: TextSelection.collapsed(offset: 7), + composing: TextRange(start: -1, end: -1), + ), + // Insert the '.' typed. + TextEditingDeltaInsertion( + oldText: '. Going', + textInserted: '.', + insertionOffset: 7, + selection: TextSelection.collapsed(offset: 8), + composing: TextRange(start: -1, end: -1), + ), + ], + getter: imeClientGetter, + ); + + // Ensure the text was inserted. + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'Going.', + ); + }); + + group('delta use-cases', () { + test('can handle an auto-inserted period', () { + // On iOS, adding 2 spaces causes the two spaces to be replaced by a + // period and a space. This test applies the same type and order of deltas + // that were observed on iOS. + // + // Previously, we had a bug where the period was appearing after the + // 2nd space, instead of between the two spaces. This test prevents + // that regression. + final document = MutableDocument(nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("This is a sentence"), + ), + ]); + final composer = MutableDocumentComposer( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 18), + ), + ), + ); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + final commonOps = CommonEditorOperations( + editor: editor, + document: document, + composer: composer, + documentLayoutResolver: () => FakeDocumentLayout(), + ); + final softwareKeyboardHandler = TextDeltasDocumentEditor( + editor: editor, + document: document, + documentLayoutResolver: () => FakeDocumentLayout(), + selection: composer.selectionNotifier, + composerPreferences: composer.preferences, + composingRegion: composer.composingRegion, + commonOps: commonOps, + onPerformAction: (_) {}, + ); + + softwareKeyboardHandler.applyDeltas([ + const TextEditingDeltaInsertion( + textInserted: ' ', + insertionOffset: 20, + selection: TextSelection.collapsed(offset: 21), + composing: TextRange(start: -1, end: -1), + oldText: '. This is a sentence', + ), + ]); + softwareKeyboardHandler.applyDeltas([ + const TextEditingDeltaReplacement( + oldText: '. This is a sentence ', + replacementText: '.', + replacedRange: TextRange(start: 20, end: 21), + selection: TextSelection.collapsed(offset: 21), + composing: TextRange(start: -1, end: -1), + ), + ]); + softwareKeyboardHandler.applyDeltas([ + const TextEditingDeltaInsertion( + textInserted: ' ', + insertionOffset: 21, + selection: TextSelection.collapsed(offset: 22), + composing: TextRange(start: -1, end: -1), + oldText: '. This is a sentence.', + ), + ]); + + expect((document.first as ParagraphNode).text.toPlainText(), "This is a sentence. "); + }); + + testWidgets('can type compound character in an empty paragraph', (tester) async { + final editContext = await tester // + .createDocument() + .withTwoEmptyParagraphs() + .withInputSource(TextInputSource.ime) + .withGestureMode(DocumentGestureMode.mouse) + .autoFocus(true) + .pump(); + + // Start the caret in the 2nd paragraph so that we send a + // hidden placeholder to the IME to report backspaces. + await tester.placeCaretInParagraph("2", 0); + + // Send the deltas that should produce a ü. + // + // We have to use implementation details to send the simulated IME deltas + // because Flutter doesn't have any testing tools for IME deltas. + final imeInteractor = find.byType(SuperEditorImeInteractor).evaluate().first; + final deltaClient = ((imeInteractor as StatefulElement).state as ImeInputOwner).imeClient; + + // Ensure that the delta client starts with the expected invisible placeholder + // characters. + expect(deltaClient.currentTextEditingValue!.text, ". "); + expect(deltaClient.currentTextEditingValue!.selection, const TextSelection.collapsed(offset: 2)); + expect(deltaClient.currentTextEditingValue!.composing, const TextRange(start: -1, end: -1)); + + // Insert the "opt+u" character. + deltaClient.updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: ". ", + textInserted: "¨", + insertionOffset: 2, + selection: TextSelection.collapsed(offset: 3), + composing: TextRange(start: 2, end: 3), + ), + ]); + await tester.pumpAndSettle(); + + // Ensure that the empty paragraph now reads "¨". + expect((editContext.document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), "¨"); + + // Ensure that the IME still has the invisible characters. + expect(deltaClient.currentTextEditingValue!.text, ". ¨"); + expect(deltaClient.currentTextEditingValue!.composing, const TextRange(start: 2, end: 3)); + + // Insert the "u" character to create the compound character. + deltaClient.updateEditingValueWithDeltas([ + const TextEditingDeltaReplacement( + oldText: ". ¨", + replacementText: "ü", + replacedRange: TextRange(start: 2, end: 3), + selection: TextSelection.collapsed(offset: 3), + composing: TextRange(start: -1, end: -1), + ), + ]); + + // We need a final pump and settle to propagate selection changes while we still + // have access to the document layout. Otherwise, the selection change callback + // will execute after the end of this test, and the layout isn't available any + // more. + // TODO: trace the selection change call stack and adjust it so that we don't need this pump + await tester.pumpAndSettle(); + + // Ensure that the empty paragraph now reads "ü". + expect((editContext.document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), "ü"); + }); + }); + + group('Android >', () { + // Note: Some Android devices report ENTER and BACKSPACE as hardware keys. Other Android + // devices report "\n" insertion and deletion IME deltas, instead. + group('on Xiaomi Redmi tablet (Android 12 SP1A)', () { + testWidgetsOnAndroid('applies list of deltas when inserting new lines', (tester) async { + // This test simulates inserting a line break in the middle of the text, + // followed by a non-text delta placing the selection/composing region on the new line. + // + // This test runs only on Android, because we only map a \n insertion to a new line on Android. + + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place caret at the start of the document. + await tester.placeCaretInParagraph('1', 0); + + // Send initial delta. + await tester.ime.sendDeltas( + const [ + TextEditingDeltaInsertion( + oldText: '. ', + textInserted: 'Before the line break new line', + insertionOffset: 2, + selection: TextSelection.collapsed(offset: 32), + composing: TextRange(start: 2, end: 32), + ) + ], + getter: imeClientGetter, + ); + + // Place the caret at "Before the line break |new line". + await tester.placeCaretInParagraph('1', 22); + + // Add a line break and simulate the OS sending a non-text delta to change the composing region. + // + // The OS thinks the editing text is "Before the line break \nnew line". + // + // With the insertion of the line break, the paragraph will be split into two and + // our current editing text will be "new line". + // + // The OS selection is invalid to us, as our editing text changed. + await tester.ime.sendDeltas( + const [ + TextEditingDeltaInsertion( + oldText: 'Before the line break new line', + textInserted: '\n', + insertionOffset: 22, + selection: TextSelection.collapsed(offset: 23), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaNonTextUpdate( + oldText: 'Before the line break \nnew line', + selection: TextSelection.collapsed(offset: 23), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaNonTextUpdate( + oldText: 'Before the line break \nnew line', + selection: TextSelection.collapsed(offset: 23), + composing: TextRange(start: 23, end: 26), + ), + ], + getter: imeClientGetter, + ); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure the paragraph was split. + expect( + (doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), + 'Before the line break ', + ); + + // Ensure the paragraph was split. + expect( + (doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), + 'new line', + ); + + // Ensure the selection is at the beginning of the second node. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.getNodeAt(1)!.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnAndroid('maintains correct selection after merging paragraphs', (tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +Paragraph one + +Paragraph two +''') + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place caret at the start of the second paragraph. + await tester.placeCaretInParagraph(doc.getNodeAt(1)!.id, 0); + + // Sends the deletion delta followed by non-text deltas. + // + // This deletion will cause the two paragraphs to be merged. + await tester.ime.sendDeltas( + const [ + TextEditingDeltaNonTextUpdate( + oldText: '. Paragraph two', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaNonTextUpdate( + oldText: 'Paragraph two', + selection: TextSelection.collapsed(offset: 0), + composing: TextRange(start: 2, end: 11), + ), + TextEditingDeltaDeletion( + oldText: '. Paragraph two', + deletedRange: TextRange(start: 1, end: 2), + selection: TextSelection.collapsed(offset: 1), + composing: TextRange(start: -1, end: -1), + ), + ], + getter: imeClientGetter, + ); + + // Ensure the paragraph was merged. + expect( + (doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), + 'Paragraph oneParagraph two', + ); + + // Ensure the selection is at "Paragraph one|Paragraph two". + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.getNodeAt(0)!.id, + nodePosition: const TextNodePosition(offset: 13), + ), + ), + ); + }); + }); + + group('on Samsung M51 (Android 12 SP1A)', () { + testWidgetsOnAndroid('applies keyboard suggestions', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the start of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Start typing the word "Anonymous" with typos. + await tester.typeImeText('Anonimoi'); + + // Simulate the user accepting a suggestion. + // The IME replaces the word and inserts a space after it. + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. Anonimoi', + selection: TextSelection.collapsed( + offset: 10, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: 2, end: 10), + ), + TextEditingDeltaReplacement( + oldText: '. Anonimoi', + replacementText: 'Anonymous', + replacedRange: TextRange(start: 2, end: 10), + selection: TextSelection.collapsed(offset: 11, affinity: TextAffinity.downstream), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaInsertion( + oldText: '. Anonymous', + textInserted: ' ', + insertionOffset: 11, + selection: TextSelection.collapsed( + offset: 12, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: -1, end: -1), + ) + ], getter: imeClientGetter); + + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'Anonymous ', + ); + }); + }); + + group('on Samsung M51 (Android 12 SP1A) with GBoard', () { + testWidgetsOnAndroid('applies keyboard suggestions', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the start of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Start typing the word "Anonymous" with typos. + await tester.typeImeText('Anonimoi'); + + // Simulate the user accepting a suggestion. + // The IME deletes the substring "imoi" and inserts "ymous ". + await tester.ime.sendDeltas(const [ + TextEditingDeltaDeletion( + oldText: '. Anonimoi', + deletedRange: TextRange(start: 6, end: 10), + selection: TextSelection.collapsed( + offset: 4, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaInsertion( + oldText: '. Anon', + textInserted: 'ymous ', + insertionOffset: 6, + selection: TextSelection.collapsed( + offset: 12, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'Anonymous ', + ); + }); + }); + + group('on Samsung', () { + testWidgetsOnAndroid('handles out of order newline followed by delta suggestion application', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the start of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Start typing the word "Anonymous" with typos. + await tester.typeImeText('Anonimoi'); + + // Simulate the user pressing "newline", which on Samsung results in reporting + // the ENTER hardware key, followed by the suggestion deltas. Notice that these + // two events are in the wrong order! + await tester.pressEnter(); + + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. Anonimoi', + selection: TextSelection.collapsed( + offset: 10, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: 2, end: 10), + ), + TextEditingDeltaNonTextUpdate( + oldText: '. Anonimoi', + selection: TextSelection.collapsed( + offset: 10, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: 2, end: 10), + ), + TextEditingDeltaReplacement( + oldText: '. Anonimoi', + replacementText: 'Anonymous', + replacedRange: TextRange(start: 2, end: 10), + selection: TextSelection.collapsed(offset: 11, affinity: TextAffinity.downstream), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + final document = SuperEditorInspector.findDocument()!; + expect(document.length, 2); + // Note: We expect the mis-spelled word to remain because we couldn't apply + // the suggestion due to receiving events in the wrong order. + expect((document.first as TextNode).text.toPlainText(), 'Anonimoi'); + expect((document.last as TextNode).text.toPlainText(), ''); + }); + }); + + group('GBoard >', () { + testWidgetsOnAndroid('can insert newline into empty paragraph', (tester) async { + // Verifies fix for GBoard empty paragraph newline bug: + // https://github.com/Flutter-Bounty-Hunters/super_editor/issues/2981 + + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the start of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Simulate press of the newline button. + // + // On GBoard this is reported as the ENTER key, followed by corrective dangling + // space removal deltas. Technically those deltas are sent out of order. This is + // because the empty paragraph is encoded as ". ". + await tester.pressEnter(); + + await tester.ime.sendDeltas(const [ + // GBoard seems to send a bunch of identical non-text updates. + TextEditingDeltaNonTextUpdate( + oldText: '. ', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange.empty, + ), + TextEditingDeltaNonTextUpdate( + oldText: '. ', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange.empty, + ), + TextEditingDeltaNonTextUpdate( + oldText: '. ', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange.empty, + ), + TextEditingDeltaNonTextUpdate( + oldText: '. ', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange.empty, + ), + // After the non-text updates, there's a deletion delta that tries to remove the space. + TextEditingDeltaDeletion( + oldText: '. ', + deletedRange: TextRange(start: 1, end: 2), + selection: TextSelection.collapsed( + offset: 1, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + final document = SuperEditorInspector.findDocument(); + expect(document, isNotNull); + expect(document!.length, 2); + + expect(document.first, isA()); + expect((document.first as TextNode).text.toPlainText(), ""); + + expect(document.last, isA()); + expect((document.last as TextNode).text.toPlainText(), ""); + }); + }); + }); + + group('iPhone >', () { + testWidgetsOnIos('can backspace an empty paragraph with deletion delta', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + // Place the caret in the first paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Create a second empty paragraph. + await tester.pressEnterWithIme(getter: imeClientGetter); + expect(SuperEditorInspector.findDocument()!.length, 2); + + // Backspace to delete the 2nd empty paragraph. We run this deletion + // as its reported on iOS, to make sure that the iOS delta deletion + // approach doesn't conflict with our logic to ignore GBoard trailing + // space removal. + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. ', + selection: TextSelection(baseOffset: 1, extentOffset: 2), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaDeletion( + oldText: '. ', + deletedRange: TextRange(start: 1, end: 2), + selection: TextSelection.collapsed( + offset: 1, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Ensure that we deleted the 2nd paragraph and moved back up to the first. + final document = SuperEditorInspector.findDocument()!; + expect(document.length, 1); + expect(document.first.id, "1"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), + ), + ); + }); + + group('on iPhone 11 (iOS 13.7) with chinese keyboard', () { + testWidgetsOnIos('applies keyboard suggestions', (tester) async { + // Holds the composing region that we sent to the IME. + TextRange? composingRegion; + + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the start of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Simulate the user typing "a a a". + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. ', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaInsertion( + oldText: '. ', + textInserted: 'a', + insertionOffset: 2, + selection: TextSelection.collapsed( + offset: 3, + affinity: TextAffinity.upstream, + ), + composing: TextRange(start: 2, end: 3), + ), + ], getter: imeClientGetter); + await tester.ime.sendDeltas(const [ + TextEditingDeltaInsertion( + oldText: '. a', + textInserted: ' a', + insertionOffset: 3, + selection: TextSelection.collapsed( + offset: 5, + affinity: TextAffinity.upstream, + ), + composing: TextRange(start: 2, end: 5), + ), + ], getter: imeClientGetter); + await tester.ime.sendDeltas(const [ + TextEditingDeltaInsertion( + oldText: '. a a', + textInserted: ' a', + insertionOffset: 5, + selection: TextSelection.collapsed( + offset: 7, + affinity: TextAffinity.upstream, + ), + composing: TextRange(start: 2, end: 7), + ), + ], getter: imeClientGetter); + + // Simulate the user accepting the suggestion from the keyboard. + // + // The IME sends the replacement and changes the composing region in the same frame, + // in separate delta batches. + final imeClient = imeClientGetter(); + imeClient.updateEditingValueWithDeltas(const [ + TextEditingDeltaReplacement( + oldText: '. a a a', + replacementText: '呵呵呵', + replacedRange: TextRange(start: 2, end: 7), + selection: TextSelection.collapsed( + offset: 5, + affinity: TextAffinity.upstream, + ), + composing: TextRange(start: 2, end: 5), + ), + ]); + + // Intercept the setEditingState message sent to the platform so we can check + // which composing region was sent. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + final params = methodCall.arguments as Map; + composingRegion = TextRange( + start: params['composingBase'], + end: params['composingExtent'], + ); + return null; + }, + ); + + imeClient.updateEditingValueWithDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. 呵呵呵', + selection: TextSelection.collapsed( + offset: 5, + affinity: TextAffinity.upstream, + ), + composing: TextRange.empty, + ), + ]); + await tester.pump(); + + // Between the two updateEditingValueWithDeltas calls, the IME interactor + // sends [0, 3) as the new composing region (the composing region of the first delta) to the IME. + // + // If the user types with that composing region, all the existing text is replaced. + // + // Ensure we cleared the composing region on the IME so the previous entered text is preserved. + expect(composingRegion, TextRange.empty); + }); + + testWidgetsOnIos('applies keyboard suggestions and keeps styles', (tester) async { + // Pump an editor with a bold text. + final testContext = await tester // + .createDocument() + .fromMarkdown('**Fix**') + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the end of the paragraph. + await tester.placeCaretInParagraph(testContext.document.first.id, 3); + + // Type a letter simulating a typo. The current text results in "Fixs". + await tester.typeImeText('s'); + + // Simulate the user accepting a suggestion. + // The IME replaces the word and inserts a space after it. + await tester.ime.sendDeltas([ + const TextEditingDeltaReplacement( + oldText: '. Fixs', + replacementText: 'Fixed', + replacedRange: TextRange(start: 2, end: 6), + selection: TextSelection.collapsed(offset: 7), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + await tester.ime.sendDeltas([ + const TextEditingDeltaInsertion( + oldText: '. Fixed', + textInserted: ' ', + insertionOffset: 7, + selection: TextSelection.collapsed(offset: 8), + composing: TextRange(start: -1, end: -1), + ) + ], getter: imeClientGetter); + + // Ensure the text was replaced and the style was preserved. + expect(testContext.document, equalsMarkdown('**Fixed **')); + }); + }); + + group('on iPhone 13 (iOS 17.2) with korean keyboard', () { + testWidgetsOnIos('applies keyboard suggestions', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the start of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Simulate the user typing "ㅅ". + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. ', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaInsertion( + oldText: '. ', + textInserted: 'ㅅ', + insertionOffset: 2, + selection: TextSelection.collapsed(offset: 3, affinity: TextAffinity.downstream), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Simulate the user typing "ㅛ" and the IME converting the "ㅅㅛ" to "쇼". + await tester.ime.sendDeltas(const [ + TextEditingDeltaNonTextUpdate( + oldText: '. ㅅ', + selection: TextSelection(baseOffset: 1, extentOffset: 3, isDirectional: false), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaDeletion( + oldText: '. ㅅ', + deletedRange: TextRange(start: 1, end: 3), + selection: TextSelection.collapsed( + offset: 1, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaInsertion( + oldText: '.', + textInserted: ' ', + insertionOffset: 1, + selection: TextSelection.collapsed( + offset: 2, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaInsertion( + oldText: '. ', + textInserted: '쇼', + insertionOffset: 2, + selection: TextSelection.collapsed( + offset: 3, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: -1, end: -1), + ) + ], getter: imeClientGetter); + + // Ensure text and selection were updated. + expect(SuperEditorInspector.findTextInComponent('1').toPlainText(), '쇼'); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 1), + ), + ), + ), + ); + }); + }); + + group('on iPhone 15 (iOS 17.5)', () { + testWidgetsOnIos('ignores keyboard suggestions when pressing the newline button', (tester) async { + final testContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the start of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Type some text. + await tester.typeImeText('run tom'); + + // Press the new line button. + await tester.testTextInput.receiveAction(TextInputAction.newline); + + // Simulate the IME sending a delta replacing "tom" with "Tom". + // At this point, we already added a new paragraph to the document, + // so these text ranges are invalid for us. + await tester.ime.sendDeltas([ + const TextEditingDeltaReplacement( + oldText: '. Run tom', + replacementText: 'Tom', + replacedRange: TextRange(start: 6, end: 9), + selection: TextSelection.collapsed(offset: 9), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + await tester.ime.sendDeltas([ + const TextEditingDeltaInsertion( + oldText: '. Run Tom', + textInserted: '\n', + insertionOffset: 9, + selection: TextSelection.collapsed( + offset: 10, + affinity: TextAffinity.downstream, + ), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + await tester.pump(); + + final document = testContext.document; + + // Ensure the replacement was ignored and a new empty node was added. + expect(document.nodeCount, 2); + expect((document.getNodeAt(0)! as TextNode).text.toPlainText(), 'run tom'); + expect((document.getNodeAt(1)! as TextNode).text.toPlainText(), ''); + }); + }); + }); + + group('moves caret', () { + testWidgetsOnDesktopAndWeb('to end of previous node when LEFT_ARROW is pressed at the beginning of a paragraph', + (tester) async { + await tester + .createDocument() // + .withLongDoc() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Place the caret at the beginning of the second paragraph. + await tester.placeCaretInParagraph('2', 0); + + // Press left arrow to move to the previous node. + await tester.pressLeftArrow(); + + // Ensure the caret sits at the end of the first paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 439), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnDesktopAndWeb('to the beginning of next node when RIGHT_ARROW is pressed at the end of a paragraph', + (tester) async { + await tester + .createDocument() // + .withLongDoc() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Place the caret at the end of the first paragraph. + await tester.placeCaretInParagraph('1', 439); + + // Press right arrow to move to the next node. + await tester.pressRightArrow(); + + // Ensure the caret sits at the beginning of the second paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }, variant: inputSourceVariant); + }); + + testWidgetsOnWebDesktop('inside a CustomScrollView > inserts space instead of scrolling with SPACEBAR', + (tester) async { + final testContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .insideCustomScrollView() + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = testContext.document.first.id; + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph(nodeId, 0); + + // Press space to insert a space character. + await _typeSpaceAdaptive(tester); + + // Ensure the space character was inserted. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), ' '); + }); + + testWidgetsOnWebDesktop('deletes a character with backspace', (tester) async { + final testContext = await tester // + .createDocument() + .fromMarkdown('This is a paragraph') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = testContext.document.first.id; + + // Place the caret at the end of the paragraph. + await tester.placeCaretInParagraph(nodeId, 19); + + // Simulate the user pressing backspace. + // + // On web, this generates both a key event and a deletion delta. + await tester.pressBackspace(); + await tester.ime.sendDeltas( + [ + const TextEditingDeltaDeletion( + oldText: '. This is a paragraph', + deletedRange: TextRange(start: 20, end: 21), + selection: TextSelection.collapsed(offset: 20), + composing: TextRange.empty, + ), + ], + getter: imeClientGetter, + ); + + // Ensure the last character was deleted. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'This is a paragrap'); + }); + + testWidgetsOnWebDesktop('merges paragraphs backspace at the beginning of a paragraph', (tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +Paragraph one + +Paragraph two +''') + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place caret at the start of the second paragraph. + await tester.placeCaretInParagraph(doc.getNodeAt(1)!.id, 0); + + // Simulates the user pressing BACKSPACE, which generates a deletion delta. + // This deletion will cause the two paragraphs to be merged. + await tester.ime.sendDeltas( + const [ + TextEditingDeltaNonTextUpdate( + oldText: '. Paragraph two', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: -1, end: -1), + ), + TextEditingDeltaDeletion( + oldText: '. Paragraph two', + deletedRange: TextRange(start: 1, end: 2), + selection: TextSelection.collapsed(offset: 1), + composing: TextRange(start: -1, end: -1), + ), + ], + getter: imeClientGetter, + ); + + // Ensure the paragraph was merged. + expect( + (doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), + 'Paragraph oneParagraph two', + ); + }); + + group('text serialization and selected content', () { + test('within a single node is reported as a TextEditingValue', () { + const text = "This is a paragraph of text."; + + _expectTextEditingValue( + actualTextEditingValue: DocumentImeSerializer( + MutableDocument(nodes: [ + ParagraphNode(id: "1", text: AttributedText(text)), + ]), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 19), + ), + ), + null, + ).toTextEditingValue(), + expectedTextWithSelection: ". This is a |paragraph| of text.", + ); + }); + + test('two text nodes is reported as a TextEditingValue', () { + const text1 = "This is the first paragraph of text."; + const text2 = "This is the second paragraph of text."; + + _expectTextEditingValue( + actualTextEditingValue: DocumentImeSerializer( + MutableDocument(nodes: [ + ParagraphNode(id: "1", text: AttributedText(text1)), + ParagraphNode(id: "2", text: AttributedText(text2)), + ]), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 28), + ), + ), + null, + ).toTextEditingValue(), + expectedTextWithSelection: ". This is the |first paragraph of text.\nThis is the second paragraph| of text.", + ); + }); + + test('text with internal non-text reported as a TextEditingValue', () { + const text = "This is a paragraph of text."; + + _expectTextEditingValue( + actualTextEditingValue: DocumentImeSerializer( + MutableDocument(nodes: [ + ParagraphNode(id: "1", text: AttributedText(text)), + HorizontalRuleNode(id: "2"), + ParagraphNode(id: "3", text: AttributedText(text)), + ]), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + extent: DocumentPosition( + nodeId: "3", + nodePosition: TextNodePosition(offset: 19), + ), + ), + null, + ).toTextEditingValue(), + expectedTextWithSelection: ". This is a |paragraph of text.\n~\nThis is a paragraph| of text.", + ); + }); + + test('text with non-text end-caps reported as a TextEditingValue', () { + const text = "This is the first paragraph of text."; + + _expectTextEditingValue( + actualTextEditingValue: DocumentImeSerializer( + MutableDocument(nodes: [ + HorizontalRuleNode(id: "1"), + ParagraphNode(id: "2", text: AttributedText(text)), + HorizontalRuleNode(id: "3"), + ]), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: "3", + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + null, + ).toTextEditingValue(), + expectedTextWithSelection: ". |~\nThis is the first paragraph of text.\n~|", + ); + }); + + testWidgetsOnArbitraryDesktop('sends selection to platform', (tester) async { + final context = await tester // + .createDocument() + .withSingleParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place caret at Lorem| ipsum. + await tester.placeCaretInParagraph('1', 5); + + int selectionBase = -1; + int selectionExtent = -1; + String selectionAffinity = ""; + + // Intercept messages sent to the platform. + tester.binding.defaultBinaryMessenger.setMockMessageHandler(SystemChannels.textInput.name, (message) async { + final methodCall = const JSONMethodCodec().decodeMethodCall(message); + if (methodCall.method == 'TextInput.setEditingState') { + selectionBase = methodCall.arguments['selectionBase']; + selectionExtent = methodCall.arguments['selectionExtent']; + selectionAffinity = methodCall.arguments['selectionAffinity']; + } + return null; + }); + + // Press shift+left to expand the selection upstream. + await tester.pressShiftLeftArrow(); + + final selection = SuperEditorInspector.findDocumentSelection()!; + final base = (selection.base.nodePosition as TextNodePosition).offset; + final extent = (selection.extent.nodePosition as TextNodePosition).offset; + final affinity = context.findEditContext().document.getAffinityForSelection(selection); + + // Ensure we sent the same base, extent and affinity to the platform. + // Add two to account for the invisble characters prepended to the text. + expect(selectionBase, base + 2); + expect(selectionExtent, extent + 2); + expect(selectionAffinity, affinity.toString()); + }); + }); + + group('typing characters near a link', () { + testWidgetsOnMobile('does not expand the link when inserting before the link', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .withCustomContent(_singleParagraphWithLinkDoc()) + .pump(); + + // Place the caret at the start of the link. + await tester.placeCaretInParagraph('1', 0); + + // Type characters before the link using the IME + await tester.ime.typeText("Go to ", getter: imeClientGetter); + + // Ensure that the link is unchanged + expect( + SuperEditorInspector.findDocument(), + equalsMarkdown("Go to [https://google.com](https://google.com)"), + ); + }); + + testWidgetsOnMobile('does not expand the link when inserting after the link', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .withCustomContent(_singleParagraphWithLinkDoc()) + .pump(); + + // Place the caret at the end of the link. + await tester.placeCaretInParagraph('1', 18); + + // Type characters after the link using the IME + await tester.ime.typeText(" to learn anything", getter: imeClientGetter); + + // Ensure that the link is unchanged + expect( + SuperEditorInspector.findDocument(), + equalsMarkdown("[https://google.com](https://google.com) to learn anything"), + ); + }); + }); + + group('applies keyboard appearance', () { + testWidgetsOnIos('dark from theme', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .useAppTheme(ThemeData.dark()) + .pump(); + + // Holds the keyboard appearance sent to the platform. + String? keyboardAppearance; + + _interceptKeyboardAppearanceSentToPlatform( + tester, + (appearance) => keyboardAppearance = appearance, + ); + + // Place the caret at the empty paragraph to trigger the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the given keyboardAppearance was applied. + expect(keyboardAppearance, 'Brightness.dark'); + }); + + testWidgetsOnIos('light from theme', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .useAppTheme(ThemeData.light()) + .pump(); + + // Holds the keyboard appearance sent to the platform. + String? keyboardAppearance; + + _interceptKeyboardAppearanceSentToPlatform( + tester, + (appearance) => keyboardAppearance = appearance, + ); + + // Place the caret at the empty paragraph to trigger the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the given keyboardAppearance was applied. + expect(keyboardAppearance, 'Brightness.light'); + }); + + testWidgetsOnIos('from the given configuration', (tester) async { + // Pump an editor with a light theme to ensure we are a configuration + // with different brightness. + await tester // + .createDocument() + .withSingleEmptyParagraph() + .useAppTheme(ThemeData.light()) + .withImeConfiguration( + const SuperEditorImeConfiguration( + keyboardBrightness: Brightness.dark, + ), + ) + .pump(); + + // Holds the keyboard appearance sent to the platform. + String? keyboardAppearance; + + _interceptKeyboardAppearanceSentToPlatform( + tester, + (appearance) => keyboardAppearance = appearance, + ); + + // Place the caret at the empty paragraph to trigger the software keyboard. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the given keyboardAppearance was applied. + expect(keyboardAppearance, 'Brightness.dark'); + }); + }); + + testWidgetsOnMobile('opens software keyboard when tapping on caret', (tester) async { + await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at "Lorem| ipsum". + await tester.placeCaretInParagraph('1', 5); + + // Hide the software keyboard using the system button. + tester.testTextInput.hide(); + + bool wasKeyboardShown = false; + + // Intercept the messages sent to the platform to check if + // we showed the software keyboard. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.show', + (methodCall) { + wasKeyboardShown = true; + + return null; + }, + ); + + // Tap again on the same selected position. + await tester.placeCaretInParagraph('1', 5); + + // Ensure the keyboard was shown. + expect(wasKeyboardShown, isTrue); + }); + + testWidgetsOnIos('opens software keyboard when tapping on an expanded selection', (tester) async { + await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Double tap to select "|Lorem| ipsum". + await tester.doubleTapInParagraph('1', 1); + + // Hide the software keyboard using the system button. + tester.testTextInput.hide(); + + bool wasKeyboardShown = false; + + // Intercept the messages sent to the platform to check if + // we showed the software keyboard. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.show', + (methodCall) { + wasKeyboardShown = true; + + return null; + }, + ); + + // Tap somewhere on the existing selection. + await tester.tapInParagraph('1', 3); + + // Ensure the keyboard was shown. + expect(wasKeyboardShown, isTrue); + }); + + testWidgetsOnAllPlatforms('applies viewId when attaching to the IME', (tester) async { + await tester + .createDocument() // + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Intercept the messages sent to the platform to check if + // we provided the viewId when attaching to the IME. + int? viewId; + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setClient', + (methodCall) { + final textInputConfig = (methodCall.arguments as List)[1] as Map; + viewId = textInputConfig['viewId']; + return null; + }, + ); + + // Place the caret at the beginning of the paragraph to attach to the IME. + await tester.placeCaretInParagraph('1', 0); + + // Ensure we provided a viewId when attaching to the IME. + expect(viewId, isNotNull); + }); + + group('clears composing region', () { + testWidgetsOnAllPlatforms('after losing focus', (tester) async { + final focusNode = FocusNode(); + + final testContext = await tester + .createDocument() // + .withTwoEmptyParagraphs() + .withInputSource(TextInputSource.ime) + .withFocusNode(focusNode) + .pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph('1', 0); + + // Type something to have some text to tap on. + await tester.typeImeText('Composing: '); + + // Ensure we don't have a composing region. + expect(testContext.composer.composingRegion.value, isNull); + + // Simulate an insertion containing a composing region. + await tester.ime.sendDeltas( + [ + const TextEditingDeltaInsertion( + oldText: '. Composing: ', + textInserted: "あs", + insertionOffset: 13, + selection: TextSelection.collapsed(offset: 15), + composing: TextRange(start: 13, end: 15), + ), + ], + getter: imeClientGetter, + ); + + // Ensure the editor applied a composing region. + expect( + testContext.composer.composingRegion.value, + isNotNull, + ); + + // Remove focus from the editor. + focusNode.unfocus(); + await tester.pump(); + + // Ensure the composing region was cleared. + expect(testContext.composer.composingRegion.value, isNull); + }); + + testWidgetsOnAllPlatforms('after selection changes', (tester) async { + final testContext = await tester + .createDocument() // + .withTwoEmptyParagraphs() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph('1', 0); + + // Type something to have some text to tap on. + await tester.typeImeText('Composing: '); + + // Ensure we don't have a composing region. + expect(testContext.composer.composingRegion.value, isNull); + + // Simulate an insertion containing a composing region. + await tester.ime.sendDeltas( + [ + const TextEditingDeltaInsertion( + oldText: '. Composing: ', + textInserted: "あs", + insertionOffset: 13, + selection: TextSelection.collapsed(offset: 15), + composing: TextRange(start: 13, end: 15), + ), + ], + getter: imeClientGetter, + ); + + // Ensure the editor applied a composing region. + expect( + testContext.composer.composingRegion.value, + isNotNull, + ); + + // Intercept the setEditingState message sent to the platform to check if we + // cleared the IME composing region when changing the selection. + int? composingBase; + int? composingExtent; + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + final params = methodCall.arguments as Map; + composingBase = params['composingBase']; + composingExtent = params['composingExtent']; + + return null; + }, + ); + + // Place the caret at the second paragraph. + await tester.placeCaretInParagraph('2', 0); + + // Ensure the composing region was cleared in the IME. + expect(composingBase, -1); + expect(composingExtent, -1); + + // Ensure SuperEditor composing region was cleared. + expect(testContext.composer.composingRegion.value, isNull); + }); + + testWidgetsOnAllPlatforms('when moving the caret up', (tester) async { + // FIXME: When we intercept the text input messages, the test text input + // does not reset some internal state, which causes macOS selectors + // to not be reported. Remove this after this is fixed. + SystemChannels.textInput.invokeMethod("TextInput.hide"); + + final testContext = await tester + .createDocument() // + .fromMarkdown('A\n\nB') + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the end of the second paragraph. + await tester.placeCaretInParagraph(testContext.document.last.id, 1); + + // Place the composing region at the same position as the caret. + final lastCharacterPosition = DocumentPosition( + nodeId: testContext.document.last.id, + nodePosition: const TextNodePosition(offset: 1), + ); + testContext.editor.execute([ + ChangeComposingRegionRequest( + DocumentRange( + start: lastCharacterPosition, + end: lastCharacterPosition, + ), + ), + ]); + await tester.pump(); + + // Ensure we have a composing region. + expect(testContext.composer.composingRegion.value, isNotNull); + + // Intercept the setEditingState message sent to the platform to check if we + // cleared the IME composing region after moving the caret up. + int? composingBase; + int? composingExtent; + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + final params = methodCall.arguments as Map; + composingBase = params['composingBase']; + composingExtent = params['composingExtent']; + + return null; + }, + ); + + // Press up arrow to move the caret to the first paragraph. + await tester.pressUpArrow(); + + // Ensure SuperEditor composing region was cleared. + expect(testContext.composer.composingRegion.value, isNull); + + // Ensure the composing region was cleared in the IME. + expect(composingBase, -1); + expect(composingExtent, -1); + }); + + testWidgetsOnAllPlatforms('when moving the caret upstream', (tester) async { + // FIXME: When we intercept the text input messages, the test text input + // does not reset some internal state, which causes macOS selectors + // to not be reported. Remove this after this is fixed. + SystemChannels.textInput.invokeMethod("TextInput.hide"); + + final testContext = await tester + .createDocument() // + .fromMarkdown('A\n\nB') + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the second paragraph. + await tester.placeCaretInParagraph(testContext.document.last.id, 0); + + // Place the composing region at the same position as the caret. + final lastCharacterPosition = DocumentPosition( + nodeId: testContext.document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ); + testContext.editor.execute([ + ChangeComposingRegionRequest( + DocumentRange( + start: lastCharacterPosition, + end: lastCharacterPosition, + ), + ), + ]); + await tester.pump(); + + // Ensure we have a composing region. + expect(testContext.composer.composingRegion.value, isNotNull); + + // Intercept the setEditingState message sent to the platform to check if we + // cleared the IME composing region when merging paragraphs. + int? composingBase; + int? composingExtent; + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + final params = methodCall.arguments as Map; + composingBase = params['composingBase']; + composingExtent = params['composingExtent']; + + return null; + }, + ); + + // Press up arrow to move the caret to the first paragraph. + await tester.pressLeftArrow(); + + // Ensure the composing region was cleared in the IME. + expect(composingBase, -1); + expect(composingExtent, -1); + + // Ensure SuperEditor composing region was cleared. + expect(testContext.composer.composingRegion.value, isNull); + }); + + testWidgetsOnAllPlatforms('when moving the caret down', (tester) async { + // FIXME: When we intercept the text input messages, the test text input + // does not reset some internal state, which causes macOS selectors + // to not be reported. Remove this after this is fixed. + SystemChannels.textInput.invokeMethod("TextInput.hide"); + + final testContext = await tester + .createDocument() // + .fromMarkdown('A\n\nC') + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the end of first paragraph. + await tester.placeCaretInParagraph(testContext.document.first.id, 1); + + // Place the composing region at the same position as the caret. + final lastCharacterPosition = DocumentPosition( + nodeId: testContext.document.first.id, + nodePosition: const TextNodePosition(offset: 1), + ); + testContext.editor.execute([ + ChangeComposingRegionRequest( + DocumentRange( + start: lastCharacterPosition, + end: lastCharacterPosition, + ), + ), + ]); + await tester.pump(); + + // Ensure we have a composing region. + expect(testContext.composer.composingRegion.value, isNotNull); + + // Intercept the setEditingState message sent to the platform to check if we + // cleared the IME composing region when merging paragraphs. + int? composingBase; + int? composingExtent; + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + final params = methodCall.arguments as Map; + composingBase = params['composingBase']; + composingExtent = params['composingExtent']; + + return null; + }, + ); + + // Press down arrow to move the caret to the second paragraph. + await tester.pressDownArrow(); + + // Ensure the composing region was cleared in the IME. + expect(composingBase, -1); + expect(composingExtent, -1); + + // Ensure SuperEditor composing region was cleared. + expect(testContext.composer.composingRegion.value, isNull); + }); + + testWidgetsOnAllPlatforms('when moving the caret downstream', (tester) async { + // FIXME: When we intercept the text input messages, the test text input + // does not reset some internal state, which causes macOS selectors + // to not be reported. Remove this after this is fixed. + SystemChannels.textInput.invokeMethod("TextInput.hide"); + + final testContext = await tester + .createDocument() // + .fromMarkdown('A\n\nB') + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the end of the first paragraph. + await tester.placeCaretInParagraph(testContext.document.first.id, 1); + + // Place the composing region at the same position as the caret. + final lastCharacterPosition = DocumentPosition( + nodeId: testContext.document.first.id, + nodePosition: const TextNodePosition(offset: 1), + ); + testContext.editor.execute([ + ChangeComposingRegionRequest( + DocumentRange( + start: lastCharacterPosition, + end: lastCharacterPosition, + ), + ), + ]); + await tester.pump(); + + // Ensure we have a composing region. + expect(testContext.composer.composingRegion.value, isNotNull); + + // Intercept the setEditingState message sent to the platform to check if we + // cleared the IME composing region when merging paragraphs. + int? composingBase; + int? composingExtent; + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + final params = methodCall.arguments as Map; + composingBase = params['composingBase']; + composingExtent = params['composingExtent']; + + return null; + }, + ); + + // Press right arrow to move the caret to the second paragraph. + await tester.pressRightArrow(); + + // Ensure the composing region was cleared in the IME. + expect(composingBase, -1); + expect(composingExtent, -1); + + // Ensure SuperEditor composing region was cleared. + expect(testContext.composer.composingRegion.value, isNull); + }); + + testWidgetsOnMac('after merging paragraphs', (tester) async { + final testContext = await tester + .createDocument() // + .withTwoEmptyParagraphs() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the second paragraph. + await tester.placeCaretInParagraph('2', 0); + + // Ensure we don't have a composing region. + expect(testContext.composer.composingRegion.value, isNull); + + // Simulate an insertion containing a composing region. + await tester.ime.sendDeltas( + [ + const TextEditingDeltaNonTextUpdate( + oldText: '. ', + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: 2, end: 2), + ), + const TextEditingDeltaInsertion( + oldText: '. ', + textInserted: 'あ', + insertionOffset: 2, + selection: TextSelection.collapsed(offset: 3), + composing: TextRange(start: 2, end: 3), + ), + ], + getter: imeClientGetter, + ); + + // Ensure the editor applied the composing region. + expect( + testContext.composer.composingRegion.value, + isNotNull, + ); + + // Intercept the setEditingState message sent to the platform to check if we + // cleared the IME composing region when merging paragraphs. + int? composingBase; + int? composingExtent; + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + final params = methodCall.arguments as Map; + composingBase = params['composingBase']; + composingExtent = params['composingExtent']; + + return null; + }, + ); + + // Simulate the user pressing BACKSPACE to delete the first character. + // Even though the selection sits after a whitespace in the IME, mac still reports + // a composing region starting after the space. + await tester.ime.sendDeltas( + [ + const TextEditingDeltaDeletion( + oldText: '. あ', + deletedRange: TextRange(start: 2, end: 3), + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: 2, end: 2), + ), + ], + getter: imeClientGetter, + ); + + // Ensure we still have a composing region in the editor. + expect( + testContext.composer.composingRegion.value, + isNotNull, + ); + + // Simulate the user pressing BACKSPACE to merge the paragraphs. + // Now that we are deleting a whitespace, mac reports a deleteBackward: selector + // instead of a deletion delta. + await _receiveSelector('deleteBackward:'); + await tester.pump(); + + // Ensure the composing region was cleared in the IME. + expect(composingBase, -1); + expect(composingExtent, -1); + + // Ensure SuperEditor composing region was cleared. + expect(testContext.composer.composingRegion.value, isNull); + + // Ensure the paragraphs were merged. + expect(testContext.document.nodeCount, equals(1)); + }); + }); + }); +} + +/// Intercepts `TextInput.setClient` calls and invokes [onSetKeyboard] +/// with the configured keyboard keyboardAppearance. +void _interceptKeyboardAppearanceSentToPlatform( + WidgetTester tester, void Function(String keyboardAppearance) onSetKeyboard) { + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setClient', + (methodCall) { + final params = methodCall.arguments[1] as Map; + onSetKeyboard(params['keyboardAppearance']); + return null; + }, + ); +} + +/// Expects that the given [expectedTextWithSelection] corresponds to a +/// `TextEditingValue` that matches [actualTextEditingValue]. +/// +/// By combining the expected text with the expected selection into a formatted +/// `String`, this method provides a naturally readable expectation, as opposed +/// to a `TextSelection` with indices. For example, if the expected selection is +/// `TextSelection(base: 10, extent: 19)`, what segment of text does that include? +/// Instead, the caller provides a formatted `String`, like "Here is so|me text w|ith selection". +/// +/// [expectedTextWithSelection] represents the expected text, and the expected +/// selection, all in one. The text within [expectedTextWithSelection] that +/// should be selected should be surrounded with "|" vertical bars. +/// +/// Example: +/// +/// This is expected text, and |this is the expected selection|. +/// +/// This method doesn't work with text that actually contains "|" vertical bars. +void _expectTextEditingValue({ + required String expectedTextWithSelection, + required TextEditingValue actualTextEditingValue, +}) { + final selectionStartIndex = expectedTextWithSelection.indexOf("|"); + final selectionEndIndex = + expectedTextWithSelection.indexOf("|", selectionStartIndex + 1) - 1; // -1 to account for the selection start "|" + final expectedText = expectedTextWithSelection.replaceAll("|", ""); + final expectedSelection = TextSelection(baseOffset: selectionStartIndex, extentOffset: selectionEndIndex); + + expect( + actualTextEditingValue, + TextEditingValue(text: expectedText, selection: expectedSelection), + ); +} + +MutableDocument _singleParagraphWithLinkDoc() { + return MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "https://google.com", + AttributedSpans( + attributions: [ + SpanMarker( + attribution: LinkAttribution.fromUri(Uri.parse('https://google.com')), + offset: 0, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: LinkAttribution.fromUri(Uri.parse('https://google.com')), + offset: 17, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ) + ], + ); +} + +class _TestImeOverrides extends DeltaTextInputClientDecorator { + _TestImeOverrides(this.performActionCallback); + + final void Function(TextInputAction) performActionCallback; + + @override + void performAction(TextInputAction action) { + performActionCallback(action); + } +} + +/// Simulates pressing the SPACE key. +/// +/// First, this method simulates pressing the SPACE key on a physical keyboard. If that key event goes unhandled +/// then this method generates an insertion delta of " ". +/// +// TODO: extract this to the flutter_test_robots package. +Future _typeSpaceAdaptive(WidgetTester tester) async { + final handled = await tester.sendKeyEvent(LogicalKeyboardKey.space); + + if (handled) { + await tester.pumpAndSettle(); + return; + } + + await tester.typeImeText(' '); +} + +/// Simulates a `TextInputClient.performSelectors` call from the platform. +Future _receiveSelector(String selectorName) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + "TextInputClient.performSelectors", + [ + -1, + [selectorName], + ], + ), + ), + null, + ); +} diff --git a/super_editor/test/super_editor/supereditor_input_keyboard_actions_test.dart b/super_editor/test/super_editor/supereditor_input_keyboard_actions_test.dart new file mode 100644 index 0000000000..2c2a3d5144 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_input_keyboard_actions_test.dart @@ -0,0 +1,2781 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +import '../test_runners.dart'; +import '../test_tools.dart'; + +void main() { + group('Super Editor keyboard actions', () { + group("movement >", () { + group("Mac and iOS >", () { + group("jumps to", () { + testWidgetsOnApple('beginning of line with CMD + LEFT ARROW', (tester) async { + // Start the user's selection somewhere after the beginning of the first + // line in the first node. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressCmdLeftArrow(); + + // Ensure that the caret moved to the beginning of the line. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnApple('end of line with CMD + RIGHT ARROW', (tester) async { + // Start the user's selection somewhere before the end of the first line + // in the first node. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressCmdRightArrow(); + + // Ensure that the caret moved to the end of the line. This value + // is very fragile. If the text size or layout width changes, this value + // will also need to change. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 27), + ), + ), + ); + }); + + testWidgetsOnApple('beginning of word with ALT + LEFT ARROW', (tester) async { + // Start the user's selection somewhere in the middle of a word. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressAltLeftArrow(); + + // Ensure that the caret moved to the beginning of the word. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + }); + + testWidgetsOnApple('end of word with ALT + RIGHT ARROW', (tester) async { + // Start the user's selection somewhere in the middle of a word. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressAltRightArrow(); + + // Ensure that the caret moved to the beginning of the word. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 11), + ), + ), + ); + }); + + testWidgetsOnApple('beginning of paragraph with OPTION + UP ARROW', (tester) async { + await _pumpTwoParagraphsTestApp( + tester, + inputSource: inputSourceVariant.currentValue!, + ); + + // Place caret at the end of the second paragraph. + await tester.placeCaretInParagraph('2', 36); + + // Press option + up arrow. + await tester.pressAltUpArrow(); + + // Ensure that the caret moved to the beginning of the paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple('end of paragraph with OPTION + DOWN ARROW', (tester) async { + await _pumpTwoParagraphsTestApp( + tester, + inputSource: inputSourceVariant.currentValue!, + ); + + // Place caret at the beginning of the first paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Press option + down arrow. + await tester.pressAltDownArrow(); + + // Ensure that the caret moved to the beginning of the paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 35), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple('beginning of document with CMD + UP ARROW', (tester) async { + await _pumpTwoParagraphsTestApp( + tester, + inputSource: inputSourceVariant.currentValue!, + ); + + // Place caret at the end of the second paragraph. + await tester.placeCaretInParagraph('2', 36); + + await tester.pressCmdUpArrow(); + + // Ensure that the caret moved to the beginning of the document. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple('end of document with CMD + DOWN ARROW', (tester) async { + await _pumpTwoParagraphsTestApp( + tester, + inputSource: inputSourceVariant.currentValue!, + ); + + // Place caret at the beginning of the first paragraph. + await tester.placeCaretInParagraph('1', 0); + + await tester.pressCmdDownArrow(); + + // Ensure that the caret moved to the end of the document. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 36), + ), + ), + ); + }, variant: inputSourceVariant); + }); + + group("expands to", () { + testWidgetsOnApple('beginning of paragraph with SHIFT + OPTION + UP ARROW', (tester) async { + await _pumpTwoParagraphsTestApp( + tester, + inputSource: inputSourceVariant.currentValue!, + ); + + // Place caret at the end of the second paragraph. + await tester.placeCaretInParagraph('2', 36); + + // Press shift option + up arrow. + await _pressShiftAltUpArrow(tester); + + // Ensure that the selection expanded to the beginning of the paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 36, affinity: TextAffinity.upstream), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple('end of paragraph with SHIFT + OPTION + DOWN ARROW', (tester) async { + await _pumpTwoParagraphsTestApp( + tester, + inputSource: inputSourceVariant.currentValue!, + ); + + // Place caret at the beginning of the first paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Press shift + option + down arrow. + await _pressShiftAltDownArrow(tester); + + // Ensure that the selection expanded to the end of the paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 35), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple('beginning of document with SHIFT + CMD + UP ARROW', (tester) async { + await _pumpTwoParagraphsTestApp( + tester, + inputSource: inputSourceVariant.currentValue!, + ); + + // Place caret at the end of the second paragraph. + await tester.placeCaretInParagraph('2', 36); + + await tester.pressShiftCmdUpArrow(); + + // Ensure that the selection expanded to the beginning of the document. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 36, affinity: TextAffinity.upstream), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple('end of document with SHIFT + CMD + DOWN ARROW', (tester) async { + await _pumpTwoParagraphsTestApp( + tester, + inputSource: inputSourceVariant.currentValue!, + ); + + // Place caret at the beginning of the first paragraph. + await tester.placeCaretInParagraph('1', 0); + + await tester.pressShiftCmdDownArrow(); + + // Ensure that the selection expanded to the end of the document. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 36), + ), + ), + ); + }, variant: inputSourceVariant); + }); + + testWidgetsOnApple("option + backspace: deletes a word upstream", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum| dolor sit amet... + await tester.placeCaretInParagraph("1", 11); + + // Press option + backspace + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + + // Ensure that the whole word was deleted. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText().startsWith("Lorem dolor sit amet"), isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple("option + backspace: deletes a word upstream (after a space)", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum |dolor sit amet... + await tester.placeCaretInParagraph("1", 12); + + // Press option + backspace + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + + // Ensure that the whole word was deleted. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText().startsWith("Lorem dolor sit amet"), isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple("option + delete: deletes a word downstream", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum |dolor sit amet... + await tester.placeCaretInParagraph("1", 12); + + // Press option + delete + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + + // Ensure that the whole word was deleted. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText(), startsWith("Lorem ipsum sit amet")); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple("option + delete: deletes a word downstream (before a space)", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum| dolor sit amet... + await tester.placeCaretInParagraph("1", 11); + + // Press option + delete + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + + // Ensure that the whole word was deleted. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText().startsWith("Lorem ipsum sit amet"), isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 11), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple("control + backspace: deletes a single upstream character", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum| dolor sit amet... + await tester.placeCaretInParagraph("1", 11); + + // Press control + backspace + await tester.pressCtlBackspace(); + + // Ensure that a character was deleted. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText(), startsWith("Lorem ipsu dolor sit amet")); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnApple("control + delete: deletes a single downstream character", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum| dolor sit amet... + await tester.placeCaretInParagraph("1", 11); + + // Press control + delete + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + + // Ensure that a character was deleted. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText().startsWith("Lorem ipsumdolor sit amet"), isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 11), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnMacDesktopAndWeb('with CMD + LEFT ARROW at the beginning of a paragraph', (tester) async { + await tester // + .createDocument() + .withLongTextContent() + .pump(); + + // Place caret at the beginning of the second paragraph. + await tester.placeCaretInParagraph('2', 0); + + // Press the key combo to move to the beginning of the line. + await tester.pressCmdLeftArrow(); + + // Ensure that the caret didn't move, since we are already at the beginning. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + + testWidgetsOnMacDesktopAndWeb('with CMD + RIGHT ARROW at the end of a paragraph', (tester) async { + await tester // + .createDocument() + .withLongTextContent() + .pump(); + + // Place caret at the end of the first paragraph. + await tester.placeCaretInParagraph('1', 439); + + // Press the key combo to move to the end of the line. + await tester.pressCmdRightArrow(); + + // Ensure that the caret didn't move, since we are already at the end. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 439), + ), + ), + ), + ); + }); + + testWidgetsOnMacDesktopAndWeb( + 'SHIFT + OPTION + LEFT ARROW: deselects word at end of line after selecting the whole line from start to end', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is a paragraph'), + ), + ], + ), + ) + .pump(); + + // Place caret at the beginning of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Press CMD + SHIFT + RIGHT ARROW to expand the selection to the end of the line. + await tester.pressShiftCmdRightArrow(); + + // Ensure that the whole line is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 19), + ), + ), + ), + ); + + // Press SHIFT + OPTION + LEFT ARROW to remove the last word from the selection. + await tester.pressShiftAltLeftArrow(); + + // Ensure that the last word was removed from the selection. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + ), + ), + ); + }); + + testWidgetsOnMacDesktopAndWeb( + 'SHIFT + OPTION + RIGHT ARROW: deselects word at start of line after selecting the whole line from end to start', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is a paragraph'), + ), + ], + ), + ) + .pump(); + + // Place caret at the end of the paragraph. + await tester.placeCaretInParagraph('1', 19); + + // Press CMD + SHIFT + LEFT ARROW to expand the selection to the beginning of the line. + await tester.pressShiftCmdLeftArrow(); + + // Ensure that the whole line is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 19), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + + // Press SHIFT + OPTION + RIGHT ARROW to remove the first word from the selection. + await tester.pressShiftAltRightArrow(); + + // Ensure that the first word was removed from the selection. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 19), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + ), + ); + }); + }); + + group("Windows and Linux >", () { + group("jumps to", () { + testWidgetsOnWindowsAndLinux('beginning of line with HOME in an auto-wrapping paragraph', (tester) async { + await _pumpAutoWrappingTestSetup(tester); + + // Place caret at the second line at "adipiscing |elit" + // We avoid placing the caret in the first line to make sure HOME doesn't move caret + // all the way to the beginning of the text + await tester.placeCaretInParagraph('1', 51); + + await tester.pressHome(); + + // Ensure that the caret moved to the beginning of the wrapped line at "|adipiscing elit" + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 40), + ), + ), + ); + }); + + testWidgetsOnWindowsAndLinux('beginning of line with HOME in a paragraph with explicit new lines', + (tester) async { + await _pumpExplicitLineBreakTestSetup(tester); + + // Place caret at the second line at "consectetur adipiscing |elit" + // We avoid placing the caret in the first line to make sure HOME doesn't move caret + // all the way to the beginning of the text + await tester.placeCaretInParagraph('1', 51); + + await tester.pressHome(); + + // Ensure that the caret moved to the beginning of the second line at "|consectetur adipiscing elit" + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 27), + ), + ), + ); + }); + + testWidgetsOnWindowsAndLinux('end of line with END in an auto-wrapping paragraph', (tester) async { + await _pumpAutoWrappingTestSetup(tester); + + // Place caret at the start of the first line + // We avoid placing the caret in the last line to make sure END doesn't move caret + // all the way to the end of the text + await tester.placeCaretInParagraph('1', 0); + + await tester.pressEnd(); + + // Ensure that the caret moved to the end of the line. This value + // is very fragile. If the text size or layout width changes, this value + // will also need to change. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + }); + + testWidgetsOnWindowsAndLinux('end of line with END in a paragraph with explicit new lines', (tester) async { + // Configure the screen to a size big enough so there's no auto line-wrapping + await _pumpExplicitLineBreakTestSetup(tester, size: const Size(1024, 400)); + + // Place caret at the first line at "Lorem |ipsum" + // Avoid placing caret in the last line to make sure END doesn't move caret + // all the way to the end of the text + await tester.placeCaretInParagraph('1', 6); + + await tester.pressEnd(); + + // Ensure that the caret moved the end of the first line + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 26, affinity: TextAffinity.upstream), + ), + ), + ); + }); + + testWidgetsOnWindowsAndLinux('beginning of word with CTRL + LEFT ARROW', (tester) async { + // Start the user's selection somewhere in the middle of a word. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressCtlLeftArrow(); + + // Ensure that the caret moved to the beginning of the word. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + }); + + testWidgetsOnWindowsAndLinux('end of word with CTRL + RIGHT ARROW', (tester) async { + // Start the user's selection somewhere in the middle of a word. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressCtlRightArrow(); + + // Ensure that the caret moved to the beginning of the word. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 11), + ), + ), + ); + }); + }); + + testWidgetsOnWindowsAndLinux("control + backspace: deletes a word upstream", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum| dolor sit amet... + await tester.placeCaretInParagraph("1", 11); + + // Press control + backspace + await tester.pressCtlBackspace(); + + // Ensure that the whole word was deleted. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText().startsWith("Lorem dolor sit amet"), isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnWindowsAndLinux("control + backspace: deletes a word upstream (after a space)", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum |dolor sit amet... + await tester.placeCaretInParagraph("1", 12); + + // Press control + backspace + await tester.pressCtlBackspace(); + + // Ensure that the whole word was deleted. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText().startsWith("Lorem dolor sit amet"), isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnWindowsAndLinux("control + delete: deletes a word downstream", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum |dolor sit amet... + await tester.placeCaretInParagraph("1", 12); + + // Press control + delete + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + + // Ensure that the whole word was deleted. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText(), startsWith("Lorem ipsum sit amet")); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnWindowsAndLinux("control + backspace: deletes a word downstream (before a space)", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum| dolor sit amet... + await tester.placeCaretInParagraph("1", 11); + + // Press control + delete + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + + // Ensure that the whole word was deleted. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText().startsWith("Lorem ipsum sit amet"), isTrue); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 11), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnWindowsAndLinux("alt + backspace: deletes upstream character", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum| dolor sit amet... + await tester.placeCaretInParagraph("1", 11); + + // Press alt + backspace + await tester.pressAltBackspace(); + + // Ensure that nothing changed. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText(), startsWith("Lorem ipsu dolor sit amet")); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + ), + ); + }, variant: inputSourceVariant); + + testWidgetsOnWindowsAndLinux("alt + delete: deletes downstream character", (tester) async { + final testContext = await tester + .createDocument() // + .withSingleParagraph() + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + // Lorem ipsum| dolor sit amet... + await tester.placeCaretInParagraph("1", 11); + + // Press alt + delete + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + + // Ensure that nothing changed. + final paragraphNode = testContext.findEditContext().document.first as ParagraphNode; + expect(paragraphNode.text.toPlainText(), startsWith("Lorem ipsumdolor sit amet")); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 11), + ), + ), + ); + }, variant: inputSourceVariant); + }); + + testWidgetsOnDesktop( + "Backspace deletes upstream character and keeps paragraph metadata", + (tester) async { + final testContext = await tester + .createDocument() // + .fromMarkdown('# A header') + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + final document = testContext.document; + + // Place caret at "A| header" + await tester.placeCaretInParagraph(document.first.id, 1); + + // Delete "A". + await tester.pressBackspace(); + + // Ensure the first character was deleted. + expect(document.first.asTextNode.text.toPlainText(), ' header'); + + // Ensure the node is still a header. + expect(document.first.getMetadataValue("blockType"), header1Attribution); + }, + variant: inputSourceVariant, + ); + + testWidgetsOnDesktop( + "Backspace clears metadata at start of a paragraph", + (tester) async { + final testContext = await tester + .createDocument() // + .fromMarkdown('# A header') + .withInputSource(inputSourceVariant.currentValue!) + .pump(); + + final document = testContext.document; + + // Place caret at the start of the header. + await tester.placeCaretInParagraph(document.first.id, 0); + + // Press backspace to clear the metadata. + await tester.pressBackspace(); + + // Ensure the text remains the same. + expect(document.first.asTextNode.text.toPlainText(), 'A header'); + + // Ensure the header was converted to a paragraph. + expect(document.first.getMetadataValue("blockType"), paragraphAttribution); + }, + variant: inputSourceVariant, + ); + + group("jumps to downstream node preserving approximate x-position with DOWN ARROW", () { + testWidgetsOnDesktop('from paragraph to paragraph', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(''' +First paragraph + +Second paragraph''') // + .pump(); + + // Place caret at "First para|graph". + await tester.placeCaretInParagraph(context.document.first.id, 10); + + // Press DOWN arrow to move the caret to the downstream paragraph. + await tester.pressDownArrow(); + + // Ensure the selection moved to "Second par|agraph". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: context.document.last.id, + nodePosition: const TextNodePosition(offset: 10), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from paragraph to task', (tester) async { + await _pumpEditorWithTaskComponent( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode(id: '1', text: AttributedText('This is a paragraph')), + TaskNode(id: '2', text: AttributedText('This is a task'), isComplete: false), + ], + ), + ); + + // Place the caret at "This is a |paragraph". + await tester.placeCaretInParagraph('1', 10); + + // Press down arrow to move the caret to the downstream node. + // + // The text layout of this document is approximately: + // + // This is a paragraph + // [ ]This is a task + // + // So, pressing DOWN should move the caret from "This is a |paragraph" + // to "This is| a task". + await tester.pressDownArrow(); + + // Ensure the caret moved to "This is| a task". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 7), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from paragraph to list item', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(''' +This is a paragraph + +* This is a list item''') // + .pump(); + + // Place the caret at "This is a |paragraph". + await tester.placeCaretInParagraph(context.document.first.id, 10); + + // Press DOWN arrow to move the caret to the downstream node. + // + // The text layout of this document is approximately: + // + // This is a paragraph + // * This is a list item + // + // So, pressing DOWN should move the caret from "This is a |paragraph" + // to "This is |a list item". + await tester.pressDownArrow(); + + // Ensure the selection moved to "This is |a list item". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: context.document.last.id, + nodePosition: const TextNodePosition(offset: 8), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from task to paragraph', (tester) async { + await _pumpEditorWithTaskComponent( + tester, + document: MutableDocument( + nodes: [ + TaskNode(id: '1', text: AttributedText('This is a task'), isComplete: false), + ParagraphNode(id: '2', text: AttributedText('This is a paragraph')), + ], + ), + ); + + // Place the caret at "This| is a task". + await tester.placeCaretInParagraph('1', 4); + + // Press down arrow to move the caret to the downstream node. + // + // The text layout of this document is approximately: + // + // [ ]This is a task + // This is a paragraph + // + // So, pressing DOWN should move the caret from "This| is a task" + // to "This is| a paragraph". + await tester.pressDownArrow(); + + // Ensure the caret moved to "This is| a paragraph." + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 7), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from task to task', (tester) async { + await _pumpEditorWithTaskComponent( + tester, + document: MutableDocument( + nodes: [ + TaskNode(id: '1', text: AttributedText('This is another task'), isComplete: false), + TaskNode(id: '2', text: AttributedText('This is a task'), isComplete: false), + ], + ), + ); + + // Place the caret at "This is a|nother task". + await tester.placeCaretInParagraph('1', 9); + + // Press down arrow to move the caret to the downstream node. + // + // The text layout of this document is approximately: + // + // [ ] This is another task + // [ ] This is a task + // + // So, pressing DOWN should move the caret from "This is a|nother task" + // to "This is a| task". + await tester.pressDownArrow(); + + // Ensure the caret moved to "This is a| task". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 9), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from task to list item', (tester) async { + await _pumpEditorWithTaskComponent( + tester, + document: MutableDocument( + nodes: [ + TaskNode(id: '1', text: AttributedText('This is a task'), isComplete: false), + ListItemNode.unordered(id: '2', text: AttributedText('This is a list item')), + ], + ), + ); + + // Place the caret at "This| is a task". + await tester.placeCaretInParagraph('1', 4); + + // Press down arrow to move the caret to the downstream node. + // + // The text layout of this document is approximately: + // + // [ ]This is a task + // * This is a list item + // + // So, pressing DOWN should move the caret from "This| is a task" + // to "This| is a list item". + await tester.pressDownArrow(); + + // Ensure the caret moved to "This| is a list item". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 4), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from list item to paragraph', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(''' +* This is a list item + +This is a paragraph''') // + .pump(); + + // Place caret at "This is |a list item". + await tester.placeCaretInParagraph(context.document.first.id, 8); + + // Press DOWN arrow to move the caret to the downstream node. + // + // The text layout of this document is approximately: + // + // * This is a list item + // This is a paragraph + // + // So, pressing DOWN should move the caret from "This is |a list item" + // to "This is a |paragraph". + await tester.pressDownArrow(); + + // Ensure the selection moved to "This is a |paragraph". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: context.document.last.id, + nodePosition: const TextNodePosition(offset: 10), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from list item to task', (tester) async { + await _pumpEditorWithTaskComponent( + tester, + document: MutableDocument( + nodes: [ + ListItemNode.unordered(id: '1', text: AttributedText('This is a list item')), + TaskNode(id: '2', text: AttributedText('This is a task'), isComplete: false), + ], + ), + ); + + // Place the caret at "This| is a list item". + await tester.placeCaretInParagraph('1', 4); + + // Press down arrow to move the caret to the downstream node. + // + // The text layout of this document is approximately: + // + // * This is a list item + // [ ]This is a task + // + // So, pressing DOWN should move the caret from "This| is a list item" + // to "This| is a task". + await tester.pressDownArrow(); + + // Ensure the caret moved to "This| is a task". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 4), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from list item to list item', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(''' +* This is another list item + +* This is a list item''') // + .pump(); + + // Place the caret at "This is a|nother list item". + await tester.placeCaretInParagraph(context.document.first.id, 9); + + // Press down arrow to move the caret to the downstream node. + // + // The text layout of this document is approximately: + // + // * This is another list item + // * This is a list item + // + // So, pressing DOWN should move the caret from "This is a|nother list item" + // to "This is a| list item". + await tester.pressDownArrow(); + + // Ensure the caret moved to "This is a| task". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: context.document.last.id, + nodePosition: const TextNodePosition(offset: 9), + ), + ), + ), + ); + }); + }); + + group("jumps to upstream node preserving approximate x-position with UP ARROW", () { + testWidgetsOnDesktop('from paragraph to paragraph', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown('''' +First paragraph + +Second paragraph''') // + .pump(); + + // Place caret at "Second p|aragraph". + await tester.placeCaretInParagraph(context.document.last.id, 8); + + // Press UP arrow to move the caret to the upstream paragraph. + await tester.pressUpArrow(); + + // Ensure the selection moved to "First para|graph". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: context.document.first.id, + nodePosition: const TextNodePosition(offset: 10), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from paragraph to task', (tester) async { + await _pumpEditorWithTaskComponent( + tester, + document: MutableDocument( + nodes: [ + TaskNode(id: '1', text: AttributedText('This is a task'), isComplete: false), + ParagraphNode(id: '2', text: AttributedText('This is a paragraph')), + ], + ), + ); + + // Place the caret at "This is a |paragraph". + await tester.placeCaretInParagraph('2', 10); + + // Press up arrow to move the caret to the upstream node. + // + // The text layout of this document is approximately: + // + // [ ]This is a task + // This is a paragraph + // + // So, pressing UP should move the caret from "This is a |paragraph" + // to "Th|is is a task". + await tester.pressUpArrow(); + + // Ensure the caret moved to "This is| a task". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 7), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from paragraph to list item', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(''' +* This is a list item + +This is a paragraph''') // + .pump(); + + // Place the caret at "This is a |paragraph". + await tester.placeCaretInParagraph(context.document.last.id, 10); + + // Press UP arrow to move the caret to the upstream node. + // + // The text layout of this document is approximately: + // + // * This is a list item + // This is a paragraph + // + // So, pressing UP should move the caret from "This is a |paragraph" + // to "This is |a list item". + await tester.pressUpArrow(); + + // Ensure the selection moved to "This is |a list item". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: context.document.first.id, + nodePosition: const TextNodePosition(offset: 8), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from task to paragraph', (tester) async { + await _pumpEditorWithTaskComponent( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode(id: '1', text: AttributedText('This is a paragraph')), + TaskNode(id: '2', text: AttributedText('This is a task'), isComplete: false), + ], + ), + ); + + // Place the caret at "This| is a task". + await tester.placeCaretInParagraph('2', 4); + + // Press up arrow to move the caret to the upstream node. + // + // The text layout of this document is approximately: + // + // This is a paragraph + // [ ]This is a task + // + // So, pressing UP should move the caret from "This| is a task" + // to "This is| a paragraph". + await tester.pressUpArrow(); + + // Ensure the caret moved to "This is| a paragraph." + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 7), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from task to task', (tester) async { + await _pumpEditorWithTaskComponent( + tester, + document: MutableDocument( + nodes: [ + TaskNode(id: '1', text: AttributedText('This is a task'), isComplete: false), + TaskNode(id: '2', text: AttributedText('This is another task'), isComplete: false), + ], + ), + ); + + // Place the caret at "This is a|nother task". + await tester.placeCaretInParagraph('2', 9); + + // Press up arrow to move the caret to the upstream node. + // + // The text layout of this document is approximately: + // + // [ ] This is a task + // [ ] This is another task + // + // So, pressing UP should move the caret from "This is a|nother task" + // to "This is a| task". + await tester.pressUpArrow(); + + // Ensure the caret moved to "This is a| task". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 9), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from task to list item', (tester) async { + await _pumpEditorWithTaskComponent( + tester, + document: MutableDocument( + nodes: [ + ListItemNode.unordered(id: '1', text: AttributedText('This is a list item')), + TaskNode(id: '2', text: AttributedText('This is a task'), isComplete: false), + ], + ), + ); + + // Place the caret at "This| is a task". + await tester.placeCaretInParagraph('2', 4); + + // Press UP arrow to move the caret to the upstream node. + // + // The text layout of this document is approximately: + // + // * This is a list item + // [ ]This is a task + // + // So, pressing UP should move the caret from "This| is a task" + // to "This| is a list item". + await tester.pressUpArrow(); + + // Ensure the caret moved to "This| is a list item". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from list item to paragraph', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(''' +This is a paragraph + +* This is a list item''') // + .pump(); + + // Place caret at "This is |a list item". + await tester.placeCaretInParagraph(context.document.last.id, 8); + + // Press UP arrow to move the caret to the upstream node. + // + // The text layout of this document is approximately: + // + // This is a paragraph + // * This is a list item + // + // So, pressing UP should move the caret from "This is |a list item" + // to "This is a |paragraph". + await tester.pressUpArrow(); + + // Ensure the selection moved to "This is a |paragraph". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: context.document.first.id, + nodePosition: const TextNodePosition(offset: 10), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from list item to task', (tester) async { + await _pumpEditorWithTaskComponent( + tester, + document: MutableDocument( + nodes: [ + TaskNode(id: '1', text: AttributedText('This is a task'), isComplete: false), + ListItemNode.unordered(id: '2', text: AttributedText('This is a list item')), + ], + ), + ); + + // Place the caret at "This| is a list item". + await tester.placeCaretInParagraph('2', 4); + + // Press UP arrow to move the caret to the upstream node. + // + // The text layout of this document is approximately: + // + // [ ]This is a task + // * This is a list item + // + // So, pressing UP should move the caret from "This| is a list item" + // to "This| is a task". + await tester.pressUpArrow(); + + // Ensure the caret moved to "This| is a task". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('from list item to list item', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(''' +* This is a list item + +* This is another list item''') // + .pump(); + + // Place the caret at "This is a|nother list item". + await tester.placeCaretInParagraph(context.document.last.id, 9); + + // Press UP arrow to move the caret to the upstream node. + // + // The text layout of this document is approximately: + // + // * This is a list item + // * This is another list item + // + // So, pressing UP should move the caret from "This is a|nother list item" + // to "This is a| list item". + await tester.pressUpArrow(); + + // Ensure the caret moved to "This is a| task". + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: context.document.first.id, + nodePosition: const TextNodePosition(offset: 9), + ), + ), + ), + ); + }); + }); + }); + + group("Linux >", () { + group('jumps to', () { + testWidgetsOnLinux('preceding character with ALT + LEFT ARROW', (tester) async { + // Start the user's selection somewhere in the middle of a word. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressAltLeftArrow(); + + // Ensure that the caret moved one character to the left. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + ), + ); + }); + + testWidgetsOnLinux('next character with ALT + RIGHT ARROW', (tester) async { + // Start the user's selection somewhere in the middle of a word. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressAltRightArrow(); + + // Ensure that the caret moved one character to the right + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 9), + ), + ), + ); + }); + }); + }); + + group("does nothing", () { + testWidgetsOnWindows("with ALT + LEFT ARROW", (tester) async { + // Start the user's selection somewhere in the middle of a word. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressAltLeftArrow(); + + // Ensure that the caret didn't move + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + }); + + testWidgetsOnWindows("with ALT + RIGHT ARROW", (tester) async { + // Start the user's selection somewhere in the middle of a word. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressAltRightArrow(); + + // Ensure that the caret didn't move + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + }); + + testWidgetsOnWindowsAndLinux('with ALT + UP ARROW', (tester) async { + await _pumpExplicitLineBreakTestSetup(tester); + + // Place caret at the second line at "consectetur adipiscing |elit" + await tester.placeCaretInParagraph('1', 51); + + await tester.pressAltUpArrow(); + + // Ensure that the caret didn't move + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 51), + ), + ), + ); + }); + + testWidgetsOnWindowsAndLinux('with ALT + DOWN ARROW', (tester) async { + await _pumpExplicitLineBreakTestSetup(tester); + + // Place caret at the first line at "Lorem |ipsum" + await tester.placeCaretInParagraph('1', 6); + + await tester.pressAltDownArrow(); + + // Ensure that the caret didn't move + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + }); + }); + + group("shortcuts for Windows and Linux do nothing on mac", () { + testWidgetsOnMac('HOME', (tester) async { + // Start the user's selection somewhere after the beginning of the first + // line in the first node. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressHome(); + + // Ensure that the caret didn't move + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + }); + + testWidgetsOnMac('END', (tester) async { + // Start the user's selection somewhere after the beginning of the first + // line in the first node. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 2); + + await tester.pressEnd(); + + // Ensure that the caret didn't move + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 2), + ), + ), + ); + }); + + testWidgetsOnMac('CTRL + LEFT ARROW', (tester) async { + // Start the user's selection somewhere in the middle of a word. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressCtlLeftArrow(); + + // Ensure that the caret moved only one character to the left + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + ), + ); + }); + + testWidgetsOnMac('CTRL + RIGHT ARROW', (tester) async { + // Start the user's selection somewhere in the middle of a word. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressCtlRightArrow(); + + // Ensure that the caret moved only one character to the right + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 9), + ), + ), + ); + }); + }); + + group("shortcuts for Mac do nothing on Windows and Linux", () { + testWidgetsOnWindowsAndLinux('CMD + LEFT ARROW', (tester) async { + // Start the user's selection somewhere after the beginning of the first + // line in the first node. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 8); + + await tester.pressCmdLeftArrow(); + + // Ensure that the caret didn't move to the beginning of the line. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + ), + ); + }); + + testWidgetsOnWindowsAndLinux('CMD + RIGHT ARROW', (tester) async { + // Start the user's selection somewhere before the end of the first line + // in the first node. + await _pumpCaretMovementTestSetup(tester, textOffsetInFirstNode: 2); + + await tester.pressCmdRightArrow(); + + // Ensure that the caret didn't move to the end of the line. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 3), + ), + ), + ); + }); + }); + + group('CMD + A to select all', () { + testWidgetsOnApple('does nothing when CMD key is pressed but A-key is not pressed', (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Press CMD key. + await tester.sendKeyEvent(LogicalKeyboardKey.metaLeft); + + // Ensure we didn't select all content. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnApple('does nothing when A-key is pressed but meta key is not pressed', (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Press CMD key. + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + + // Ensure we didn't select all content. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 1), + ), + ), + ); + }); + + testWidgetsOnApple('does nothing when CMD+A is pressed but the document is empty', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + await tester.pressCmdA(); + + // We don't expect that our selection changed at all. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnApple('selects all when CMD+A is pressed with a single-node document', (tester) async { + await tester // + .createDocument() + .withCustomContent(MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is some text'), + ), + ], + )) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + await tester.pressCmdA(); + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + }); + + testWidgetsOnApple('selects all when CMD+A is pressed with a two-node document', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is some text'), + ), + ParagraphNode( + id: '2', + text: AttributedText('This is some text'), + ), + ], + ), + ) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + await tester.pressCmdA(); + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + }); + + testWidgetsOnApple('selects all when CMD+A is pressed with a three-node document', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ImageNode( + id: '1', + imageUrl: 'https://fake.com/image/url.png', + ), + ParagraphNode( + id: '2', + text: AttributedText('This is some text'), + ), + ImageNode( + id: '3', + imageUrl: 'https://fake.com/image/url.png', + ), + ], + ), + ) + .withAddedComponents([const FakeImageComponentBuilder(size: Size(800, 400))]) // + .pump(); + + await tester.placeCaretInParagraph("2", 0); + + await tester.pressCmdA(); + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + }); + }); + + group('key pressed with selection', () { + testWidgetsOnApple('deletes selection if backspace is pressed', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Text with [DELETEME] selection'), + ), + ], + ), + ) + .pump(); + + // Select "DELETEME" by doube-tapping. + await tester.doubleTapInParagraph("1", 14); + + // Ensure that we selected the word. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 19), + ), + ), + ); + + await tester.pressBackspace(); + + // Ensure the selected content was deleted. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Text with [] selection"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + ), + ); + }); + + testWidgetsOnApple('deletes selection if delete is pressed', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Text with [DELETEME] selection'), + ), + ], + ), + ) + .pump(); + + // Select "DELETEME" by doube-tapping. + await tester.doubleTapInParagraph("1", 14); + + // Ensure that we selected the word. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 19), + ), + ), + ); + + await tester.pressDelete(); + + // Ensure the selected content was deleted. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Text with [] selection"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + ), + ); + }); + + testWidgetsOnApple('replaces selected content with character when character key is pressed', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Text with [DELETEME] selection'), + ), + ], + ), + ) + .withInputSource(TextInputSource.keyboard) + .pump(); + + // Select "DELETEME" by doube-tapping. + await tester.doubleTapInParagraph("1", 14); + + // Ensure that we selected the word. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 19), + ), + ), + ); + + await tester.typeKeyboardText("a"); + + // Ensure the selected content was deleted. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Text with [a] selection"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 12), + ), + ), + ); + }); + + testWidgetsOnApple('collapses selection if escape is pressed', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Text with [SELECTME] selection'), + ), + ], + ), + ) + .pump(); + + // Select "SELECTME" by double-tapping. + await tester.doubleTapInParagraph("1", 14); + + // Ensure that we selected the word. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 19), + ), + ), + ); + + await tester.pressEscape(); + + // Ensure the selected content was deleted. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Text with [SELECTME] selection"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 19), + ), + ), + ); + }); + }); + + testWidgetsOnApple('does nothing when escape is pressed if the selection is collapsed', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is some text'), + ), + ], + ), + ) + .pump(); + + // Select "SELECTME" by double-tapping. + await tester.placeCaretInParagraph("1", 8); + + await tester.pressEscape(); + + // Ensure that nothing changed. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "This is some text"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + }); + + group("page scrolling", () { + testWidgetsOnAllPlatforms( + 'PAGE DOWN scrolls down by the viewport height', + (tester) async { + await _scrollingVariant.currentValue!.pumpEditor( + tester, + _scrollingVariant.currentValue!.textInputSource, + ); + + await tester.placeCaretInParagraph('1', 0); + + final scrollState = tester.state(find.byType(Scrollable)); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageDown); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we scrolled down by the viewport height. + expect( + scrollState.position.pixels, + equals(scrollState.position.viewportDimension), + ); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnAllPlatforms( + 'PAGE DOWN does not scroll past bottom of the viewport', + (tester) async { + await _scrollingVariant.currentValue!.pumpEditor( + tester, + _scrollingVariant.currentValue!.textInputSource, + ); + + await tester.placeCaretInParagraph('1', 0); + + final scrollState = tester.state(find.byType(Scrollable)); + + // Scroll very close to the bottom but not all the way to avoid explicit + // checks comparing scroll offset directly against `maxScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent - 10); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageDown); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we didn't scroll past the bottom of the viewport. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnAllPlatforms( + 'PAGE UP scrolls up by the viewport height', + (tester) async { + await _scrollingVariant.currentValue!.pumpEditor( + tester, + _scrollingVariant.currentValue!.textInputSource, + ); + + await tester.placeCaretInParagraph('1', 0); + + final scrollState = tester.state(find.byType(Scrollable)); + + // Scroll to the bottom of the viewport. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageUp); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we scrolled up by the viewport height. + expect( + scrollState.position.pixels, + equals(scrollState.position.maxScrollExtent - scrollState.position.viewportDimension), + ); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnAllPlatforms( + 'PAGE UP does not scroll past top of the viewport', + (tester) async { + await _scrollingVariant.currentValue!.pumpEditor( + tester, + _scrollingVariant.currentValue!.textInputSource, + ); + + await tester.placeCaretInParagraph('1', 0); + + final scrollState = tester.state(find.byType(Scrollable)); + + // Scroll very close to the top but not all the way to avoid explicit + // checks comparing scroll offset directly against `minScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.minScrollExtent + 10); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageUp); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we didn't scroll past the top of the viewport. + expect(scrollState.position.pixels, equals(scrollState.position.minScrollExtent)); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnAllPlatforms( + 'CMD + HOME on mac/ios and CTRL + HOME on other platforms scrolls to top of viewport', + (tester) async { + await _scrollingVariant.currentValue!.pumpEditor( + tester, + _scrollingVariant.currentValue!.textInputSource, + ); + + await tester.placeCaretInParagraph('1', 0); + + final scrollState = tester.state(find.byType(Scrollable)); + + // Scroll to the bottom of the viewport. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent); + + if (defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.iOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we scrolled to the top of the viewport. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnAllPlatforms( + "CMD + HOME on mac/ios and CTRL + HOME on other platforms does not scroll past top of the viewport", + (tester) async { + await _scrollingVariant.currentValue!.pumpEditor( + tester, + _scrollingVariant.currentValue!.textInputSource, + ); + + await tester.placeCaretInParagraph('1', 0); + + final scrollState = tester.state(find.byType(Scrollable)); + + // Scroll very close to the top but not all the way to avoid explicit + // checks comparing scroll offset directly against `minScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.minScrollExtent + 10); + + if (defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.iOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we didn't scroll past the top of the viewport. + expect(scrollState.position.pixels, equals(scrollState.position.minScrollExtent)); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnAllPlatforms( + "CMD + END on mac/ios and CTRL + END on other platforms scrolls to bottom of viewport", + (tester) async { + await _scrollingVariant.currentValue!.pumpEditor( + tester, + _scrollingVariant.currentValue!.textInputSource, + ); + + await tester.placeCaretInParagraph('1', 0); + + final scrollState = tester.state(find.byType(Scrollable)); + + if (defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.iOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + + // Ensure we scrolled to the bottom of the viewport. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnAllPlatforms( + "CMD + END on mac/ios and CTRL + END on other platforms does not scroll past bottom of the viewport", + (tester) async { + await _scrollingVariant.currentValue!.pumpEditor( + tester, + _scrollingVariant.currentValue!.textInputSource, + ); + + await tester.placeCaretInParagraph('1', 0); + + final scrollState = tester.state(find.byType(Scrollable)); + + // Scroll very close to the bottom but not all the way to avoid explicit + // checks comparing scroll offset directly against `maxScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent - 10); + + if (defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.iOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + + // Ensure we didn't scroll past the bottom of the viewport. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + }); + }); +} + +/// Pumps a [SuperEditor] with a single-paragraph document, with focus, and returns +/// the associated [SuperEditorContext] for further inspection and control. +/// +/// This particular setup is intended for caret movement testing within a single +/// paragraph node. +Future _pumpCaretMovementTestSetup( + WidgetTester tester, { + required int textOffsetInFirstNode, +}) async { + final focusNode = FocusNode()..requestFocus(); + final context = await tester // + .createDocument() + .withSingleParagraph() + .withFocusNode(focusNode) + .pump(); + + await tester.placeCaretInParagraph("1", textOffsetInFirstNode); + + return context; +} + +Future _pumpAutoWrappingTestSetup(WidgetTester tester) async { + return await tester // + .createDocument() + .withSingleParagraph() + .forDesktop() + .withEditorSize(const Size(400, 400)) + .pump(); +} + +Future _pumpExplicitLineBreakTestSetup( + WidgetTester tester, { + Size? size, +}) async { + return await tester + .createDocument() + .withCustomContent(MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'Lorem ipsum dolor sit amet\nconsectetur adipiscing elit', + ), + ), + ], + )) + .forDesktop() + .withEditorSize(size) + .pump(); +} + +/// Pumps a [SuperEditor] with a document with two multi-line paragraphs. +Future _pumpTwoParagraphsTestApp( + WidgetTester tester, { + required TextInputSource inputSource, +}) async { + final context = await tester // + .createDocument() + .withCustomContent(MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'First paragraph\nwith multiple lines', + ), + ), + ParagraphNode( + id: '2', + text: AttributedText( + 'Second paragraph\nwith multiple lines', + ), + ), + ], + )) + .withInputSource(inputSource) + .pump(); + + return context; +} + +/// Variant for an editor experience with an internal scrollable and +/// an ancestor scrollable. +final _scrollingVariant = ValueVariant<_PageScrollSetup>({ + const _PageScrollSetup( + description: "inner viewport", + pumpEditor: _pumpPageScrollTestSetup, + textInputSource: TextInputSource.ime, + ), + const _PageScrollSetup( + description: "inner viewport", + pumpEditor: _pumpPageScrollTestSetup, + textInputSource: TextInputSource.keyboard, + ), + const _PageScrollSetup( + description: "ancestor viewport", + pumpEditor: _pumpPageScrollSliverTestSetup, + textInputSource: TextInputSource.ime, + ), + const _PageScrollSetup( + description: "ancestor viewport", + pumpEditor: _pumpPageScrollSliverTestSetup, + textInputSource: TextInputSource.keyboard, + ), +}); + +/// Pumps a [SuperEditor] experience with the default [Scrollable]. +Future _pumpPageScrollTestSetup( + WidgetTester tester, + TextInputSource textInputSource, +) async { + return await tester // + .createDocument() + .withLongDoc() + .withInputSource(textInputSource) + .pump(); +} + +/// Pumps a [SuperEditor] within a ancestor [Scrollable], including additional +/// content above the [SuperEditor] and additional content on top of [Scrollable]. +/// +/// By including content above the [SuperEditor], it doesn't have the same origin as +/// the ancestor [Scrollable]. +/// +/// By including content on top of [Scrollable], it doesn't have the origin +/// at [Offset.zero]. +/// +/// This setup is intended for testing page scrolling actions behaviour in presense +/// of an ancestor [Scrollable]. +Future _pumpPageScrollSliverTestSetup( + WidgetTester tester, + TextInputSource textInputSource, +) async { + return tester // + .createDocument() + .withLongDoc() + .withInputSource(textInputSource) + .withCustomWidgetTreeBuilder((superEditor) { + return MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 300), + child: CustomScrollView( + slivers: [ + const SliverAppBar( + title: Text( + 'Rich Text Editor Sliver Example', + ), + expandedHeight: 200.0, + ), + superEditor, + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return ListTile(title: Text('$index')); + }, + childCount: 100, + ), + ), + ], + ), + ), + ), + debugShowCheckedModeBanner: false, + ); + }).pump(); +} + +/// Pumps a [SuperEditor] configured with the [TaskComponentBuilder]. +Future _pumpEditorWithTaskComponent( + WidgetTester tester, { + required MutableDocument document, +}) async { + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); +} + +Future _pressShiftAltUpArrow(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift, platform: 'macos'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.alt, platform: 'macos'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp, platform: 'macos'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowUp, platform: 'macos'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.alt, platform: 'macos'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift, platform: 'macos'); + await tester.pumpAndSettle(); +} + +Future _pressShiftAltDownArrow(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift, platform: 'macos'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.alt, platform: 'macos'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown, platform: 'macos'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown, platform: 'macos'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.alt, platform: 'macos'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift, platform: 'macos'); + await tester.pumpAndSettle(); +} + +/// Holds the setup for a page scroll test. +class _PageScrollSetup { + const _PageScrollSetup({ + required this.description, + required this.pumpEditor, + required this.textInputSource, + }); + final String description; + final _PumpEditorWidget pumpEditor; + final TextInputSource textInputSource; + + @override + String toString() { + return "PageScrollSetup: $description, ${textInputSource.toString()}"; + } +} + +typedef _PumpEditorWidget = Future Function( + WidgetTester tester, + TextInputSource textInputSource, +); diff --git a/super_editor/test/super_editor/supereditor_keyboard_test.dart b/super_editor/test/super_editor/supereditor_keyboard_test.dart index 09b4398b3f..f682bf2492 100644 --- a/super_editor/test/super_editor/supereditor_keyboard_test.dart +++ b/super_editor/test/super_editor/supereditor_keyboard_test.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; +import '../test_runners.dart'; import '../test_tools.dart'; -import 'document_test_tools.dart'; +import 'test_documents.dart'; void main() { group('SuperEditor keyboard', () { @@ -13,7 +16,7 @@ void main() { group('moves caret', () { testAllInputsOnDesktop("left by one character when LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 2, inputSource: inputSource); @@ -24,7 +27,7 @@ void main() { testAllInputsOnDesktop("left by one character and expands when SHIFT + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 2, inputSource: inputSource); @@ -35,7 +38,7 @@ void main() { testAllInputsOnDesktop("right by one character when RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 2, inputSource: inputSource); @@ -46,7 +49,7 @@ void main() { testAllInputsOnDesktop("right by one character and expands when SHIFT + RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 2, inputSource: inputSource); @@ -55,9 +58,9 @@ void main() { expect(SuperEditorInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 2, to: 3)); }); - testAllInputsOnMac("to beginning of word when ALT + LEFT_ARROW is pressed", ( + testAllInputsOnApple("to beginning of word when ALT + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -66,9 +69,9 @@ void main() { expect(SuperEditorInspector.findDocumentSelection(), _caretInParagraph(nodeId, 8)); }); - testAllInputsOnMac("to beginning of word and expands when SHIFT + ALT + LEFT_ARROW is pressed", ( + testAllInputsOnApple("to beginning of word and expands when SHIFT + ALT + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -77,9 +80,9 @@ void main() { expect(SuperEditorInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 10, to: 8)); }); - testAllInputsOnMac("to end of word when ALT + RIGHT_ARROW is pressed", ( + testAllInputsOnApple("to end of word when ALT + RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -88,9 +91,9 @@ void main() { expect(SuperEditorInspector.findDocumentSelection(), _caretInParagraph(nodeId, 12)); }); - testAllInputsOnMac("to end of word and expands when SHIFT + ALT + RIGHT_ARROW is pressed", ( + testAllInputsOnApple("to end of word and expands when SHIFT + ALT + RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -99,9 +102,9 @@ void main() { expect(SuperEditorInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 10, to: 12)); }); - testAllInputsOnMac("to beginning of line when CMD + LEFT_ARROW is pressed", ( + testAllInputsOnApple("to beginning of line when CMD + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -110,9 +113,9 @@ void main() { expect(SuperEditorInspector.findDocumentSelection(), _caretInParagraph(nodeId, 0)); }); - testAllInputsOnMac("to beginning of line and expands when SHIFT + CMD + LEFT_ARROW is pressed", ( + testAllInputsOnApple("to beginning of line and expands when SHIFT + CMD + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -121,9 +124,9 @@ void main() { expect(SuperEditorInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 10, to: 0)); }); - testAllInputsOnMac("to end of line when CMD + RIGHT_ARROW is pressed", ( + testAllInputsOnApple("to end of line when CMD + RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -132,9 +135,9 @@ void main() { expect(SuperEditorInspector.findDocumentSelection(), _caretInParagraph(nodeId, 26, TextAffinity.upstream)); }); - testAllInputsOnMac("to end of line and expands when SHIFT + CMD + RIGHT_ARROW is pressed", ( + testAllInputsOnApple("to end of line and expands when SHIFT + CMD + RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -148,7 +151,7 @@ void main() { testAllInputsOnWindowsAndLinux("to beginning of word when CTL + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -159,7 +162,7 @@ void main() { testAllInputsOnWindowsAndLinux("to beginning of word and expands when SHIFT + CTL + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -170,7 +173,7 @@ void main() { testAllInputsOnWindowsAndLinux("to end of word when CTL + Right_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -181,7 +184,7 @@ void main() { testAllInputsOnWindowsAndLinux("to end of word and expands when SHIFT + CTL + RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: inputSource); @@ -192,7 +195,7 @@ void main() { testAllInputsOnDesktop("up one line when UP_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLineWithCaret(tester, offset: 41, inputSource: inputSource); @@ -203,7 +206,7 @@ void main() { testAllInputsOnDesktop("up one line and expands when SHIFT + UP_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLineWithCaret(tester, offset: 41, inputSource: inputSource); @@ -214,7 +217,7 @@ void main() { testAllInputsOnDesktop("down one line when DOWN_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLineWithCaret(tester, offset: 12, inputSource: inputSource); @@ -225,7 +228,7 @@ void main() { testAllInputsOnDesktop("down one line and expands when SHIFT + DOWN_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLineWithCaret(tester, offset: 12, inputSource: inputSource); @@ -236,7 +239,7 @@ void main() { testAllInputsOnDesktop("to beginning of line when UP_ARROW is pressed at top of document", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLineWithCaret(tester, offset: 12, inputSource: inputSource); @@ -247,7 +250,7 @@ void main() { testAllInputsOnDesktop("to beginning of line and expands when SHIFT + UP_ARROW is pressed at top of document", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLineWithCaret(tester, offset: 12, inputSource: inputSource); @@ -258,7 +261,7 @@ void main() { testAllInputsOnDesktop("to end of line when DOWN_ARROW is pressed at end of document", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLineWithCaret(tester, offset: 41, inputSource: inputSource); @@ -269,7 +272,7 @@ void main() { testAllInputsOnDesktop("end of line and expands when SHIFT + DOWN_ARROW is pressed at end of document", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLineWithCaret(tester, offset: 41, inputSource: inputSource); @@ -279,43 +282,572 @@ void main() { }); }); }); + + testWidgetsOnMacWeb("on web moves caret to beginning of line when CMD + LEFT_ARROW is pressed", (tester) async { + final nodeId = await _pumpSingleLineWithCaret(tester, offset: 10, inputSource: TextInputSource.ime); + + // Simulate the user pressing CMD + LEFT ARROW, which generates a delta moving + // the selection to the beginning of the line. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. This is some testing text.', + selection: TextSelection.collapsed(offset: 12), + composing: TextRange.empty, + ), + const TextEditingDeltaNonTextUpdate( + oldText: '. This is some testing text.', + selection: TextSelection.collapsed(offset: 0), + composing: TextRange.collapsed(0), + ), + ], getter: imeClientGetter); + + // Ensure the selection and composing region were updated. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo(_caretInParagraph(nodeId, 0)), + ); + expect( + SuperEditorInspector.findComposingRegion(), + DocumentRange( + start: DocumentPosition(nodeId: nodeId, nodePosition: const TextNodePosition(offset: 0)), + end: DocumentPosition(nodeId: nodeId, nodePosition: const TextNodePosition(offset: 0)), + ), + ); + }); + + testAllInputsOnAllPlatforms('does nothing without primary focus', ( + tester, { + required TextInputSource inputSource, + }) async { + final editorFocusNode = FocusNode(); + final popoverFocusNode = FocusNode(); + final textFieldFocusNode = FocusNode(); + final overlayController = OverlayPortalController(); + + bool keyHandlerCalled = false; + + // Pump a tree with an OverlayPortal that displays a SuperTextField. + // The textfield shares focus with SuperEditor, simulating a popover toolbar. + await tester // + .createDocument() + .withSingleParagraph() + .withFocusNode(editorFocusNode) + .withInputSource(inputSource) + .withAddedKeyboardActions(append: [ + ({required editContext, required keyEvent}) { + keyHandlerCalled = true; + return ExecutionInstruction.continueExecution; + } + ]) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: OverlayPortal( + controller: overlayController, + overlayChildBuilder: (context) => Focus( + focusNode: popoverFocusNode, + parentNode: editorFocusNode, + child: SuperTextField( + focusNode: textFieldFocusNode, + inputSource: inputSource, + ), + ), + child: superEditor, + ), + ), + ), + ) + .pump(); + + // Double tap to select the word "Lorem". + await tester.doubleTapInParagraph('1', 0); + + // Show the popover and request focus to the textfield. + overlayController.show(); + textFieldFocusNode.requestFocus(); + await tester.pump(); + + // Press cmd + shift + alt + ctrl + space. + // This isn't a default shortcut in any platform. + // Therefore, if the editor is handling key events, our custom handler + // will be called. + await tester.sendKeyDownEvent(LogicalKeyboardKey.meta); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyDownEvent(LogicalKeyboardKey.alt); + await tester.sendKeyDownEvent(LogicalKeyboardKey.control); + await tester.sendKeyDownEvent(LogicalKeyboardKey.space); + await tester.sendKeyUpEvent(LogicalKeyboardKey.space); + await tester.sendKeyUpEvent(LogicalKeyboardKey.control); + await tester.sendKeyUpEvent(LogicalKeyboardKey.alt); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.sendKeyUpEvent(LogicalKeyboardKey.meta); + await tester.pumpAndSettle(); + + // Ensure the custom handler wasn't called. + expect(keyHandlerCalled, false); + + // Press enter, which by default inserts a new line, + // to check if the document will change. + await tester.pressEnter(); + + // Ensure the document doesn't change. + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + (singleParagraphDoc().first as TextNode).text.toPlainText(), + ); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 5), + ), + ), + ), + ); + }); }); group('SuperEditor software keyboard', () { - testWidgetsOnIos('pressing tab indent list', (tester) async { - await _pumpUnorderedList(tester); + group('in automatic control mode', () { + testWidgetsOnAndroid('clears selection when it closes', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final testContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withSoftwareKeyboardController(keyboardController) + .withSelectionPolicies( + const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: true, + clearSelectionWhenImeConnectionCloses: true, + ), + ) + .withImePolicies( + const SuperEditorImePolicies( + openKeyboardOnSelectionChange: true, + ), + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .pump(); + + // Place the caret in Super Editor to open the IME. + final nodeId = testContext.findEditContext().document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Ensure the IME is open + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Ensure the document selection is gone + expect(SuperEditorInspector.findDocumentSelection(), null); + }); + + testWidgetsOnAndroid('re-opens when selection changes', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final testContext = await tester // + .createDocument() + .withSingleParagraph() + .withSoftwareKeyboardController(keyboardController) + .withSelectionPolicies( + const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: true, + ), + ) + .withImePolicies( + const SuperEditorImePolicies( + openKeyboardOnSelectionChange: true, + ), + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .pump(); + + // Place the caret in Super Editor. + final nodeId = testContext.findEditContext().document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Move the caret somewhere else. + await tester.placeCaretInParagraph(nodeId, 5); + // Ensure the selection changed. + expect(SuperEditorInspector.findDocumentSelection(), isNot(selectionBefore)); + // Ensure the keyboard re-opened. + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Select a word + await tester.doubleTapInParagraph(nodeId, 10); + // Ensure the keyboard re-opened. + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Select a paragraph + await tester.tripleTapInParagraph(nodeId, 15); + // Ensure the keyboard re-opened. + expect(keyboardController.isConnectedToIme, isTrue); + }); + }); - final node = SuperEditorInspector.getNodeAt(0); + group('in manual control mode', () { + testWidgetsOnAndroid('leaves selection active when it closes', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final testContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withSoftwareKeyboardController(keyboardController) + .withSelectionPolicies( + const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: false, + clearSelectionWhenImeConnectionCloses: false, + ), + ) + .withImePolicies( + const SuperEditorImePolicies( + openKeyboardOnSelectionChange: false, + ), + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .pump(); + + // Place the caret in Super Editor to open the IME. + final nodeId = testContext.findEditContext().document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Open the keyboard + keyboardController.open(viewId: 1); + await tester.pump(); + + // Ensure the IME is open + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Ensure the document selection hasn't changed + expect(SuperEditorInspector.findDocumentSelection(), selectionBefore); + }); + + testWidgetsOnAndroid('stays closed when changing selection', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final testContext = await tester // + .createDocument() + .withSingleParagraph() + .withSoftwareKeyboardController(keyboardController) + .withSelectionPolicies( + const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: false, + clearSelectionWhenImeConnectionCloses: false, + ), + ) + .withImePolicies( + const SuperEditorImePolicies( + openKeyboardOnSelectionChange: false, + ), + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .pump(); + + // Place the caret in Super Editor. + final nodeId = testContext.findEditContext().document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Open the keyboard + keyboardController.open(viewId: 0); + await tester.pump(); + + // Ensure the IME is open + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Move the caret somewhere else. + await tester.placeCaretInParagraph(nodeId, 5); + // Ensure the selection changed. + expect(SuperEditorInspector.findDocumentSelection()!.extent, isNot(selectionBefore.extent)); + // Ensure the keyboard is still closed. + expect(keyboardController.isConnectedToIme, isFalse); + + // Select a word + // + // WARNING: To avoid accidentally tapping on the popover toolbar, we tap down a few + // lines. The specific text offset isn't important in this case. + await tester.doubleTapInParagraph(nodeId, 100); + // Ensure the keyboard is still closed. + expect(keyboardController.isConnectedToIme, isFalse); + + // Select a paragraph. + // + // WARNING: To avoid accidentally tapping on the popover toolbar, we tap down a few + // lines. The specific text offset isn't important in this case. + await tester.tripleTapInParagraph(nodeId, 100); + // Ensure the keyboard is still closed. + expect(keyboardController.isConnectedToIme, isFalse); + }); + + testWidgetsOnAndroid('opens when requested after previously closing', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final testContext = await tester // + .createDocument() + .withSingleParagraph() + .withSoftwareKeyboardController(keyboardController) + .withSelectionPolicies( + const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: false, + clearSelectionWhenImeConnectionCloses: false, + ), + ) + .withImePolicies( + const SuperEditorImePolicies( + openKeyboardOnSelectionChange: false, + ), + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .pump(); + + // Place the caret in Super Editor. + final nodeId = testContext.findEditContext().document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Open the keyboard + keyboardController.open(viewId: 0); + await tester.pump(); + + // Ensure the IME is open + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Re-open the IME + keyboardController.open(viewId: 0); + await tester.pumpAndSettle(); + + // Ensure the IME is re-opened + expect(keyboardController.isConnectedToIme, isTrue); + + // Ensure the selection is unchanged. + expect(SuperEditorInspector.findDocumentSelection(), selectionBefore); + }); + + testWidgetsOnAndroid('closes when requested before navigation', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final navigationKey = GlobalKey(); + final firstPageKey = GlobalKey(); + + // Display a page without SuperEditor. We'll pop() back to this page, later. + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigationKey, + home: Scaffold( + key: firstPageKey, + body: const Center( + child: Text("Starting Page"), + ), + ), + ), + ); + expect(find.byKey(firstPageKey), findsOneWidget); + + // Push a page with SuperEditor. + final superEditorAndContext = tester // + .createDocument() + .withSingleParagraph() + .withSoftwareKeyboardController(keyboardController) + .withSelectionPolicies( + const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: false, + ), + ) + .withImePolicies( + const SuperEditorImePolicies( + openKeyboardOnSelectionChange: false, + ), + ) + .withCustomWidgetTreeBuilder( + (superEditor) => _CloseKeyboardOnDispose( + keyboardController: keyboardController, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .build(); + navigationKey.currentState!.push(MaterialPageRoute(builder: (context) { + return superEditorAndContext.widget; + })); + await tester.pumpAndSettle(); // navigation transition + + // Ensure the first page is no longer visible. + expect(find.byKey(firstPageKey), findsNothing); + + // Place the caret in Super Editor. + final nodeId = superEditorAndContext.context.findEditContext().document.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Open the keyboard + keyboardController.open(viewId: 0); + await tester.pump(); + + // Ensure the IME is open + expect(keyboardController.isConnectedToIme, isTrue); + + // Pop navigation back to the first screen. + navigationKey.currentState!.pop(); + await tester.pumpAndSettle(); + + // Ensure first page is visible again. + expect(find.byKey(firstPageKey), findsOneWidget); + + // By getting to this point in the test without crashing, we know that the + // _CloseKeyboardOnDispose widget was able to instruct the keyboard to + // close in its `dispose()` method. This should mean that Super Editor users + // can close the keyboard when their Super Editor screen navigates elsewhere. + }); + }); + + testWidgetsOnIos('tab indents list item', (tester) async { + final context = await _pumpUnorderedList(tester); + final document = context.document; // Ensure we started with indentation level 0. - expect(node.indent, 0); + expect(document.first.asListItem.indent, 0); - await tester.placeCaretInParagraph(node.id, 0); + await tester.placeCaretInParagraph(document.first.id, 0); // Simulate the user pressing TAB on the software keyboard. await tester.typeImeText("\t"); // Ensure we indented the list item. - expect(node.indent, 1); + expect(document.first.asListItem.indent, 1); // Ensure the selection didn't change. expect( SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: node.id, + nodeId: document.first.id, nodePosition: const TextNodePosition(offset: 0), ), ), ); // Ensure the content of the list item didn't change. - expect(node.text.text, 'list item 1'); + expect(document.first.asListItem.text.toPlainText(), 'list item 1'); }); }); group('SuperEditor inputSource', () { - testWidgetsOnMobile('configures for IME input by default on mobile', (tester) async { + testWidgetsOnAllPlatforms('configures for IME input by default', (tester) async { await tester // .createDocument() .withSingleEmptyParagraph() @@ -324,10 +856,10 @@ void main() { final document = SuperEditorInspector.findDocument()!; // Ensure the document was created with one node. - expect(document.nodes.length, 1); + expect(document.nodeCount, 1); // Tap to give focus to the editor. - await tester.placeCaretInParagraph(document.nodes.first.id, 0); + await tester.placeCaretInParagraph(document.first.id, 0); // Ensure that IME input is enabled. To check IME input, we arbitrarily simulate a newline action from // the IME. If the editor responds to the newline, it means IME input is enabled. @@ -336,37 +868,7 @@ void main() { await tester.pumpAndSettle(); // Ensure a new node was added. - expect(document.nodes.length, 2); - }); - - testWidgetsOnDesktop('configures for keyboard input by default on desktop', (tester) async { - await tester // - .createDocument() - .withSingleEmptyParagraph() - .pump(); - - final document = SuperEditorInspector.findDocument()!; - - // Ensure the document was created with one node. - expect(document.nodes.length, 1); - - // Tap to give focus to the editor. - await tester.placeCaretInParagraph(document.nodes.first.id, 0); - - // Ensure that IME input is disabled. To check IME input, we arbitrarily simulate a newline action from - // the IME. If the editor doesn't respond to the newline, it means IME input is disabled. - // We expect that the document content remains unchanged. - await tester.testTextInput.receiveAction(TextInputAction.newline); - await tester.pumpAndSettle(); - - // Ensure no node was added. - expect(document.nodes.length, 1); - - // Simulate typing on a keyboard. - await tester.typeKeyboardText('abc'); - - // Ensure text was added. - expect(SuperEditorInspector.findTextInParagraph(document.nodes.first.id).text, 'abc'); + expect(document.nodeCount, 2); }); }); } @@ -374,7 +876,7 @@ void main() { Future _pumpSingleLineWithCaret( WidgetTester tester, { required int offset, - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final testContext = await tester // .createDocument() @@ -382,7 +884,7 @@ Future _pumpSingleLineWithCaret( .withInputSource(inputSource) .pump(); - final nodeId = testContext.editContext.editor.document.nodes.first.id; + final nodeId = testContext.findEditContext().document.first.id; await tester.placeCaretInParagraph(nodeId, offset); @@ -390,7 +892,7 @@ Future _pumpSingleLineWithCaret( } Future _pumpDoubleLineWithCaret(WidgetTester tester, - {required int offset, required DocumentInputSource inputSource}) async { + {required int offset, required TextInputSource inputSource}) async { final testContext = await tester // .createDocument() // Text indices: @@ -400,7 +902,7 @@ Future _pumpDoubleLineWithCaret(WidgetTester tester, .fromMarkdown("This is the first paragraph.\nThis is the second paragraph.") .pump(); - final nodeId = testContext.editContext.editor.document.nodes.first.id; + final nodeId = testContext.findEditContext().document.first.id; await tester.placeCaretInParagraph(nodeId, offset); @@ -420,7 +922,7 @@ Future _pumpUnorderedList(WidgetTester tester) async { final testContext = await tester // .createDocument() .fromMarkdown(markdown) - .withInputSource(DocumentInputSource.ime) + .withInputSource(TextInputSource.ime) .pump(); return testContext; @@ -444,3 +946,34 @@ DocumentSelection _selectionInParagraph( extent: DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: to, affinity: toAffinity)), ); } + +/// A widget that calls [SoftwareKeyboardController.close] during `dispose()`. +/// +/// This behavior ensures that Super Editor users can close the keyboard as their +/// Super Editor experience goes out of existence, such as navigation. +class _CloseKeyboardOnDispose extends StatefulWidget { + const _CloseKeyboardOnDispose({ + Key? key, + required this.keyboardController, + required this.child, + }) : super(key: key); + + final SoftwareKeyboardController keyboardController; + final Widget child; + + @override + State<_CloseKeyboardOnDispose> createState() => _CloseKeyboardOnDisposeState(); +} + +class _CloseKeyboardOnDisposeState extends State<_CloseKeyboardOnDispose> { + @override + void dispose() { + widget.keyboardController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/super_editor/test/super_editor/supereditor_mobile_handles_test.dart b/super_editor/test/super_editor/supereditor_mobile_handles_test.dart deleted file mode 100644 index 0e4846038e..0000000000 --- a/super_editor/test/super_editor/supereditor_mobile_handles_test.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_editor_test.dart'; - -import '../test_tools.dart'; -import 'document_test_tools.dart'; - -void main() { - group("SuperEditor mobile drag handles", () { - testWidgetsOnAndroid("with caret change colors (on Android)", (tester) async { - final testContext = await tester // - .createDocument() // - .fromMarkdown("This is some text to select.") // - .useAppTheme(ThemeData(primaryColor: Colors.red)) // - .pump(); - final nodeId = testContext.editContext.editor.document.nodes.first.id; - - await tester.placeCaretInParagraph(nodeId, 15); - - expectLater( - find.byType(MaterialApp), - matchesGoldenFile("goldens/mobile/supereditor_android_collapsed_handle_color.png"), - ); - }); - - testWidgetsOnAndroid("with selection change colors (on Android)", (tester) async { - final testContext = await tester // - .createDocument() // - .fromMarkdown("This is some text to select.") // - .useAppTheme(ThemeData(primaryColor: Colors.red)) // - .pump(); - final nodeId = testContext.editContext.editor.document.nodes.first.id; - - await tester.doubleTapInParagraph(nodeId, 15); - - expectLater( - find.byType(MaterialApp), - matchesGoldenFile("goldens/mobile/supereditor_android_expanded_handle_color.png"), - ); - }); - - testWidgetsOnIos("with caret change colors (on iOS)", (tester) async { - final testContext = await tester // - .createDocument() // - .fromMarkdown("This is some text to select.") // - .useAppTheme(ThemeData(primaryColor: Colors.red)) // - .pump(); - final nodeId = testContext.editContext.editor.document.nodes.first.id; - - await tester.placeCaretInParagraph(nodeId, 15); - - expectLater( - find.byType(MaterialApp), - matchesGoldenFile("goldens/mobile/supereditor_ios_collapsed_handle_color.png"), - ); - }); - - testWidgetsOnIos("with selection change colors (on iOS)", (tester) async { - final testContext = await tester // - .createDocument() // - .fromMarkdown("This is some text to select.") // - .useAppTheme(ThemeData(primaryColor: Colors.red)) // - .pump(); - final nodeId = testContext.editContext.editor.document.nodes.first.id; - - await tester.doubleTapInParagraph(nodeId, 15); - - expectLater( - find.byType(MaterialApp), - matchesGoldenFile("goldens/mobile/supereditor_ios_expanded_handle_color.png"), - ); - }); - }); -} diff --git a/super_editor/test/super_editor/supereditor_multi_editor_test.dart b/super_editor/test/super_editor/supereditor_multi_editor_test.dart new file mode 100644 index 0000000000..9a7f3827fc --- /dev/null +++ b/super_editor/test/super_editor/supereditor_multi_editor_test.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group("SuperEditor > multiple editors >", () { + testWidgetsOnAllPlatforms("can select both editors", (tester) async { + final editor1Key = GlobalKey(); + final editor2Key = GlobalKey(); + + await _buildTextScaleScaffold( + tester, + editor1: _buildSuperEditor(tester, "super-editor-1", key: editor1Key), + editor2: _buildSuperEditor(tester, "super-editor-2", key: editor2Key), + ); + + // Select different text in each editor. + // Text starts with: "Lorem ipsum dolor sit amet, consectetur adipiscing...." + await tester.placeCaretInParagraph("1", 6, superEditorFinder: find.byKey(editor1Key)); + await tester.placeCaretInParagraph("1", 12, superEditorFinder: find.byKey(editor2Key)); + + // Ensure that both editors have the expected selections. + expect( + SuperEditorInspector.findDocumentSelection(find.byKey(editor1Key)), + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 6)), + ), + ); + expect( + SuperEditorInspector.findDocumentSelection(find.byKey(editor2Key)), + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 12)), + ), + ); + }); + }); + + group("SuperEditor > editor switching >", () { + testWidgetsOnAllPlatforms("can switch between editors", (tester) async { + await tester.pumpWidget(const _SwitchEditorsDemo()); + + // Ensure that the first editor content is visible. + expect(SuperEditorInspector.findWidgetForComponent("Editor1_Header"), isNotNull); + expect(SuperEditorInspector.findWidgetForComponent("Editor1_Para"), isNotNull); + + // Switch to the second editor. + await tester.tap(find.byKey(const ValueKey("Editor2"))); + await tester.pump(); + + // Ensure that the second editor content is visible. + expect(SuperEditorInspector.findWidgetForComponent("Editor2_Header"), isNotNull); + expect(SuperEditorInspector.findWidgetForComponent("Editor2_Para"), isNotNull); + }); + + testWidgetsOnAllPlatforms("restores selection when switching back to a previously selected editor", (tester) async { + const docSelection1 = DocumentSelection( + base: DocumentPosition( + nodeId: "Editor1_Header", + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: "Editor1_Para", + nodePosition: TextNodePosition(offset: 10), + ), + ); + final composer1 = MutableDocumentComposer( + initialSelection: docSelection1, + ); + + const docSelection2 = DocumentSelection( + base: DocumentPosition( + nodeId: "Editor2_Header", + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: "Editor2_Para", + nodePosition: TextNodePosition(offset: 5), + ), + ); + final composer2 = MutableDocumentComposer( + initialSelection: docSelection2, + ); + + await tester.pumpWidget(_SwitchEditorsDemo( + composer1: composer1, + composer2: composer2, + )); + + // Switch to the second editor. + await tester.tap(find.byKey(const ValueKey("Editor2"))); + await tester.pump(); + + // Ensure that the original selection is maintained for the second editor. + expect( + SuperEditorInspector.findDocumentSelection(), + docSelection2, + ); + + await tester.tap(find.byKey(const ValueKey("Editor1"))); + await tester.pump(); + + // Ensure that the original selection is maintained for the first editor. + expect( + SuperEditorInspector.findDocumentSelection(), + docSelection1, + ); + }); + + testWidgetsOnDesktop("the user can select content after switching to a different editor", (tester) async { + await tester.pumpWidget(const _SwitchEditorsDemo()); + + // Switch to the second editor. + await tester.tap(find.byKey(const ValueKey("Editor2"))); + await tester.pump(); + + final document = SuperEditorInspector.findDocument()!; + final header = document.getNodeById("Editor2_Header") as ParagraphNode; + final paragraph = document.getNodeById("Editor2_Para") as ParagraphNode; + + // Change the selection on the second editor. + await tester.dragSelectDocumentFromPositionByOffset( + from: DocumentPosition( + nodeId: header.id, + nodePosition: header.beginningPosition, + ), + delta: const Offset(0, 400), + ); + + // Ensure that the selection was changed. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection( + base: DocumentPosition( + nodeId: header.id, + nodePosition: header.beginningPosition, + ), + extent: DocumentPosition( + nodeId: paragraph.id, + nodePosition: paragraph.endPosition, + ), + ), + ); + }); + + testWidgetsOnDesktop("the user can edit content after switching to a different editor", (tester) async { + await tester.pumpWidget( + _SwitchEditorsDemo( + composer2: MutableDocumentComposer( + initialSelection: const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "Editor2_Header", + nodePosition: TextNodePosition(offset: "Document #2".length), + ), + ), + ), + ), + ); + + // Enable the SuperEditor. + await tester.placeCaretInParagraph("Editor1_Header", 0); + + // Switch to the second editor. + await tester.tap(find.byKey(const ValueKey("Editor2"))); + await tester.pump(); + + await tester.pressBackspace(); + + // Ensure that the text was edited upon pressing backspace. + expect( + SuperEditorInspector.findTextInComponent("Editor2_Header").toPlainText(), + "Document #", + ); + + await tester.typeImeText("Edit"); + + // Ensure that the text was inserted into the paragraph. + expect( + SuperEditorInspector.findTextInComponent("Editor2_Header").toPlainText(), + "Document #Edit", + ); + }); + }); +} + +Widget _buildSuperEditor( + WidgetTester tester, + String inputRole, { + Key? key, +}) { + return tester // + .createDocument() + .withSingleParagraph() + .withKey(key) + .withInputRole(inputRole) + // Testing concurrent selections across multiple editors requires + // that each editor leave their selection alone when losing focus + // or closing the IME. + .withSelectionPolicies( + const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: false, + clearSelectionWhenImeConnectionCloses: false, + ), + ) + .build() + .widget; +} + +/// Pumps a widget tree containing two editors side by side. +Future _buildTextScaleScaffold( + WidgetTester tester, { + required Widget editor1, + required Widget editor2, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + Expanded( + child: editor1, + ), + Expanded( + child: editor2, + ), + ], + ), + ), + ), + ); +} + +/// Demo of an [SuperEditor] widget where the [Editor] changes. +/// +/// This demo ensures that [SuperEditor] state resets where appropriate +/// when its content is replaced. +class _SwitchEditorsDemo extends StatefulWidget { + const _SwitchEditorsDemo({ + Key? key, + this.composer1, + this.composer2, + }) : super(key: key); + + final MutableDocumentComposer? composer1; + final MutableDocumentComposer? composer2; + + @override + State<_SwitchEditorsDemo> createState() => _SwitchEditorsDemoState(); +} + +class _SwitchEditorsDemoState extends State<_SwitchEditorsDemo> { + late MutableDocument _doc1; + late MutableDocumentComposer _composer1; + late Editor _docEditor1; + + late MutableDocument _doc2; + late MutableDocumentComposer _composer2; + late Editor _docEditor2; + + late Editor _activeDocumentEditor; + + @override + void initState() { + super.initState(); + _doc1 = _createDocument1(); + _composer1 = widget.composer1 ?? MutableDocumentComposer(); + _docEditor1 = createDefaultDocumentEditor(document: _doc1, composer: _composer1); + + _doc2 = _createDocument2(); + _composer2 = widget.composer2 ?? MutableDocumentComposer(); + _docEditor2 = createDefaultDocumentEditor(document: _doc2, composer: _composer2); + + _activeDocumentEditor = _docEditor1; + } + + @override + void dispose() { + _docEditor1.dispose(); + _docEditor2.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Column( + children: [ + _buildDocSelector(), + Expanded( + child: SuperEditor( + // Note: We give this `SuperEditor` the "global" input role because even though + // we're switching its backing `Editor`, we aren't switching between visibly + // different `SuperEditor`s. + editor: _activeDocumentEditor, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildDocSelector() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + key: const ValueKey("Editor1"), + onPressed: () { + setState(() { + _activeDocumentEditor = _docEditor1; + }); + }, + child: const Text('Document 1'), + ), + const SizedBox(width: 24), + TextButton( + key: const ValueKey("Editor2"), + onPressed: () { + setState(() { + _activeDocumentEditor = _docEditor2; + }); + }, + child: const Text('Document 2'), + ), + ], + ); + } +} + +MutableDocument _createDocument1() { + return MutableDocument( + nodes: [ + ParagraphNode( + id: "Editor1_Header", + text: AttributedText('Document #1'), + metadata: { + 'blockType': header1Attribution, + }, + ), + ParagraphNode( + id: "Editor1_Para", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ], + ); +} + +MutableDocument _createDocument2() { + return MutableDocument( + nodes: [ + ParagraphNode( + id: "Editor2_Header", + text: AttributedText('Document #2'), + metadata: { + 'blockType': header1Attribution, + }, + ), + ParagraphNode( + id: "Editor2_Para", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ], + ); +} diff --git a/super_editor/test/super_editor/supereditor_plugin_test.dart b/super_editor/test/super_editor/supereditor_plugin_test.dart new file mode 100644 index 0000000000..c7310c555b --- /dev/null +++ b/super_editor/test/super_editor/supereditor_plugin_test.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperEditor > plugins > lifecycle', () { + testWidgetsOnAllPlatforms('are detached when the editor is disposed', (tester) async { + final plugin = _FakePlugin(); + + await tester // + .createDocument() + .withSingleParagraph() + .withPlugin(plugin) + .pump(); + + // Ensure the plugin was not attached initially. + expect(plugin.detachCallCount, 0); + + // Pump another widget tree to dispose SuperEditor. + await tester.pumpWidget(Container()); + + // Ensure the plugin was detached. + expect(plugin.detachCallCount, 1); + }); + + group('rebuild >', () { + testWidgetsOnAllPlatforms('different SuperEditor, same Editor, same plugin instance', (tester) async { + final pump1Key = GlobalKey(debugLabel: 'pump-1'); + final pump2Key = GlobalKey(debugLabel: 'pump-2'); + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText()), + ], + ), + composer: MutableDocumentComposer(), + ); + final plugin = _FakePlugin(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: pump1Key, + body: SuperEditor( + editor: editor, + plugins: {plugin}, + ), + ), + ), + ); + + // Grab the instance of the context resource that was added by + // the plugin. We want to make sure the instance doesn't disappear + // or get replaced. + expect(plugin.attachCallCount, 1); + expect(plugin.detachCallCount, 0); + final resource1 = editor.context.findMaybe(_FakePluginResource.key); + expect(resource1, isNotNull); + + // Pump another widget tree to replace the existing Super Editor tree + // with another Super Editor tree (simulating something like a navigator + // replacing an entire subtree, including SuperEditor, but wanting to + // continue using the same backing editor and document). + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: pump2Key, + body: SuperEditor( + editor: editor, + plugins: {plugin}, + ), + ), + ), + ); + + // Since we retained the same `Editor` and the same plugin instance, across two + // different `SuperEditor` widgets, we expect that the plugin remained attached + // to the `Editor` the whole time, and the resource instance remained the same, too. + expect(plugin.attachCallCount, 1); + expect(plugin.detachCallCount, 0); + final resource2 = editor.context.findMaybe(_FakePluginResource.key); + expect(resource2, isNotNull); + expect(resource1, resource2); + }); + + testWidgetsOnAllPlatforms('different SuperEditor, same Editor, different plugin instance', (tester) async { + final pump1Key = GlobalKey(debugLabel: 'pump-1'); + final plugin1 = _FakePlugin(); + + final pump2Key = GlobalKey(debugLabel: 'pump-2'); + final plugin2 = _FakePlugin(); + + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText()), + ], + ), + composer: MutableDocumentComposer(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: pump1Key, + body: SuperEditor( + editor: editor, + plugins: {plugin1}, + ), + ), + ), + ); + + // Grab the instance of the context resource that was added by + // the plugin. We expect that this resource instance will change + // because the plugin will be replaced with a second plugin. + expect(plugin1.attachCallCount, 1); + expect(plugin1.detachCallCount, 0); + final resource1 = editor.context.findMaybe(_FakePluginResource.key); + expect(resource1, isNotNull); + + // Pump another widget tree to replace the existing Super Editor tree + // with another Super Editor tree (simulating something like a navigator + // replacing an entire subtree, including SuperEditor, but wanting to + // continue using the same backing editor and document). + // + // Also replace one plugin with another instance of that plugin. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: pump2Key, + body: SuperEditor( + editor: editor, + plugins: {plugin2}, + ), + ), + ), + ); + + // Grab the context resource again and ensure the first plugin's resource + // was replaced by the second plugin's resource. + expect(plugin1.attachCallCount, 1); + expect(plugin1.detachCallCount, 1); + + expect(plugin2.attachCallCount, 1); + expect(plugin2.detachCallCount, 0); + + final resource2 = editor.context.findMaybe(_FakePluginResource.key); + expect(resource2, isNotNull); + expect(resource1, isNot(resource2)); + }); + + testWidgetsOnAllPlatforms('same SuperEditor, different Editor, same plugin instance', (tester) async { + final superEditorKey = GlobalKey(debugLabel: 'SuperEditor'); + final plugin = _FakePlugin(); + + final editor1 = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText()), + ], + ), + composer: MutableDocumentComposer(), + ); + + final editor2 = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText()), + ], + ), + composer: MutableDocumentComposer(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: superEditorKey, + body: SuperEditor( + editor: editor1, + plugins: {plugin}, + ), + ), + ), + ); + + // Ensure the plugin attached itself. Grab the resource because we expect + // it to the stay the same. + expect(plugin.attachCallCount, 1); + expect(plugin.detachCallCount, 0); + + final resource1 = editor1.context.findMaybe(_FakePluginResource.key); + expect(resource1, plugin.fakeResource); + + // Re-pump with a different Editor, but the same SuperEditor and plugin. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: superEditorKey, + body: SuperEditor( + editor: editor2, + plugins: {plugin}, + ), + ), + ), + ); + + // Ensure the plugin detached and re-attached when the `SuperEditor` rebuilt + // with a new `Editor`. Ensure the same plugin resource is in the context. + expect(plugin.attachCallCount, 2); + expect(plugin.detachCallCount, 1); + + final resource2 = editor2.context.findMaybe(_FakePluginResource.key); + expect(resource2, plugin.fakeResource); + }); + + testWidgetsOnAllPlatforms('different SuperEditor, different Editor, same plugin instance', (tester) async { + final plugin = _FakePlugin(); + + final pump1Key = GlobalKey(debugLabel: 'pump-1'); + final editor1 = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText()), + ], + ), + composer: MutableDocumentComposer(), + ); + + final pump2Key = GlobalKey(debugLabel: 'pump-2'); + final editor2 = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText()), + ], + ), + composer: MutableDocumentComposer(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: pump1Key, + body: SuperEditor( + editor: editor1, + plugins: {plugin}, + ), + ), + ), + ); + + // Ensure the plugin attached itself. We expect the plugin resource to remain the same. + expect(plugin.attachCallCount, 1); + expect(plugin.detachCallCount, 0); + final resource1 = editor1.context.findMaybe(_FakePluginResource.key); + expect(resource1, plugin.fakeResource); + + // Re-pump with a different Editor, but the same plugin. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + key: pump2Key, + body: SuperEditor( + editor: editor2, + plugins: {plugin}, + ), + ), + ), + ); + + // Ensure the plugin detached and re-attached, and the same resource is registered + // with the new editor context. + expect(plugin.attachCallCount, 2); + expect(plugin.detachCallCount, 1); + + final resource2 = editor2.context.findMaybe(_FakePluginResource.key); + expect(resource2, plugin.fakeResource); + }); + }); + }); +} + +/// A plugin that tracks whether it was detached. +class _FakePlugin extends SuperEditorPlugin { + int get attachCallCount => _attachCallCount; + int _attachCallCount = 0; + + int get detachCallCount => _detachCallCount; + int _detachCallCount = 0; + + final fakeResource = _FakePluginResource(); + + @override + void attach(Editor editor) { + editor.context.put(_FakePluginResource.key, fakeResource); + + _attachCallCount += 1; + } + + @override + void detach(Editor editor) { + editor.context.remove(_FakePluginResource.key, fakeResource); + + _detachCallCount += 1; + } +} + +class _FakePluginResource extends Editable { + static const key = "fake-resource"; +} diff --git a/super_editor/test/super_editor/supereditor_popover_focus_test.dart b/super_editor/test/super_editor/supereditor_popover_focus_test.dart index 9e9891992d..86df4308f3 100644 --- a/super_editor/test/super_editor/supereditor_popover_focus_test.dart +++ b/super_editor/test/super_editor/supereditor_popover_focus_test.dart @@ -1,12 +1,9 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; - -import '../super_textfield/super_textfield_inspector.dart'; -import '../super_textfield/super_textfield_robot.dart'; -import '../test_tools.dart'; -import 'document_test_tools.dart'; +import 'package:super_editor/super_text_field_test.dart'; void main() { group("SuperEditor popover focus", () { @@ -14,7 +11,7 @@ void main() { final editContext = await tester .createDocument() .withSingleParagraph() - .withInputSource(DocumentInputSource.ime) + .withInputSource(TextInputSource.ime) .autoFocus(true) .pump(); @@ -30,7 +27,13 @@ void main() { nodePosition: TextNodePosition(offset: 20), ), ); - editContext.editContext.composer.selection = documentSelection; + editContext.findEditContext().editor.execute([ + const ChangeSelectionRequest( + documentSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); await tester.pumpAndSettle(); expect(SuperEditorInspector.findDocumentSelection(), documentSelection); @@ -66,7 +69,13 @@ void main() { nodePosition: TextNodePosition(offset: 20), ), ); - editContext.editContext.composer.selection = documentSelection; + editContext.findEditContext().editor.execute([ + const ChangeSelectionRequest( + documentSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); await tester.pumpAndSettle(); expect(SuperEditorInspector.findDocumentSelection(), documentSelection); @@ -104,7 +113,7 @@ Future _showPopover( ); }); - Overlay.of(context)!.insert(_overlayEntry!); + Overlay.of(context).insert(_overlayEntry!); await tester.pump(); } @@ -115,7 +124,7 @@ Future _hidePopover(WidgetTester tester) async { await tester.pump(); } -class _Popover extends StatelessWidget { +class _Popover extends StatefulWidget { const _Popover({ Key? key, required this.editorFocusNode, @@ -125,16 +134,29 @@ class _Popover extends StatelessWidget { final FocusNode editorFocusNode; final FocusNode textFieldFocusNode; + @override + State<_Popover> createState() => _PopoverState(); +} + +class _PopoverState extends State<_Popover> { + final _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Center( child: ConstrainedBox( constraints: const BoxConstraints(minWidth: 300, minHeight: 56), - child: FocusWithCustomParent( - parentFocusNode: editorFocusNode, - focusNode: textFieldFocusNode, + child: Focus( + focusNode: _popoverFocusNode, + parentNode: widget.editorFocusNode, child: SuperTextField( - focusNode: textFieldFocusNode, + focusNode: widget.textFieldFocusNode, lineHeight: 20, ), ), diff --git a/super_editor/test/super_editor/supereditor_robot_test.dart b/super_editor/test/super_editor/supereditor_robot_test.dart index 093c322228..93755ee07a 100644 --- a/super_editor/test/super_editor/supereditor_robot_test.dart +++ b/super_editor/test/super_editor/supereditor_robot_test.dart @@ -2,12 +2,10 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; -import '../test_tools.dart'; -import 'document_test_tools.dart'; - void main() { group("SuperEditor robot", () { testWidgetsOnAllPlatforms("taps to place caret in empty paragraph", (tester) async { @@ -116,8 +114,11 @@ void main() { ), ); + // Tap on another character, because tapping on the same character shows the toolbar + // instead of changing the selection. + await tester.placeCaretInParagraph("1", 5); + // Place the caret at the same offset as before but with an upstream affinity. - await tester.pump(kTapTimeout * 2); // Pause to avoid double tap. await tester.placeCaretInParagraph("1", 1, affinity: TextAffinity.upstream); // Ensure the document has the correct selection, including affinity; expect( @@ -167,7 +168,7 @@ void main() { .createDocument() .withSingleEmptyParagraph() .forDesktop() - .withInputSource(DocumentInputSource.keyboard) + .withInputSource(TextInputSource.keyboard) .autoFocus(true) .pump(); @@ -178,7 +179,30 @@ void main() { await tester.typeKeyboardText("Hello, world!"); // Verify that SuperEditor displays the text we typed. - expect(SuperEditorInspector.findTextInParagraph("1").text, "Hello, world!"); + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Hello, world!"); + }); + + testWidgetsOnDesktop("enters text with hardware keyboard with multiple taps", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.keyboard) + .pump(); + + // Tap to place the caret in the first paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Type some text by simulating hardware keyboard key presses. + await tester.typeKeyboardText("Hello, world!"); + + // Place the caret at the end of the paragraph. + await tester.placeCaretInParagraph("1", 13); + + // Type another text. + await tester.typeKeyboardText("ABC"); + + // Ensure that the text is inserted. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Hello, world!ABC"); }); testWidgetsOnDesktop("enters text with IME keyboard", (tester) async { @@ -187,7 +211,7 @@ void main() { .createDocument() .withSingleEmptyParagraph() .forDesktop() - .withInputSource(DocumentInputSource.ime) + .withInputSource(TextInputSource.ime) .autoFocus(true) .pump(); @@ -198,7 +222,74 @@ void main() { await tester.typeImeText("Hello, world!"); // Verify that SuperEditor displays the text we typed. - expect(SuperEditorInspector.findTextInParagraph("1").text, "Hello, world!"); + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Hello, world!"); + }); + + testWidgetsOnDesktop("enters text with IME keyboard with multiple taps", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Tap to place the caret in the first paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Type some text by simulating IME keyboard key presses. + await tester.typeImeText("Hello, world!"); + + // Place the caret at the end of the paragraph. + await tester.placeCaretInParagraph("1", 13); + + // Type another text. + await tester.typeImeText("ABC"); + + // Ensure that the text is inserted. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "Hello, world!ABC"); + }); + + testWidgetsOnAllPlatforms("performs back to back taps with hardware keyboard", (tester) async { + final testContext = await tester // + .createDocument() + .fromMarkdown('Hello, world!') + .withInputSource(TextInputSource.keyboard) + .pump(); + + final nodeId = testContext.document.first.id; + + // Tap to place the caret in the first paragraph. + await tester.placeCaretInParagraph(nodeId, 0); + + // Place the caret at 'Hello, |world!'. + await tester.placeCaretInParagraph(nodeId, 7); + + // Type another text. + await tester.typeKeyboardText("new "); + + // Ensure that the text is inserted. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello, new world!"); + }); + + testWidgetsOnAllPlatforms("performs back to back taps with software keyboard", (tester) async { + final testContext = await tester // + .createDocument() + .fromMarkdown('Hello, world!') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = testContext.document.first.id; + + // Tap to place the caret in the first paragraph. + await tester.placeCaretInParagraph(nodeId, 0); + + // Place the caret at 'Hello, |world!'. + await tester.placeCaretInParagraph(nodeId, 7); + + // Type another text. + await tester.typeImeText("new "); + + // Ensure that the text is inserted. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Hello, new world!"); }); }); } diff --git a/super_editor/test/super_editor/supereditor_route_test.dart b/super_editor/test/super_editor/supereditor_route_test.dart new file mode 100644 index 0000000000..dc530ec5cf --- /dev/null +++ b/super_editor/test/super_editor/supereditor_route_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperEditor > routes >', () { + testWidgetsOnAllPlatforms('can be used with a route with a delegated transition on top', (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + Expanded( + child: superEditor, + ), + Builder(builder: (context) { + return ElevatedButton( + child: const Text('delegatedTransition'), + onPressed: () { + Navigator.of(context).push(_TestRoute()); + }, + ); + }), + ], + ), + ), + ), + ) + .pump(); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + // Reaching this point means that the editor did not crash when the route with + // a delegated transition was pushed on top of it. + // See https://github.com/Flutter-Bounty-Hunters/super_editor/issues/2794 for details. + }); + }); +} + +/// A [ModalRoute] that uses a delegated transition. +class _TestRoute extends ModalRoute { + _TestRoute(); + + @override + DelegatedTransitionBuilder? get delegatedTransition => + (context, animation, secondaryAnimation, allowSnapshotting, child) { + return FadeTransition( + opacity: animation, + child: child, + ); + }; + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + bool get barrierDismissible => true; + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + return const Center( + child: Text('Hello'), + ); + } + + @override + bool get maintainState => true; + + @override + bool get opaque => false; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); +} diff --git a/super_editor/test/super_editor/supereditor_scribble_test.dart b/super_editor/test/super_editor/supereditor_scribble_test.dart new file mode 100644 index 0000000000..25c4fc72f5 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_scribble_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group('Scribble', () { + group('state tracking', () { + testWidgetsOnIos('tracks scribble in-progress via insertTextPlaceholder and removeTextPlaceholder', + (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret so the IME connects. + await tester.placeCaretInParagraph('1', 0); + + // Simulate the platform calling insertTextPlaceholder (Scribble start). + await _sendScribbleInsertPlaceholder(tester, const Size(20, 20)); + + // Pump to let the frame callback run. + await tester.pump(); + + // Simulate the platform calling removeTextPlaceholder (Scribble end). + await _sendScribbleRemovePlaceholder(tester); + await tester.pump(); + }); + + testWidgetsOnAndroid('tracks scribble in-progress via insertTextPlaceholder and removeTextPlaceholder', + (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret so the IME connects. + await tester.placeCaretInParagraph('1', 0); + + // Simulate the platform calling insertTextPlaceholder (Scribble start). + await _sendScribbleInsertPlaceholder(tester, const Size(20, 20)); + await tester.pump(); + + // Simulate the platform calling removeTextPlaceholder (Scribble end). + await _sendScribbleRemovePlaceholder(tester); + await tester.pump(); + }); + }); + + group('IME text input during scribble', () { + testWidgetsOnIos('applies text deltas during scribble', (tester) async { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: '1', text: AttributedText('Hello')), + ], + ); + + await tester // + .createDocument() + .withCustomContent(document) + .withInputSource(TextInputSource.ime) + .pump(); + + // Place caret at end of text. + await tester.placeCaretInParagraph('1', 5); + + // Start a scribble interaction. + await _sendScribbleInsertPlaceholder(tester, const Size(20, 20)); + await tester.pump(); + + // Type text via IME (simulating scribble-converted text). + await tester.typeImeText(' world'); + + // Verify text was inserted. + expect( + (document.getNodeAt(0)! as ParagraphNode).text.toPlainText(), + 'Hello world', + ); + + // End scribble. + await _sendScribbleRemovePlaceholder(tester); + await tester.pump(); + }); + }); + }); +} + +/// Simulates the platform sending `TextInputClient.insertTextPlaceholder` to the +/// Flutter engine, which happens when a Scribble interaction begins. +Future _sendScribbleInsertPlaceholder(WidgetTester tester, Size size) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + 'TextInputClient.insertTextPlaceholder', + [-1, size.width, size.height], + ), + ), + null, + ); +} + +/// Simulates the platform sending `TextInputClient.removeTextPlaceholder` to the +/// Flutter engine, which happens when a Scribble interaction ends. +Future _sendScribbleRemovePlaceholder(WidgetTester tester) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + const MethodCall( + 'TextInputClient.removeTextPlaceholder', + [-1], + ), + ), + null, + ); +} diff --git a/super_editor/test/super_editor/supereditor_scrolling_test.dart b/super_editor/test/super_editor/supereditor_scrolling_test.dart index db2a303ed3..865303b3e0 100644 --- a/super_editor/test/super_editor/supereditor_scrolling_test.dart +++ b/super_editor/test/super_editor/supereditor_scrolling_test.dart @@ -1,11 +1,16 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/blinking_caret.dart'; +import 'package:super_editor/src/infrastructure/flutter/material_scrollbar.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; import '../test_tools.dart'; -import 'document_test_tools.dart'; +import 'test_documents.dart'; void main() { group("SuperEditor scrolling", () { @@ -19,7 +24,7 @@ void main() { .pump(); final document = SuperEditorInspector.findDocument()!; - final firstParagraph = document.nodes.first as ParagraphNode; + final firstParagraph = document.first as ParagraphNode; final dragGesture = await tester.startDocumentDragFromPosition( from: DocumentPosition( @@ -54,10 +59,11 @@ void main() { .pump(); final document = SuperEditorInspector.findDocument()!; - final lastParagraph = document.nodes.last as ParagraphNode; + final lastParagraph = document.last as ParagraphNode; // Jump to the end of the document scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); final dragGesture = await tester.startDocumentDragFromPosition( from: DocumentPosition( @@ -84,7 +90,7 @@ void main() { testWidgetsOnDesktop("auto-scrolls down", (tester) async { const windowSize = Size(800, 600); - tester.binding.window.physicalSizeTestValue = windowSize; + tester.view.physicalSize = windowSize; await tester // .createDocument() // @@ -93,8 +99,8 @@ void main() { .pump(); final document = SuperEditorInspector.findDocument()!; - final firstParagraph = document.nodes.first as ParagraphNode; - final lastParagraph = document.nodes.last as ParagraphNode; + final firstParagraph = document.first as ParagraphNode; + final lastParagraph = document.last as ParagraphNode; final dragGesture = await tester.startDocumentDragFromPosition( from: DocumentPosition( @@ -129,7 +135,7 @@ void main() { testWidgetsOnDesktop("auto-scrolls up", (tester) async { const windowSize = Size(800, 600); - tester.binding.window.physicalSizeTestValue = windowSize; + tester.view.physicalSize = windowSize; final docContext = await tester // .createDocument() // @@ -138,17 +144,24 @@ void main() { .pump(); final document = SuperEditorInspector.findDocument()!; - final firstParagraph = document.nodes.first as ParagraphNode; - final lastParagraph = document.nodes.last as ParagraphNode; + final firstParagraph = document.first as ParagraphNode; + final lastParagraph = document.last as ParagraphNode; // Place the caret at the end of the document, which causes the editor to // scroll to the bottom. - docContext.editContext.composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: lastParagraph.id, - nodePosition: lastParagraph.endPosition, + docContext.findEditContext().editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: lastParagraph.id, + nodePosition: lastParagraph.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, ), - ); + ]); + docContext.focusNode.requestFocus(); await tester.pumpAndSettle(); @@ -170,25 +183,75 @@ void main() { // Ensure that the entire document is selected. expect( SuperEditorInspector.findDocumentSelection(), - DocumentSelection( - base: DocumentPosition( - nodeId: lastParagraph.id, - nodePosition: TextNodePosition( - offset: lastParagraph.endPosition.offset, - affinity: TextAffinity.upstream, + selectionEquivalentTo( + DocumentSelection( + base: DocumentPosition( + nodeId: lastParagraph.id, + nodePosition: TextNodePosition( + offset: lastParagraph.endPosition.offset, + affinity: TextAffinity.upstream, + ), + ), + extent: DocumentPosition( + nodeId: firstParagraph.id, + nodePosition: firstParagraph.beginningPosition, ), - ), - extent: DocumentPosition( - nodeId: firstParagraph.id, - nodePosition: firstParagraph.beginningPosition, ), ), ); }); + testWidgetsOnMobile('starts auto-scrolling when dragging near the top', (tester) async { + final scrollController = ScrollController(); + + // Pump an editor with an appbar above the editor so we make sure that + // auto-scroll starts when the user dragged near the top of the editor, + // not at the top of the screen. + await tester + .createDocument() + .withLongTextContent() + .withScrollController(scrollController) + .withEditorSize(const Size(300, 300)) + .autoFocus(true) + .withAppBar(100.0) + .pump(); + + // Scroll all the way to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pumpAndSettle(); + + // Place the caret at approximately at the middle of the first visible line. + await tester.tapAt(tester.getTopLeft(find.byType(SuperEditor)) + const Offset(150, 20)); + await tester.pump(kDoubleTapTimeout); + + final scrollOffsetBeforeDrag = scrollController.offset; + + // Drag the handle a bit to the top. + final dragGesture = await tester.startGesture(tester.getCenter( + SuperEditorInspector.findMobileCaretDragHandle(), + )); + await dragGesture.moveBy(const Offset(0, -20)); + await tester.pump(); + + // Pump some frames to let the auto-scroll kick in. + for (int i = 0; i < 60; i += 1) { + await tester.pump(); + } + + // Release the gesture. + await dragGesture.up(); + await tester.pump(); + + // Ensure the editor scrolled up. + expect(scrollController.offset, lessThan(scrollOffsetBeforeDrag)); + + // Let the long-press timer resolve. + await tester.pump(kLongPressTimeout); + }); + testWidgetsOnDesktop("auto-scrolls to caret position", (tester) async { const windowSize = Size(800, 600); - tester.binding.window.physicalSizeTestValue = windowSize; + tester.view.physicalSize = windowSize; final docContext = await tester // .createDocument() // @@ -196,16 +259,22 @@ void main() { .forDesktop() // .pump(); final document = SuperEditorInspector.findDocument()!; - final lastParagraph = document.nodes.last as ParagraphNode; + final lastParagraph = document.last as ParagraphNode; // Place the caret at the end of the document, which should cause the // editor to scroll to the bottom. - docContext.editContext.composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: lastParagraph.id, - nodePosition: lastParagraph.endPosition, + docContext.findEditContext().editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: lastParagraph.id, + nodePosition: lastParagraph.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, ), - ); + ]); docContext.focusNode.requestFocus(); await tester.pumpAndSettle(); @@ -221,5 +290,1392 @@ void main() { isTrue, ); }); + + testWidgetsOnAndroid("auto-scrolls to caret position when dragging the spacebar", (tester) async { + // Pump an editor with a size that will cause it to be scrollable. + const windowSize = Size(800, 400); + tester.view.physicalSize = windowSize; + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + await tester // + .createDocument() // + .withLongTextContent() // + .pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph('1', 0); + + final paragraphImeText = '. ${SuperEditorInspector.findTextInComponent('1').toPlainText()}'; + + // Simulate the user dragging the spacebar to move the caret to + // "In aliquet convallis efficitur|.". This position was chosen arbitrarily, we + // just need a position that is outside of the viewport. + const destinationOffset = 226; + int currentOffset = 0; + while (currentOffset < destinationOffset) { + await tester.ime.sendDeltas( + [ + TextEditingDeltaNonTextUpdate( + oldText: paragraphImeText, + selection: TextSelection.collapsed(offset: currentOffset), + composing: TextRange.empty, + ), + ], + getter: imeClientGetter, + ); + + await tester.pump(); + currentOffset += 1; + } + + // Ensure that the selection is visible. + expect( + SuperEditorInspector.isPositionVisibleGlobally( + const DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: destinationOffset), + ), + windowSize, + ), + isTrue, + ); + }); + + testWidgetsOnAllPlatforms("doesn't jump the content when typing at the first line", (tester) async { + final scrollController = ScrollController(); + + // We use a custom stylesheet to avoid any padding, ensuring that the text + // will be close to the edge. + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .withInputSource(TextInputSource.keyboard) + .useStylesheet( + Stylesheet( + inlineTextStyler: (Set attributions, TextStyle base) { + return base; + }, + rules: [ + StyleRule(BlockSelector.all, (document, node) { + return { + Styles.textStyle: const TextStyle( + color: Colors.black, + ), + }; + }), + ], + ), + ) + .pump(); + + // Ensure the editor starts without any scrolling. + expect(scrollController.position.pixels, 0); + + // Place caret at the beginning of the document. + await tester.placeCaretInParagraph('1', 0); + + // Simulate the user typing. + await tester.typeKeyboardText("A"); + + // Ensure typing doesn't cause the content to jump. + expect(scrollController.position.pixels, 0); + }); + + testWidgetsOnAllPlatforms("doesn't jump the content when typing at the last line", (tester) async { + final scrollController = ScrollController(); + + // Pump an editor with a size that will know will cause it to be scrollable. + // We use a custom stylesheet to avoid any padding, ensuring that the text + // will be close to the edge. + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .withInputSource(TextInputSource.keyboard) + .withEditorSize(const Size(600, 100)) + .useStylesheet( + Stylesheet( + inlineTextStyler: (Set attributions, TextStyle base) { + return base; + }, + rules: [ + StyleRule(BlockSelector.all, (document, node) { + return { + Styles.textStyle: const TextStyle( + color: Colors.black, + ), + }; + }), + ], + ), + ) + .pump(); + + // Ensure the editor starts without any scrolling. + expect(scrollController.position.pixels, 0); + + // Ensure the editor is scrollable. + expect(scrollController.position.maxScrollExtent, greaterThan(0)); + + // On mobile, changing the selection isn't causing the editor + // to reveal the selection, so we manually jump to the end of the scrollable + // and then change the selection. + scrollController.position.jumpTo(scrollController.position.maxScrollExtent); + // Place caret at last line of the editor. + await tester.placeCaretInParagraph('1', 444); + + // Simulate the user typing. + await tester.typeKeyboardText("A"); + + // Ensure typing doesn't cause the content to jump. + expect(scrollController.position.pixels, scrollController.position.maxScrollExtent); + }); + + testWidgetsOnDesktop("doesn't auto-scroll for selection changes that aren't user interactions", (tester) async { + final scrollController = ScrollController(); + + // Pump a editor with a size we know will cause the editor to be scrollable. + final docContext = await tester // + .createDocument() + .withLongTextContent() + .withEditorSize(const Size(300, 100)) + .withScrollController(scrollController) + .pump(); + + // Select the first paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Place the caret at the last paragraph, simulating an event that wasn't initiated by the user. + // This paragraph is outside the viewport. + docContext.findEditContext().editor.execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '4', + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.contentChange, + ), + ]); + await tester.pumpAndSettle(); + + // Ensure the editor didn't scroll. + expect(scrollController.position.pixels, 0.0); + }); + + testWidgetsOnAllPlatforms("doesn't auto-scroll for key presses that don't insert any content", (tester) async { + final scrollController = ScrollController(); + + // Pump an editor with a size we know will cause the editor to be scrollable. + final docContext = await tester // + .createDocument() + .withLongTextContent() + .withEditorSize(const Size(300, 100)) + .withScrollController(scrollController) + .pump(); + + // Select the first paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Place the caret at the last paragraph, simulating an event that was initiated by the user. + // We pretend it was initiated by the user because that's what causes an auto-scroll. + // But the auto-scroll should be smart enough to see that the selection hasn't changed + // and therefore it shouldn't auto-scroll. + docContext.findEditContext().editor.execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + await tester.pumpAndSettle(); + + // Ensure the editor didn't scroll. + expect(scrollController.position.pixels, 0.0); + + // Press non-content keys. + await tester.sendKeyEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.shift); + await tester.pump(); + + // We don't expect anything to happen, but in case something unexpected happens, + // give the editor whatever time it needs to run the unexpected behavior. + await tester.pumpAndSettle(); + + // Ensure the editor didn't scroll. + expect(scrollController.position.pixels, 0.0); + }); + + testWidgetsOnArbitraryDesktop("doesn't scroll when dragging over an image", (tester) async { + const editorSize = Size(300, 300); + + await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("First Paragraph"), + ), + ParagraphNode( + id: "2", + text: AttributedText("Second Paragraph"), + ), + ImageNode( + id: "img-node", + imageUrl: 'https://this.is.a.fake.image', + metadata: const SingleColumnLayoutComponentStyles( + width: double.infinity, + ).toMetadata(), + ), + ], + ), + ) + .withAddedComponents([const FakeImageComponentBuilder(size: editorSize)]) + .withEditorSize(editorSize) + .pump(); + + // Drag from the second paragraph to the image. + await tester.dragSelectDocumentFromPositionByOffset( + from: const DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 1), + ), + delta: const Offset(0, 50), + ); + + // Ensure the bottom of the image isn't visible. + expect( + SuperEditorInspector.isPositionVisibleGlobally( + const DocumentPosition( + nodeId: 'img-node', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + editorSize, + ), + false, + ); + }); + + testWidgetsOnMobile("stops momentum on tap down and doesn't place the caret", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() // + .withLongDoc() // + .withScrollController(scrollController) // + .pump(); + + // Ensure the editor initially has no selection. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Fling scroll the editor. + await tester.fling(find.byType(SuperEditor), const Offset(0.0, -1000), 1000); + + // Pump a few frames of momentum. + for (int i = 0; i < 25; i += 1) { + await tester.pump(const Duration(milliseconds: 16)); + } + final scrollOffsetInMiddleOfMomentum = scrollController.offset; + + // Tap down to stop the momentum. + final gesture = await tester.startGesture(tester.getCenter(find.byType(SuperEditor))); + + // Let any remaining momentum run (there shouldn't be any). + await tester.pumpAndSettle(); + + // Ensure that the momentum stopped exactly where we tapped. + expect(scrollOffsetInMiddleOfMomentum, scrollController.offset); + + // Release the pointer. + await gesture.up(); + await tester.pump(); + + // Ensure that tapping on the editor didn't place the caret. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + + testWidgetsOnDesktop("stops momentum on tap down with trackpad and doesn't place the caret", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() // + .withLongDoc() // + .withScrollController(scrollController) // + .pump(); + + // Ensure the editor initially has no selection. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Fling scroll the editor with the trackpad. + final scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(SuperEditor)), + kind: PointerDeviceKind.trackpad, + ); + await scrollGesture.moveBy(const Offset(0, -1000)); + await scrollGesture.up(); + + // Pump a few frames of momentum. + for (int i = 0; i < 25; i += 1) { + await tester.pump(const Duration(milliseconds: 16)); + } + final scrollOffsetInMiddleOfMomentum = scrollController.offset; + + // Ensure the editor scrolled. + expect(scrollOffsetInMiddleOfMomentum, greaterThan(0.0)); + + // Tap down to stop the momentum. + final gesture = await tester.startGesture( + tester.getCenter(find.byType(SuperEditor)), + kind: PointerDeviceKind.trackpad, + ); + + // Let any remaining momentum run (there shouldn't be any). + await tester.pumpAndSettle(); + + // Ensure that the momentum stopped exactly where we tapped. + expect(scrollController.offset, scrollOffsetInMiddleOfMomentum); + + // Release the pointer. + await gesture.up(); + await tester.pump(); + + // Ensure that tapping on the editor didn't change the selection. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + + testWidgetsOnArbitraryDesktop("does not stop momentum on mouse move", (tester) async { + final scrollController = ScrollController(); + + // Pump an editor with a small size to make it scrollable. + await tester // + .createDocument() // + .withLongDoc() // + .withScrollController(scrollController) // + .withEditorSize(const Size(300, 300)) + .pump(); + + // Fling scroll with the trackpad to generate momentum. + await tester.trackpadFling( + find.byType(SuperEditor), + const Offset(0.0, -300), + 300.0, + ); + + final scrollOffsetInMiddleOfMomentum = scrollController.offset; + + // Move the mouse around. + final gesture = await tester.createGesture(); + await gesture.moveTo(tester.getTopLeft(find.byType(SuperEditor))); + + // Let any momentum run. + await tester.pumpAndSettle(); + + // Ensure that the momentum didn't stop due to mouse movement. + expect(scrollOffsetInMiddleOfMomentum, lessThan(scrollController.offset)); + }); + + testWidgetsOnAndroid("doesn't overscroll when dragging down", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .pump(); + + // Ensure the editor didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount of pixels from the top of the editor with a small margin. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperEditor)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 200.0), + ); + + // Ensure the drag gesture didn't scroll the editor. + expect(scrollController.offset, 0); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnAndroid("doesn't overscroll when dragging up", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + + // Drag an arbitrary amount of pixels from the bottom of the editor. + // The gesture starts with an arbitrary small margin from the bottom. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperEditor)).bottomCenter - const Offset(0, 10), + totalDragOffset: const Offset(0, -200.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnIos('overscrolls when dragging down', (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withLongDoc() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount of pixels a few pixels below the top of the editor. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperEditor)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 80.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, lessThan(0.0)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the top. + expect(scrollController.offset, 0.0); + }); + + testWidgetsOnIos('overscrolls when dragging up', (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withLongDoc() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pumpAndSettle(); + + // Drag an arbitrary amount of pixels from the bottom of the editor. + // The gesture starts with an arbitrary margin from the bottom. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperEditor)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -200.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, greaterThan(scrollController.position.maxScrollExtent)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the end. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + }); + + group('scrolls when dragging at empty space', () { + testWidgetsOnMobile("with collapsed selection", (tester) async { + final scrollController = ScrollController(); + + // Pump an editor with horizontal padding, so we can drag from an offset where there is no text. + await tester // + .createDocument() + .withLongDoc() + .withEditorSize(const Size(300, 300)) + .withScrollController(scrollController) + .useStylesheet( + defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(horizontal: 100), + ), + ) + .pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph('1', 0); + + final scrollOffsetBeforeDrag = scrollController.offset; + + // Drag from approximately the bottom of the editor until the top. + await tester.dragFrom( + tester.getBottomLeft(find.byType(SuperEditor)) + const Offset(10, -10), + const Offset(0, -300), + ); + await tester.pump(); + + // Ensure the editor scrolled up and the selection didn't change. + expect(scrollController.offset, greaterThan(scrollOffsetBeforeDrag)); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + )), + ); + + // Let the long-press timer resolve. + await tester.pump(kLongPressTimeout); + }); + + testWidgetsOnMobile("with expanded selection", (tester) async { + final scrollController = ScrollController(); + + // Pump an editor with horizontal padding, so we can drag from an offset where there is no text. + await tester // + .createDocument() + .withLongDoc() + .withEditorSize(const Size(300, 300)) + .withScrollController(scrollController) + .useStylesheet( + defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(horizontal: 100), + ), + ) + .pump(); + + // Double tap the word "Lorem". + await tester.doubleTapInParagraph('1', 1); + + final scrollOffsetBeforeDrag = scrollController.offset; + + // Drag from approximately the bottom of the editor until the top. + await tester.dragFrom( + tester.getBottomLeft(find.byType(SuperEditor)) + const Offset(10, -10), + const Offset(0, -300), + ); + await tester.pump(); + + // Ensure the editor scrolled up and the selection didn't change. + expect(scrollController.offset, greaterThan(scrollOffsetBeforeDrag)); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 5), + ), + )), + ); + + // Let the long-press timer resolve. + await tester.pump(kLongPressTimeout); + }); + + testWidgetsOnMobile("with no selection", (tester) async { + final scrollController = ScrollController(); + + // Pump an editor with horizontal padding, so we can drag from an offset where there is no text. + await tester // + .createDocument() + .withLongDoc() + .withEditorSize(const Size(300, 300)) + .withScrollController(scrollController) + .useStylesheet( + defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(horizontal: 100), + ), + ) + .pump(); + + final scrollOffsetBeforeDrag = scrollController.offset; + + // Drag from approximately the bottom of the editor until the top. + await tester.dragFrom( + tester.getBottomLeft(find.byType(SuperEditor)) + const Offset(10, -10), + const Offset(0, -300), + ); + await tester.pump(); + + // Ensure the editor scrolled up and the selection didn't change. + expect(scrollController.offset, greaterThan(scrollOffsetBeforeDrag)); + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Let the long-press timer resolve. + await tester.pump(kLongPressTimeout); + }); + }); + + group("within an ancestor Scrollable", () { + const screenSizeWithoutKeyboard = Size(390.0, 844.0); + const screenSizeWithKeyboard = Size(390.0, 544.0); + const keyboardExpansionFrameCount = 60; + final shrinkPerFrame = + (screenSizeWithoutKeyboard.height - screenSizeWithKeyboard.height) / keyboardExpansionFrameCount; + + testWidgetsOnAndroid('on Android, keeps caret visible when keyboard appears', (WidgetTester tester) async { + tester.view + ..physicalSize = screenSizeWithoutKeyboard + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..devicePixelRatio = 1.0; + + await tester.pumpWidget( + const _SliverTestEditor( + gestureMode: DocumentGestureMode.android, + ), + ); + + // Select text near the bottom of the screen, where the keyboard will appear + final tapPosition = Offset(screenSizeWithoutKeyboard.width / 2, screenSizeWithoutKeyboard.height - 1); + await tester.tapAt(tapPosition); + await tester.pump(); + + // TODO: add caret finder to inspector + final caretFinder = find.byKey(DocumentKeys.caret); + expect(caretFinder, findsOneWidget); + + // Shrink the screen height, as if the keyboard appeared. + await _simulateKeyboardAppearance( + tester: tester, + initialScreenSize: screenSizeWithoutKeyboard, + shrinkPerFrame: shrinkPerFrame, + frameCount: keyboardExpansionFrameCount, + ); + + // Ensure that the editor auto-scrolled to keep the caret visible. + expect(caretFinder, findsOneWidget); + final caretOffset = tester.getBottomLeft(caretFinder); + + // The default trailing boundary of the default `SuperEditor` + const trailingBoundary = 54.0; + + // The caret should be at the trailing boundary, within a small margin of error + expect(caretOffset.dy, lessThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary)); + expect(caretOffset.dy, greaterThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary)); + }); + + testWidgetsOnIos('on iOS, keeps caret visible when keyboard appears', (WidgetTester tester) async { + tester.view + ..physicalSize = screenSizeWithoutKeyboard + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..devicePixelRatio = 1.0; + + await tester.pumpWidget( + const _SliverTestEditor( + gestureMode: DocumentGestureMode.iOS, + ), + ); + + // Select text near the bottom of the screen, where the keyboard will appear + final tapPosition = Offset(screenSizeWithoutKeyboard.width / 2, screenSizeWithoutKeyboard.height - 1); + await tester.tapAt(tapPosition); + await tester.pump(); + + // Shrink the screen height, as if the keyboard appeared. + await _simulateKeyboardAppearance( + tester: tester, + initialScreenSize: screenSizeWithoutKeyboard, + shrinkPerFrame: shrinkPerFrame, + frameCount: keyboardExpansionFrameCount, + ); + + // Ensure that the editor auto-scrolled to keep the caret visible. + final caretFinder = find.byType(BlinkingCaret); + final caretOffset = tester.getBottomLeft(caretFinder); + + // The default trailing boundary of the default `SuperEditor` + const trailingBoundary = 54.0; + + // The caret should be at the trailing boundary, within a small margin of error + expect(caretOffset.dy, lessThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary + 2)); + expect(caretOffset.dy, greaterThanOrEqualTo(screenSizeWithKeyboard.height - trailingBoundary - 2)); + }); + + testWidgetsOnMobile('scrolling and holding the pointer doesn\'t cause the keyboard to open', (tester) async { + final scrollController = ScrollController(); + + // Pump an editor inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() // + .withLongTextContent() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + final scrollableRect = tester.getRect(find.byType(CustomScrollView)); + + const dragFrameCount = 10; + final dragAmountPerFrame = scrollableRect.height / dragFrameCount; + + // Drag from the bottom all the way up to the top of the scrollable. + final dragGesture = await tester.startGesture(scrollableRect.bottomCenter - const Offset(0, 1)); + for (int i = 0; i < dragFrameCount; i += 1) { + await dragGesture.moveBy(Offset(0, -dragAmountPerFrame)); + await tester.pump(); + } + + // The editor supports long press to select. + // Wait long enough to make sure this gesture wasn't confused with a long press. + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + + // Ensure we scrolled, didn't changed the selection and didn't attach to the IME. + expect(scrollController.offset, greaterThan(0)); + expect(SuperEditorInspector.findDocumentSelection(), isNull); + expect(tester.testTextInput.hasAnyClients, isFalse); + + // Release the pointer. + await dragGesture.up(); + await dragGesture.removePointer(); + }); + + testWidgetsOnMobile('scrolling and releasing the pointer doesn\'t cause the keyboard to open', (tester) async { + final scrollController = ScrollController(); + + // Pump an editor inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() // + .withLongTextContent() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + final scrollableRect = tester.getRect(find.byType(CustomScrollView)); + + const dragFrameCount = 10; + final dragAmountPerFrame = scrollableRect.height / dragFrameCount; + + // Drag from the bottom all the way up to the top of the scrollable. + final dragGesture = await tester.startGesture(scrollableRect.bottomCenter - const Offset(0, 1)); + for (int i = 0; i < dragFrameCount; i += 1) { + await dragGesture.moveBy(Offset(0, -dragAmountPerFrame)); + await tester.pump(); + } + + // Stop the scrolling gesture. + await dragGesture.up(); + await dragGesture.removePointer(); + await tester.pump(); + + // The editor supports long press to select. + // Wait long enough to make sure this gesture wasn't confused with a long press. + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + + // Ensure we scrolled, didn't changed the selection and didn't attach to the IME. + expect(scrollController.offset, greaterThan(0)); + expect(SuperEditorInspector.findDocumentSelection(), isNull); + expect(tester.testTextInput.hasAnyClients, isFalse); + }); + + testWidgetsOnAndroid("doesn't overscroll when dragging down", (tester) async { + final scrollController = ScrollController(); + + await tester + .createDocument() // + .withSingleParagraph() + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount of pixels from the top of the editor. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 400.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, 0); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnAndroid("doesn't overscroll when dragging up", (tester) async { + final scrollController = ScrollController(); + + // Pump an editor inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() + .withSingleParagraph() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + + // Drag an arbitrary amount of pixels from the bottom of the editor. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).bottomCenter - const Offset(0, 10), + totalDragOffset: const Offset(0, -400.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnIos('overscrolls when dragging down', (tester) async { + final scrollController = ScrollController(); + + // Pump an editor inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() // + .withLongTextContent() + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount, smaller than the editor size. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 80.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, lessThan(0.0)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the top. + expect(scrollController.offset, 0.0); + }); + + testWidgetsOnIos('overscrolls when dragging up', (tester) async { + final scrollController = ScrollController(); + + // Pump an editor inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() // + .withLongTextContent() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pumpAndSettle(); + + // Drag up an arbitrary amount, smaller than the editor size. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -100.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, greaterThan(scrollController.position.maxScrollExtent)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the end. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + }); + + group('respects horizontal scrolling', () { + testWidgetsOnAllPlatforms('inside a TabBar', (tester) async { + final tabController = TabController(length: 2, vsync: tester); + final scrollController = ScrollController(); + + // Pump a SuperEditor with a small maxHeight, so adding lines + // will cause the editor to scroll. + await tester + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .withScrollController(scrollController) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 300, + maxHeight: 100, + ), + child: Scaffold( + appBar: AppBar( + bottom: TabBar( + controller: tabController, + tabs: const [ + Tab(text: 'Tab 1'), + Tab(text: 'Tab 2'), + ], + ), + ), + body: TabBarView( + controller: tabController, + children: [ + superEditor, + const SizedBox(), + ], + ), + ), + ), + ), + ) + .pump(); + + // Select the editor. + await tester.placeCaretInParagraph('1', 0); + + // Add new lines so the content will cause editor to scroll + await _addNewLines(tester, count: 40); + await tester.pumpAndSettle(); + + // Ensure SuperEditor has scrolled + expect(scrollController.offset, greaterThan(0)); + + // Ensure that scrolling didn't cause a tab change + expect(tabController.index, equals(0)); + }); + + testWidgetsOnAllPlatforms('inside a horizontal ListView', (tester) async { + final listScrollController = ScrollController(); + final editorScrollController = ScrollController(); + + // Pump a SuperEditor with a small maxHeight, so adding lines + // will cause the editor to scroll. + await tester + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .withScrollController(editorScrollController) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 300, + maxHeight: 100, + maxWidth: 300, + ), + child: ListView( + scrollDirection: Axis.horizontal, + controller: listScrollController, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: superEditor, + ), + ...List.generate(20, (index) => Text('Text $index')), + ], + ), + ), + ), + ), + ) + .pump(); + + // Select the editor. + await tester.placeCaretInParagraph('1', 0); + + // Add new lines so the content will cause editor to scroll + await _addNewLines(tester, count: 40); + await tester.pumpAndSettle(); + + // Ensure SuperEditor has scrolled + expect(editorScrollController.offset, greaterThan(0)); + }); + }); + + group("when all content fits in the viewport", () { + testWidgetsOnDesktop( + "trackpad doesn't scroll content", + (tester) async { + tester.view.physicalSize = const Size(800, 600); + + final isScrollingUp = _scrollDirectionVariant.currentValue == _ScrollDirection.up; + + await tester // + .createDocument() + .withCustomContent( + paragraphThenHrThenParagraphDoc() + ..insertNodeAt( + 0, + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Document #1'), + metadata: { + 'blockType': header1Attribution, + }, + ), + ), + ) + .pump(); + + final scrollState = tester.state(find.byType(Scrollable)); + + // Perform a fling on the editor to attemp scrolling. + await tester.trackpadFling( + find.byType(SuperEditor), + Offset(0.0, isScrollingUp ? 100 : -100), + 300, + ); + + await tester.pump(); + + // Ensure SuperEditor is not scrolling. + expect(scrollState.position.activity?.isScrolling, false); + }, + variant: _scrollDirectionVariant, + ); + + testWidgetsOnDesktop( + "mouse scroll wheel doesn't scroll content", + (tester) async { + tester.view.physicalSize = const Size(800, 600); + + final isScrollUp = _scrollDirectionVariant.currentValue == _ScrollDirection.up; + + await tester // + .createDocument() + .withCustomContent( + paragraphThenHrThenParagraphDoc() + ..insertNodeAt( + 0, + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Document #1'), + metadata: { + 'blockType': header1Attribution, + }, + ), + ), + ) + .pump(); + + final scrollState = tester.state(find.byType(Scrollable)); + + final Offset scrollEventLocation = tester.getCenter(find.byType(SuperEditor)); + final TestPointer testPointer = TestPointer(1, PointerDeviceKind.mouse); + + // Send initial pointer event to set the location for subsequent pointer scroll events. + await tester.sendEventToBinding(testPointer.hover(scrollEventLocation)); + + // Send pointer scroll event to start scrolling. + await tester.sendEventToBinding( + testPointer.scroll( + Offset( + 0.0, + isScrollUp ? 100 : -100.0, + ), + ), + ); + + await tester.pump(); + + // Ensure SuperReader is not scrolling. + expect(scrollState.position.activity!.isScrolling, false); + }, + variant: _scrollDirectionVariant, + ); + }); + }); + + testWidgetsOnDesktop('shows scrollbar by default', (tester) async { + final scrollController = ScrollController(); + await tester // + .createDocument() + .withSingleParagraph() + .withEditorSize(const Size(300, 300)) + .withScrollController(scrollController) + .pump(); + + // Ensure the editor is scrollable. + expect(scrollController.position.maxScrollExtent, greaterThan(0.0)); + + // Ensure the scrollbar is displayed. + expect( + find.descendant( + of: find.byType(SuperEditor), + matching: find.byType(ScrollbarWithCustomPhysics), + ), + findsOneWidget, + ); + }); + + testWidgetsOnDesktop('does not show scrollbar when ancestor ScrollConfiguration does not want one', (tester) async { + final scrollController = ScrollController(); + await tester // + .createDocument() + .withSingleParagraph() + .withEditorSize(const Size(300, 300)) + .withScrollController(scrollController) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: ScrollConfiguration( + behavior: const ScrollBehavior().copyWith(scrollbars: false), + child: superEditor, + ), + ), + ), + ) + .pump(); + + // Ensure the editor is scrollable. + expect(scrollController.position.maxScrollExtent, greaterThan(0.0)); + + // Ensure no scrollbar is displayed. + expect( + find.descendant( + of: find.byType(SuperEditor), + matching: find.byType(ScrollbarWithCustomPhysics), + ), + findsNothing, + ); + }); + + testWidgetsOnMobile('spurious metrics change is ignored', (tester) async { + final scrollController = ScrollController(); + await tester // + .createDocument() + .withLongDoc() + .withEditorSize(const Size(300, 300)) + .withScrollController(scrollController) + .pump(); + await tester.tapInParagraph('1', 0); + final gesture = await tester.startGesture(const Offset(100, 100), kind: PointerDeviceKind.touch); + await gesture.moveBy(const Offset(0, -100)); + await tester.pumpAndSettle(); + final pixels = scrollController.position.pixels; + // This should not change scroll position. + WidgetsBinding.instance.handleMetricsChanged(); + await Future.microtask(() {}); + await tester.pump(); + expect(scrollController.position.pixels, pixels); + }); }); } + +/// Displays a [SuperEditor] within a parent [Scrollable], including additional +/// content above the [SuperEditor] and additional content on top of [Scrollable]. +/// +/// By including content above the [SuperEditor], it doesn't have the same origin as the parent [Scrollable]. +/// +/// By including content on top of [Scrollable], it doesn't have the origin at [Offset.zero]. +class _SliverTestEditor extends StatefulWidget { + const _SliverTestEditor({ + Key? key, + required this.gestureMode, + }) : super(key: key); + + final DocumentGestureMode gestureMode; + + @override + State<_SliverTestEditor> createState() => _SliverTestEditorState(); +} + +class _SliverTestEditorState extends State<_SliverTestEditor> { + late MutableDocument _doc; + late MutableDocumentComposer _composer; + late Editor _docEditor; + + @override + void initState() { + super.initState(); + + _doc = _createExampleDocumentForScrolling(); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 300), + child: CustomScrollView( + slivers: [ + SliverAppBar( + title: const Text( + 'Rich Text Editor Sliver Example', + ), + expandedHeight: 200.0, + leading: const SizedBox(), + flexibleSpace: FlexibleSpaceBar( + background: Container(color: Colors.blue), + ), + ), + const SliverToBoxAdapter( + child: Text( + 'Lorem Ipsum Dolor', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ), + SuperEditor( + editor: _docEditor, + stylesheet: defaultStylesheet.copyWith( + documentPadding: const EdgeInsets.symmetric(vertical: 56, horizontal: 24), + ), + gestureMode: widget.gestureMode, + inputSource: TextInputSource.ime, + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return ListTile(title: Text('$index')); + }, + ), + ), + ], + ), + ), + ), + debugShowCheckedModeBanner: false, + ); + } +} + +/// Slowly reduces window size to imitate the appearance of a keyboard. +Future _simulateKeyboardAppearance({ + required WidgetTester tester, + required Size initialScreenSize, + required double shrinkPerFrame, + required int frameCount, +}) async { + // Shrink the height of the screen, one frame at a time. + double keyboardHeight = 0.0; + for (var i = 0; i < frameCount; i++) { + // Shrink the height of the screen by a small amount. + keyboardHeight += shrinkPerFrame; + final currentScreenSize = (initialScreenSize - Offset(0, keyboardHeight)) as Size; + tester.view.physicalSize = currentScreenSize; + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + } +} + +/// Adds [count] new lines using IME actions +Future _addNewLines( + WidgetTester tester, { + required int count, +}) async { + for (int i = 0; i < count; i++) { + await tester.testTextInput.receiveAction(TextInputAction.newline); + await tester.pump(); + } +} + +MutableDocument _createExampleDocumentForScrolling() { + return MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'Example Document', + ), + metadata: { + 'blockType': header1Attribution, + }, + ), + HorizontalRuleNode(id: Editor.createNodeId()), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ], + ); +} + +final _scrollDirectionVariant = ValueVariant<_ScrollDirection>({ + _ScrollDirection.up, + _ScrollDirection.down, +}); + +enum _ScrollDirection { + up, + down; +} diff --git a/super_editor/test/super_editor/supereditor_selection_test.dart b/super_editor/test/super_editor/supereditor_selection_test.dart index da417869ec..7f476c1a83 100644 --- a/super_editor/test/super_editor/supereditor_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_selection_test.dart @@ -1,16 +1,111 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; -import 'package:super_editor/src/infrastructure/blinking_caret.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_text_field_test.dart'; import '../test_tools.dart'; -import 'document_test_tools.dart'; void main() { group("SuperEditor selection", () { + group("styles", () { + testWidgetsOnAllPlatforms("changes color of selected text", (tester) async { + final stylesheet = defaultStylesheet.copyWith( + selectedTextColorStrategy: ({required Color originalTextColor, required Color selectionHighlightColor}) { + return Colors.white; + }, + ); + + await tester // + .createDocument() + .withSingleParagraph() + .useStylesheet(stylesheet) + .pump(); + + // Select the 2nd word in the paragraph. + await tester.doubleTapInParagraph("1", 7); + + // Ensure that the first word is black and the second (selected) word is white. + final richText = SuperEditorInspector.findRichTextInParagraph("1"); + + expect(richText.getSpanForPosition(const TextPosition(offset: 0))!.style!.color, Colors.black); + expect(richText.getSpanForPosition(const TextPosition(offset: 5))!.style!.color, Colors.black); + + expect(richText.getSpanForPosition(const TextPosition(offset: 6))!.style!.color, Colors.white); + expect(richText.getSpanForPosition(const TextPosition(offset: 10))!.style!.color, Colors.white); + }); + + testWidgetsOnAllPlatforms("overrides existing color attributions", (tester) async { + final stylesheet = defaultStylesheet.copyWith( + selectedTextColorStrategy: ({required Color originalTextColor, required Color selectionHighlightColor}) { + return Colors.white; + }, + ); + + // Pump an editor with green text throught the document. + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'Lorem ipsum dolor', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: ColorAttribution(Colors.green), offset: 0, markerType: SpanMarkerType.start), + const SpanMarker( + attribution: ColorAttribution(Colors.green), offset: 16, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ], + ), + ) + .useStylesheet(stylesheet) + .pump(); + + // Double tap to select the word "Lorem". + await tester.doubleTapInParagraph('1', 2); + + // Ensure that the first word is white and the rest is green. + final richText = SuperEditorInspector.findRichTextInParagraph('1'); + + expect(richText.getSpanForPosition(const TextPosition(offset: 0))!.style!.color, Colors.white); + expect(richText.getSpanForPosition(const TextPosition(offset: 4))!.style!.color, Colors.white); + + expect(richText.getSpanForPosition(const TextPosition(offset: 5))!.style!.color, Colors.green); + expect(richText.getSpanForPosition(const TextPosition(offset: 16))!.style!.color, Colors.green); + }); + + testWidgetsOnAllPlatforms("does not crash when triple tapping on an empty paragraph", (tester) async { + // This test is to ensure that the selection color strategy doesn't crash when the paragraph is empty. + // See https://github.com/superlistapp/super_editor/issues/2253 for more context. + final stylesheet = defaultStylesheet.copyWith( + selectedTextColorStrategy: ({required Color originalTextColor, required Color selectionHighlightColor}) { + return Colors.white; + }, + ); + + await tester // + .createDocument() + .withSingleEmptyParagraph() + .useStylesheet(stylesheet) + .pump(); + + // Tripple tap on the empty paragraph. + await tester.tripleTapInParagraph("1", 0); + + // Reaching this point means the editor didn't crash. + }); + }); + testWidgetsOnArbitraryDesktop("calculates upstream document selection within a single node", (tester) async { await tester // .createDocument() // @@ -28,7 +123,7 @@ void main() { // directions: right-to-left is upstream for a single line, and up-to-down is // downstream for multi-node. This test ensures that the single-line direction is // honored by the document layout, rather than the more common multi-node calculation. - final selection = layout.getDocumentSelectionInRegion(const Offset(200, 35), const Offset(150, 45)); + final selection = layout.getDocumentSelectionInRegion(const Offset(1100, 35), const Offset(1050, 45)); expect(selection, isNotNull); // Ensure that the document selection is upstream. @@ -54,7 +149,7 @@ void main() { // directions: left-to-right is downstream for a single line, and down-to-up is // upstream for multi-node. This test ensures that the single-line direction is // honored by the document layout, rather than the more common multi-node calculation. - final selection = layout.getDocumentSelectionInRegion(const Offset(150, 45), const Offset(200, 35)); + final selection = layout.getDocumentSelectionInRegion(const Offset(1050, 45), const Offset(1100, 35)); expect(selection, isNotNull); // Ensure that the document selection is downstream. @@ -68,7 +163,7 @@ void main() { .createDocument() // .fromMarkdown("This is paragraph one.\nThis is paragraph two.") // .pump(); - final nodeId = testContext.editContext.editor.document.nodes.first.id; + final nodeId = testContext.findEditContext().document.first.id; /// Triple tap on the first line in the paragraph node. await tester.tripleTapInParagraph(nodeId, 10); @@ -99,7 +194,7 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final firstParagraphId = testContext.editContext.editor.document.nodes.first.id; + final firstParagraphId = testContext.findEditContext().document.first.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -126,7 +221,7 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final secondParagraphId = testContext.editContext.editor.document.nodes.last.id; + final secondParagraphId = testContext.findEditContext().document.last.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -158,7 +253,7 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final secondParagraphId = testContext.editContext.editor.document.nodes.last.id; + final secondParagraphId = testContext.findEditContext().document.last.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -190,7 +285,7 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final firstParagraphId = testContext.editContext.editor.document.nodes.first.id; + final firstParagraphId = testContext.findEditContext().document.first.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -217,8 +312,8 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final firstParagraphId = testContext.editContext.editor.document.nodes.first.id; - final secondParagraphId = testContext.editContext.editor.document.nodes.last.id; + final firstParagraphId = testContext.findEditContext().document.first.id; + final secondParagraphId = testContext.findEditContext().document.last.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -248,8 +343,8 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final firstParagraphId = testContext.editContext.editor.document.nodes.first.id; - final secondParagraphId = testContext.editContext.editor.document.nodes.last.id; + final firstParagraphId = testContext.findEditContext().document.first.id; + final secondParagraphId = testContext.findEditContext().document.last.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -277,6 +372,85 @@ void main() { ); }); + testWidgetsOnArbitraryDesktop("keeps selection base while dragging a selection across components that change size", + (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode( + id: '1', + text: AttributedText('Task 1'), + isComplete: false, + ), + TaskNode( + id: '2', + text: AttributedText('Task 2'), + isComplete: false, + ), + TaskNode( + id: '3', + text: AttributedText('Task 3'), + isComplete: false, + ), + ], + ); + + await tester // + .createDocument() + .withCustomContent(document) + .withAddedComponents([ExpandingTaskComponentBuilder()]) // + .pump(); + + // Place the caret at "Tas|k 3" to make it expand. + await tester.placeCaretInParagraph('3', 3); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '3', + nodePosition: TextNodePosition(offset: 3), + ), + ), + ), + ); + + // Start dragging from "Tas|k 3" to the beginning of the document. + final gesture = await tester.startDocumentDragFromPosition( + from: const DocumentPosition( + nodeId: '3', + nodePosition: TextNodePosition(offset: 3), + ), + ); + addTearDown(() => gesture.removePointer()); + + // Gradually move up until the beginning of the document. + for (int i = 0; i <= 10; i++) { + await gesture.moveBy(const Offset(0, -30)); + await tester.pump(); + } + + // Ensure the selection expanded to the beginning of the document + // and the selection base was retained. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition( + nodeId: '3', + nodePosition: TextNodePosition(offset: 3), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + + // Pump with enough time to expire the tap recognizer timer. + await tester.pump(kTapTimeout); + }); + testWidgetsOnAllPlatforms("removes caret when it loses focus", (tester) async { await tester .createDocument() @@ -341,7 +515,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: doc!.nodes.last.id, + nodeId: doc!.last.id, nodePosition: const TextNodePosition(offset: 477), ), ), @@ -355,7 +529,7 @@ void main() { await tester .createDocument() .withLongTextContent() - .withInputSource(DocumentInputSource.ime) + .withInputSource(TextInputSource.ime) .withAddedComponents([const _UnselectableHrComponentBuilder()]) .withCustomWidgetTreeBuilder( (superEditor) => MaterialApp( @@ -385,7 +559,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: doc!.nodes.last.id, + nodeId: doc!.last.id, nodePosition: const TextNodePosition(offset: 477), ), ), @@ -419,7 +593,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: doc!.nodes.last.id, + nodeId: doc!.last.id, nodePosition: const TextNodePosition(offset: 477), ), ), @@ -445,7 +619,7 @@ void main() { SuperEditorInspector.findDocumentSelection(), DocumentSelection.collapsed( position: DocumentPosition( - nodeId: doc!.nodes.last.id, + nodeId: doc!.last.id, nodePosition: const TextNodePosition(offset: 477), ), ), @@ -488,7 +662,7 @@ Second Paragraph await tester.pumpAndSettle(); final doc = SuperEditorInspector.findDocument(); - final secondParagraphNodeId = doc!.nodes[1].id; + final secondParagraphNodeId = doc!.getNodeAt(1)!.id; // Ensure selection is at the last character of the second paragraph. expect( @@ -505,11 +679,210 @@ Second Paragraph expect(_caretFinder(), findsOneWidget); }); + testWidgetsOnDesktop("by default keeps IME connection open when it loses primary focus", (tester) async { + // Note: we don't include mobile in this test because mobile text fields always use IME + // which will steal the IME connection away from SuperEditor and interfere with the expected + // result. + + final textFieldFocus = FocusNode(); + final subtreeFocus = FocusNode(); + final editorFocus = FocusNode(); + await tester + .createDocument() + .withSingleParagraph() + .withInputSource(TextInputSource.ime) + .withFocusNode(editorFocus) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + Focus( + focusNode: subtreeFocus, + parentNode: editorFocus, + child: SuperTextField( + focusNode: textFieldFocus, + // We put the SuperTextField in keyboard mode so that the SuperTextField + // doesn't steal the IME connection. This way, we ensure that SuperEditor, + // left to its own devices, will proactively close the IME connection when + // it loses primary focus. + inputSource: TextInputSource.keyboard, + ), + ), + Expanded( + child: superEditor, + ), + ], + ), + ), + ), + ) + .autoFocus(true) + .pump(); + + // Ensure that SuperEditor begins with focus, a selection, and IME connection + expect(editorFocus.hasPrimaryFocus, isTrue); + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.isImeConnectionOpen(), isTrue); + + // Focus the textfield. + await tester.placeCaretInSuperTextField(0); + + // Ensure that the textfield has primary focus, the editor doesn't, and the editor + // still has an open IME connection. + expect(textFieldFocus.hasPrimaryFocus, isTrue); + expect(editorFocus.hasPrimaryFocus, isFalse); + expect(editorFocus.hasFocus, isTrue); + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.isImeConnectionOpen(), isTrue); + + // Give focus back to the editor. + textFieldFocus.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + await tester.pump(); + + // Ensure that the textfield doesn't have any focus, and the editor has primary focus again. + expect(textFieldFocus.hasFocus, isFalse); + expect(editorFocus.hasPrimaryFocus, isTrue); + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.isImeConnectionOpen(), isTrue); + }); + + testWidgetsOnDesktop("can optionally close IME connection when it loses primary focus", (tester) async { + // Note: we don't include mobile in this test because mobile text fields always use IME + // which will steal the IME connection away from SuperEditor and interfere with the expected + // result. + + final textFieldFocus = FocusNode(); + final subtreeFocus = FocusNode(); + final editorFocus = FocusNode(); + await tester + .createDocument() + .withSingleParagraph() + .withInputSource(TextInputSource.ime) + .withFocusNode(editorFocus) + .withImePolicies( + const SuperEditorImePolicies(closeKeyboardOnLosePrimaryFocus: true), + ) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + Focus( + focusNode: subtreeFocus, + parentNode: editorFocus, + child: SuperTextField( + focusNode: textFieldFocus, + // We put the SuperTextField in keyboard mode so that the SuperTextField + // doesn't steal the IME connection. This way, we ensure that SuperEditor, + // left to its own devices, will proactively close the IME connection when + // it loses primary focus. + inputSource: TextInputSource.keyboard, + ), + ), + Expanded( + child: superEditor, + ), + ], + ), + ), + ), + ) + .autoFocus(true) + .pump(); + + // Ensure that SuperEditor begins with focus, a selection, and IME connection + expect(editorFocus.hasPrimaryFocus, isTrue); + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.isImeConnectionOpen(), isTrue); + + // Focus the textfield. + await tester.placeCaretInSuperTextField(0); + + // Ensure that the textfield has primary focus, the editor doesn't, and the editor + // closed the IME connection. + expect(textFieldFocus.hasPrimaryFocus, isTrue); + expect(editorFocus.hasPrimaryFocus, isFalse); + expect(editorFocus.hasFocus, isTrue); + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.isImeConnectionOpen(), isFalse); + + // Give focus back to the editor. + textFieldFocus.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + await tester.pump(); + + // Ensure that the textfield doesn't have any focus, and the editor has primary focus again. + expect(textFieldFocus.hasFocus, isFalse); + expect(editorFocus.hasPrimaryFocus, isTrue); + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + expect(SuperEditorInspector.isImeConnectionOpen(), isTrue); + }); + + testWidgetsOnAllPlatforms("retains selection when user types in sub-focus text field", (tester) async { + final textFieldFocus = FocusNode(); + final subTreeFocusNode = FocusNode(); + final textFieldController = ImeAttributedTextEditingController(); + final editorFocus = FocusNode(); + const initialEditorSelection = DocumentSelection( + base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 6)), + extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 11)), + ); + await tester + .createDocument() + .withSingleParagraph() + .withInputSource(TextInputSource.ime) + .withFocusNode(editorFocus) + .withSelection(initialEditorSelection) + .withSelectionPolicies(const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: true, + )) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + Focus( + focusNode: subTreeFocusNode, + parentNode: editorFocus, + child: SuperTextField( + focusNode: textFieldFocus, + textController: textFieldController, + inputSource: TextInputSource.ime, + ), + ), + Expanded( + child: superEditor, + ), + ], + ), + ), + ), + ) + .autoFocus(true) + .pump(); + + // Ensure that SuperEditor has focus, a selection, and IME connection + expect(editorFocus.hasPrimaryFocus, isTrue); + expect(SuperEditorInspector.findDocumentSelection(), initialEditorSelection); + + // Type into the text field. + await tester.placeCaretInSuperTextField(0); + await tester.typeImeText("Hello, world", find.byType(SuperTextField)); + + // Ensure the text field received the text. + expect(textFieldController.text.toPlainText(), "Hello, world"); + + // Ensure that SuperEditor has the same selection as before. + expect(SuperEditorInspector.findDocumentSelection(), initialEditorSelection); + }); + + // TODO: make sure caret disappears when editor has focus, but not primary focus + testWidgetsOnAllPlatforms("places caret at the previous selection when re-focusing by tab", (tester) async { await tester .createDocument() .withSingleParagraph() - .withInputSource(DocumentInputSource.ime) + .withInputSource(TextInputSource.ime) .withCustomWidgetTreeBuilder( (superEditor) => MaterialApp( home: Scaffold( @@ -566,7 +939,7 @@ Second Paragraph await tester .createDocument() .withSingleParagraph() - .withInputSource(DocumentInputSource.ime) + .withInputSource(TextInputSource.ime) .withCustomWidgetTreeBuilder( (superEditor) => MaterialApp( home: Scaffold( @@ -626,7 +999,7 @@ Second Paragraph await tester .createDocument() .withSingleParagraph() - .withInputSource(DocumentInputSource.ime) + .withInputSource(TextInputSource.ime) .withFocusNode(focusNode) .withCustomWidgetTreeBuilder( (superEditor) => MaterialApp( @@ -680,6 +1053,61 @@ Second Paragraph expect(_caretFinder(), findsOneWidget); }); + testWidgetsOnAllPlatforms("doesn't restore previous selection upon re-focusing when selected node was deleted", + (tester) async { + final focusNode = FocusNode(); + + final context = await tester + .createDocument() + .withLongTextContent() + .withInputSource(TextInputSource.ime) + .withFocusNode(focusNode) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + const TextField(), + Expanded(child: superEditor), + ], + ), + ), + ), + ) + .pump(); + + // Place caret at the beginning of the text. + await tester.placeCaretInParagraph('1', 0); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + + // Focus the textfield. + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + + // Ensure selection was cleared. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + + // Delete the selected node. + context.findEditContext().editor.execute([ + DeleteNodeRequest(nodeId: "1"), + ]); + + // Focus the editor. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Ensure no selection was restored. + expect(SuperEditorInspector.findDocumentSelection(), isNull); + }); + testWidgetsOnAllPlatforms('retains composer initial selection upon first editor focus', (tester) async { final focusNode = FocusNode(); @@ -707,6 +1135,95 @@ Second Paragraph // Ensure caret is displayed. expect(_caretFinder(), findsOneWidget); }); + + testWidgetsOnAllPlatforms("applies selection changes from the platform", (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the middle of the first word. + await tester.placeCaretInParagraph('1', 2); + + final text = SuperEditorInspector.findTextInComponent('1').toPlainText(); + + await tester.ime.sendDeltas( + [ + TextEditingDeltaNonTextUpdate( + oldText: '. $text', + selection: const TextSelection.collapsed(offset: 8), + composing: const TextSelection.collapsed(offset: 8), + ) + ], + getter: imeClientGetter, + ); + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("doesn't notify about selection changes when the selection hasn't changed", + (tester) async { + // The composer pauses and restarts selection notifications, which is an unusual + // behavior. We want to ensure that when the selection doesn't actually change, + // this system doesn't send selection change notifications. + + final context = await tester // + .createDocument() + .withLongTextContent() + .autoFocus(true) + .pump(); + + final doc = context.findEditContext().document; + final composer = context.findEditContext().composer; + + int selectionNotificationCount = 0; + composer.selectionNotifier.addListener(() { + selectionNotificationCount += 1; + }); + int selectionChangeCount = 0; + composer.selectionChanges.listen((event) { + selectionChangeCount += 1; + }); + + // Ensure selection is at the last character of the last paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.last.id, + nodePosition: const TextNodePosition(offset: 477), + ), + ), + ); + + // Send a selection change, using the existing selection. + context.findEditContext().editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: doc.last.id, + nodePosition: const TextNodePosition(offset: 477), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + await tester.pump(); + + // Ensure that we weren't notified of any selection changes. + expect(selectionNotificationCount, 0); + expect(selectionChangeCount, 0); + }); }); } @@ -775,9 +1292,5 @@ class _UnselectableHorizontalRuleComponent extends StatelessWidget { } Finder _caretFinder() { - if (debugDefaultTargetPlatformOverride == TargetPlatform.iOS || - debugDefaultTargetPlatformOverride == TargetPlatform.android) { - return find.byType(BlinkingCaret); - } - return find.byKey(primaryCaretKey); + return find.byKey(DocumentKeys.caret); } diff --git a/super_editor/test/super_editor/supereditor_ancestor_shortcuts_test.dart b/super_editor/test/super_editor/supereditor_shortcuts_test.dart similarity index 91% rename from super_editor/test/super_editor/supereditor_ancestor_shortcuts_test.dart rename to super_editor/test/super_editor/supereditor_shortcuts_test.dart index 13e6ce2a87..15bd52c2ee 100644 --- a/super_editor/test/super_editor/supereditor_ancestor_shortcuts_test.dart +++ b/super_editor/test/super_editor/supereditor_shortcuts_test.dart @@ -2,11 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; -import '../test_tools.dart'; - void main() { group("SuperEditor shortcuts", () { testWidgetsOnDesktop("overrides ancestor Shortcut widgets", (tester) async { @@ -61,9 +60,13 @@ void main() { Future _pumpShortcutsAndSuperEditor( WidgetTester tester, - List keyboardActions, + List keyboardActions, VoidCallback onShortcut, ) async { + final document = MutableDocument.empty("1"); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -79,13 +82,7 @@ Future _pumpShortcutsAndSuperEditor( _VoidCallbackIntent: _VoidCallbackAction(), }, child: SuperEditor( - editor: DocumentEditor( - document: MutableDocument( - nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "")), - ], - ), - ), + editor: editor, keyboardActions: keyboardActions, ), ), diff --git a/super_editor/test/super_editor/supereditor_single_column_layout_test.dart b/super_editor/test/super_editor/supereditor_single_column_layout_test.dart new file mode 100644 index 0000000000..77daad6b56 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_single_column_layout_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperEditor > single column layout >', () { + testWidgetsOnAllPlatforms('updates component width when component styles are changed by the editor', + (tester) async { + // Pump an editor with an arbitrary size, so we know + // the maximum width a component can be. + final context = await tester + .createDocument() // + .withCustomContent( + MutableDocument(nodes: [ + ParagraphNode( + id: '1', + text: AttributedText(), + metadata: const SingleColumnLayoutComponentStyles( + width: 600.0, + ).toMetadata(), + ), + ]), + ) + .withEditorSize(const Size(1000.0, 5000.0)) + .useStylesheet( + defaultStylesheet.copyWith(addRulesAfter: [ + StyleRule( + BlockSelector.all, + (doc, docNode) => { + // Zeroes the padding so the component is exactly + // the requested size. + Styles.padding: const CascadingPadding.all(0.0), + }, + ) + ]), + ) + .pump(); + + // Ensure the component initially has the requested size. + expect(SuperEditorInspector.findComponentSize('1').width, 600.0); + + // Change the width the of the first component in the document layout. + context.editor.execute( + const [ + ChangeSingleColumnLayoutComponentStylesRequest( + nodeId: '1', + styles: SingleColumnLayoutComponentStyles( + width: 400.0, + ), + ), + ], + ); + await tester.pump(); + + // Ensure the component width changed to the requested value. + expect(SuperEditorInspector.findComponentSize('1').width, 400.0); + }); + }); + + testWidgetsOnAllPlatforms( + 'updates padding around each component in a document layout, when the overall document layout padding is changed by the editor', + (tester) async { + final context = await tester + .createDocument() // + .withSingleEmptyParagraph() + .pump(); + + // Ensure the component started with some padding. + final paddingBefore = tester.widget(find + .ancestor( + of: find.byWidget(SuperEditorInspector.findWidgetForComponent('1')), + matching: find.byType(Padding), + ) + .first); + expect(paddingBefore.padding.horizontal, greaterThan(0.0)); + + // Changes the padding. + context.editor.execute( + const [ + ChangeSingleColumnLayoutComponentStylesRequest( + nodeId: '1', + styles: SingleColumnLayoutComponentStyles( + padding: EdgeInsets.zero, + ), + ), + ], + ); + await tester.pump(); + + // Ensure the padding was removed. + final paddingAfter = tester.widget(find + .ancestor( + of: find.byWidget(SuperEditorInspector.findWidgetForComponent('1')), + matching: find.byType(Padding), + ) + .first); + expect(paddingAfter.padding.horizontal, 0.0); + }); +} diff --git a/super_editor/test/super_editor/supereditor_smoke_test.dart b/super_editor/test/super_editor/supereditor_smoke_test.dart index 0b4e3819c1..354ad92a6e 100644 --- a/super_editor/test/super_editor/supereditor_smoke_test.dart +++ b/super_editor/test/super_editor/supereditor_smoke_test.dart @@ -3,8 +3,6 @@ import 'package:flutter_test_robots/flutter_test_robots.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; -import 'document_test_tools.dart'; - void main() { group("SuperEditor smoke test", () { testWidgets("writes a document with multiple types of content", (tester) async { @@ -13,6 +11,7 @@ void main() { .createDocument() .withSingleEmptyParagraph() .forDesktop() + .withInputSource(TextInputSource.keyboard) .pump(); await tester.placeCaretInParagraph("1", 0); @@ -57,7 +56,7 @@ void main() { // Ensure that we've created the document that we think we have. expect( - testDocContext.editContext.editor.document, + testDocContext.findEditContext().document, documentEquivalentTo(_expectedDocument), ); }); @@ -66,18 +65,18 @@ void main() { final _expectedDocument = MutableDocument( nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "This is the first paragraph of the document.")), + ParagraphNode(id: "1", text: AttributedText("This is the first paragraph of the document.")), ParagraphNode( - id: "2", text: AttributedText(text: "This is a blockquote."), metadata: {'blockType': blockquoteAttribution}), - ParagraphNode(id: "3", text: AttributedText(text: "This is an ordered list.")), - ListItemNode.ordered(id: "4", text: AttributedText(text: "item 1")), - ListItemNode.ordered(id: "5", text: AttributedText(text: "item 2")), - ListItemNode.ordered(id: "6", text: AttributedText(text: "item 3")), - ParagraphNode(id: "7", text: AttributedText(text: "This is an unordered list.")), - ListItemNode.unordered(id: "8", text: AttributedText(text: "item 1")), - ListItemNode.unordered(id: "9", text: AttributedText(text: "item 2")), - ListItemNode.unordered(id: "10", text: AttributedText(text: "item 3")), + id: "2", text: AttributedText("This is a blockquote."), metadata: {'blockType': blockquoteAttribution}), + ParagraphNode(id: "3", text: AttributedText("This is an ordered list.")), + ListItemNode.ordered(id: "4", text: AttributedText("item 1")), + ListItemNode.ordered(id: "5", text: AttributedText("item 2")), + ListItemNode.ordered(id: "6", text: AttributedText("item 3")), + ParagraphNode(id: "7", text: AttributedText("This is an unordered list.")), + ListItemNode.unordered(id: "8", text: AttributedText("item 1")), + ListItemNode.unordered(id: "9", text: AttributedText("item 2")), + ListItemNode.unordered(id: "10", text: AttributedText("item 3")), HorizontalRuleNode(id: "11"), - ParagraphNode(id: "12", text: AttributedText(text: "")), + ParagraphNode(id: "12", text: AttributedText("")), ], ); diff --git a/super_editor/test/super_editor/supereditor_software_keyboard_toolbar_test.dart b/super_editor/test/super_editor/supereditor_software_keyboard_toolbar_test.dart new file mode 100644 index 0000000000..603bc28e47 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_software_keyboard_toolbar_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +// TODO: Make the software keyboard toolbar testable +// Until then, this suite contains pieces of functionality that mirror the +// software keyboard toolbar behavior. + +void main() { + group('SuperEditor software keyboard toolbar >', () { + testWidgetsOnAllPlatforms('converts empty paragraph a horizontal rule', (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + final document = SuperEditorInspector.findDocument()!; + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph(document.first.id, 0); + + // Convert the empty paragraph into a horizontal rule. + final toolbarOps = KeyboardEditingToolbarOperations( + editor: context.findEditContext().editor, + document: context.findEditContext().document, + composer: context.findEditContext().composer, + commonOps: context.findEditContext().commonOps, + ); + toolbarOps.convertToHr(); + await tester.pump(); + + // Ensure the first node is now a horizontal rule node, and there's a + // a second node, which is a paragraph node. + final firstNode = document.first; + expect(firstNode, isA()); + + final secondNode = document.getNodeAt(1)!; + expect(secondNode, isA()); + expect((secondNode as ParagraphNode).text.toPlainText(), isEmpty); + + // Ensure the caret sits in the new paragraph node. + final selection = SuperEditorInspector.findDocumentSelection()!; + expect(selection.isCollapsed, isTrue); + expect(selection.extent.nodeId, secondNode.id); + expect((selection.extent.nodePosition as TextNodePosition).offset, 0); + }); + }); +} diff --git a/super_editor/test/super_editor/supereditor_style_test.dart b/super_editor/test/super_editor/supereditor_style_test.dart index a56c21e88f..0feb9eeefb 100644 --- a/super_editor/test/super_editor/supereditor_style_test.dart +++ b/super_editor/test/super_editor/supereditor_style_test.dart @@ -1,15 +1,34 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import '../test_tools.dart'; -import 'document_test_tools.dart'; - void main() { group('SuperEditor', () { + testWidgets("re-runs its presenter when the stylesheet changes", (tester) async { + // Configure and render a document. + final testDocumentContext = await tester // + .createDocument() + .withSingleParagraph() + .useStylesheet(_stylesheetWithBlackText) + .pump(); + + // Ensure that the initial text is black + expect(SuperEditorInspector.findParagraphStyle("1")!.color, Colors.black); + + // Configure and render a document with a different stylesheet. + await tester // + .updateDocument(testDocumentContext.configuration) + .useStylesheet(_stylesheetWithWhiteText) + .pump(); + + // Expect the paragraph to now be white. + expect(SuperEditorInspector.findParagraphStyle("1")!.color, Colors.white); + }); + testWidgetsOnArbitraryDesktop('changes visual text style when attributions change', (tester) async { final testContext = await tester .createDocument() // @@ -20,7 +39,7 @@ void main() { await tester.doubleTapInParagraph('1', 0); // Apply italic to the word. - testContext.editContext.commonOps.toggleAttributionsOnSelection({italicsAttribution}); + testContext.findEditContext().commonOps.toggleAttributionsOnSelection({italicsAttribution}); await tester.pump(); // Ensure italic was applied. @@ -34,21 +53,21 @@ void main() { final testContext = await tester // .createDocument() .withTwoEmptyParagraphs() - .useStylesheet(_stylesheet) + .useStylesheet(_stylesheetWithNodePositionRule) .pump(); - final doc = testContext.editContext.editor.document; + final doc = testContext.findEditContext().document; - final firstParagraphId = doc.nodes[0].id; - final secondParagraphId = doc.nodes[1].id; + final firstParagraphId = doc.getNodeAt(0)!.id; + final secondParagraphId = doc.getNodeAt(1)!.id; // Ensure the rule for paragraph is applied. expect(SuperEditorInspector.findParagraphStyle(firstParagraphId)!.color, Colors.red); // Remove the second paragraph. - testContext.editContext.editor.executeCommand( - DeleteNodeCommand(nodeId: secondParagraphId), - ); + testContext.findEditContext().editor.execute([ + DeleteNodeRequest(nodeId: secondParagraphId), + ]); await tester.pump(); // The first paragraph is now the only paragraph in the document. @@ -56,6 +75,78 @@ void main() { expect(SuperEditorInspector.findParagraphStyle(firstParagraphId)!.color, Colors.blue); }); + testWidgetsOnArbitraryDesktop('retains visual text style when combining a list item with a paragraph', + (tester) async { + await tester // + .createDocument() + .fromMarkdown(""" +* 1 +* 2 + +A paragraph + """) + .useStylesheet(Stylesheet( + inlineTextStyler: inlineTextStyler, + rules: [ + StyleRule( + const BlockSelector("listItem"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.blue, + fontSize: 16, + ), + }; + }, + ), + StyleRule( + const BlockSelector("paragraph"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Colors.red, + fontSize: 16, + ), + }; + }, + ), + ], + )) + .pump(); + + // Ensure the correct style was applied to the list item. + expect( + SuperEditorInspector.findParagraphStyle(SuperEditorInspector.getNodeAt(1).id)!.color, + Colors.blue, + ); + + // Ensure the correct style was applied to the paragraph. + expect( + SuperEditorInspector.findParagraphStyle(SuperEditorInspector.getNodeAt(2).id)!.color, + Colors.red, + ); + + // Place the caret at the end of the second list item. + final secondListItem = SuperEditorInspector.getNodeAt(1); + await tester.placeCaretInParagraph(secondListItem.id, 1); + + // Press backspace to delete the list item text. The content will be empty. + await tester.pressBackspace(); + + // Place the caret at the beginning of the paragraph. + final paragraph = SuperEditorInspector.getNodeAt(2); + await tester.placeCaretInParagraph(paragraph.id, 0); + + // Press backspace to combine the list item and the paragraph. + await tester.pressBackspace(); + + // Ensure the list item retained the correct style. + expect( + SuperEditorInspector.findParagraphStyle(SuperEditorInspector.getNodeAt(1).id)!.color, + Colors.blue, + ); + }); + testWidgetsOnArbitraryDesktop('rebuilds only changed nodes', (tester) async { int componentChangedCount = 0; @@ -64,11 +155,16 @@ void main() { .withLongTextContent() .pump(); - await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.nodes.last.id, 0); + await tester.placeCaretInParagraph(SuperEditorInspector.findDocument()!.last.id, 0); final presenter = tester.state(find.byType(SuperEditor)).presenter; presenter.addChangeListener(SingleColumnLayoutPresenterChangeListener( - onViewModelChange: ({required addedComponents, required changedComponents, required removedComponents}) { + onViewModelChange: ({ + required addedComponents, + required movedComponents, + required changedComponents, + required removedComponents, + }) { if (componentChangedCount != 0) { // The listener is called two times. The first one for the text change, which is the one // we care about, and the second one for the selection change. @@ -85,6 +181,65 @@ void main() { // Ensure only the changed component was marked as dirty. expect(componentChangedCount, 1); }); + + testWidgetsOnArbitraryDesktop('rebuilds moved nodes', (tester) async { + int componentAddedCount = 0; + int componentMoveCount = 0; + int componentChangedCount = 0; + int componentRemovedCount = 0; + + final testContext = await tester + .createDocument() // + .withLongTextContent() + .pump(); + + final presenter = tester.state(find.byType(SuperEditor)).presenter; + presenter.addChangeListener(SingleColumnLayoutPresenterChangeListener( + onViewModelChange: ({ + required addedComponents, + required movedComponents, + required changedComponents, + required removedComponents, + }) { + if (componentChangedCount != 0) { + throw Exception("Expected only one view model change, but there was more than one."); + } + + componentAddedCount = addedComponents.length; + componentMoveCount = movedComponents.length; + componentChangedCount = changedComponents.length; + componentRemovedCount = removedComponents.length; + }, + )); + + // Move the 2nd node to the end of the document. This should impact nodes 2, 3, and 4, + // but not node 1. + testContext.findEditContext().editor.execute([ + const MoveNodeRequest(nodeId: "2", newIndex: 3), + ]); + await tester.pumpAndSettle(); + + // Ensure that the relevant nodes were moved, but nothing was added or removed. + expect(componentAddedCount, 0); + expect(componentRemovedCount, 0); + expect(componentChangedCount, 0); + expect(componentMoveCount, 3); + + // Ensure the visual layout was updated, by inspecting the y-offset of the + // visual components. + expect( + SuperEditorInspector.findComponentOffset("1", Alignment.bottomLeft).dy, + lessThanOrEqualTo(SuperEditorInspector.findComponentOffset("3", Alignment.topLeft).dy), + ); + expect( + SuperEditorInspector.findComponentOffset("3", Alignment.bottomLeft).dy, + lessThanOrEqualTo(SuperEditorInspector.findComponentOffset("4", Alignment.topLeft).dy), + ); + expect( + SuperEditorInspector.findComponentOffset("4", Alignment.bottomLeft).dy, + lessThanOrEqualTo(SuperEditorInspector.findComponentOffset("2", Alignment.topLeft).dy), + ); + }); }); } @@ -92,18 +247,18 @@ InlineSpan _findSpanAtOffset( WidgetTester tester, { required int offset, }) { - final superTextWithSelection = tester.widget(find.byType(SuperTextWithSelection)); - return superTextWithSelection.richText.getSpanForPosition(TextPosition(offset: offset))!; + final superText = tester.widget(find.byType(SuperText)); + return superText.richText.getSpanForPosition(TextPosition(offset: offset))!; } -final _stylesheet = Stylesheet( +final _stylesheetWithNodePositionRule = Stylesheet( inlineTextStyler: inlineTextStyler, rules: [ StyleRule( const BlockSelector("paragraph"), (doc, docNode) { return { - "textStyle": const TextStyle( + Styles.textStyle: const TextStyle( color: Colors.red, ), }; @@ -113,7 +268,7 @@ final _stylesheet = Stylesheet( const BlockSelector("paragraph").last(), (doc, docNode) { return { - "textStyle": const TextStyle( + Styles.textStyle: const TextStyle( color: Colors.blue, ) }; @@ -121,6 +276,33 @@ final _stylesheet = Stylesheet( ), ], ); + +final _stylesheetWithBlackText = Stylesheet( + inlineTextStyler: inlineTextStyler, + rules: [ + StyleRule(BlockSelector.all, (document, node) { + return { + Styles.textStyle: const TextStyle( + color: Colors.black, + ), + }; + }), + ], +); + +final _stylesheetWithWhiteText = Stylesheet( + inlineTextStyler: inlineTextStyler, + rules: [ + StyleRule(BlockSelector.all, (document, node) { + return { + Styles.textStyle: const TextStyle( + color: Colors.white, + ), + }; + }), + ], +); + TextStyle inlineTextStyler(Set attributions, TextStyle base) { return base; } diff --git a/super_editor/test/super_editor/supereditor_switching_test.dart b/super_editor/test/super_editor/supereditor_switching_test.dart new file mode 100644 index 0000000000..be96686e60 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_switching_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/default_document_editor.dart'; +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/super_reader/super_reader.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/src/test/super_reader_test/super_reader_robot.dart'; + +import 'test_documents.dart'; + +void main() { + group('SuperEditor', () { + testWidgetsOnAllPlatforms('can be switched with a SuperReader', (tester) async { + final isEditable = ValueNotifier(true); + final document = longDoc(); + final scrollController = ScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: _EditorReaderSwitchDemo( + document: document, + isEditable: isEditable, + scrollController: scrollController, + ), + ), + ); + + // Select the first word and scroll the viewport an arbitrary amount of pixels. + await SuperEditorRobot(tester).doubleTapInParagraph('1', 0); + scrollController.jumpTo(100.0); + await tester.pump(); + + // Switch the SuperEditor with a SuperReader. + isEditable.value = false; + await tester.pump(); + + // Select the first word and scroll the viewport an arbitrary amount of pixels. + await SuperReaderRobot(tester).doubleTapInParagraph('1', 0); + scrollController.jumpTo(150.0); + await tester.pump(); + + // Switch back to the editor. + isEditable.value = true; + await tester.pump(); + + // Select the first word and scroll the viewport an arbitrary amount of pixels. + await SuperEditorRobot(tester).doubleTapInParagraph('1', 0); + scrollController.jumpTo(200.0); + await tester.pump(); + + // Reaching this point means we switched between SuperEditor and SuperReader without any crashes. + }); + }); +} + +/// A Scaffold which switches between a [SuperEditor] and a [SuperEditor] depending +/// on the value of [isEditable]. +class _EditorReaderSwitchDemo extends StatefulWidget { + const _EditorReaderSwitchDemo({ + Key? key, + required this.isEditable, + required this.document, + required this.scrollController, + }) : super(key: key); + + final MutableDocument document; + + /// When `true` a [SuperEditor] is displayed. Otherwise, display a [SuperReader]. + final ValueListenable isEditable; + + /// Scroll controller of the viewport. + final ScrollController scrollController; + + @override + State<_EditorReaderSwitchDemo> createState() => _EditorReaderSwitchDemoState(); +} + +class _EditorReaderSwitchDemoState extends State<_EditorReaderSwitchDemo> { + late Editor _docEditor; + late MutableDocumentComposer _composer; + + @override + void initState() { + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor( + document: widget.document, + composer: _composer, + ); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + controller: widget.scrollController, + slivers: [ + const SliverAppBar(), + ListenableBuilder( + listenable: widget.isEditable, + builder: (context, _) { + return _buildEditorOrReader(); + }, + ) + ], + ), + ); + } + + Widget _buildEditorOrReader() { + if (widget.isEditable.value) { + return SuperEditor( + editor: _docEditor, + ); + } else { + return SuperReader( + editor: _docEditor, + ); + } + } +} diff --git a/super_editor/test/super_editor/supereditor_tapregion_test.dart b/super_editor/test/super_editor/supereditor_tapregion_test.dart new file mode 100644 index 0000000000..797a37cea7 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_tapregion_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../test_tools.dart'; + +void main() { + group('SuperEditor inside a TapRegion', () { + testWidgetsOnAndroid("allows interaction with collapsed handle", (tester) async { + const tapRegionGroupId = 'super_editor_group_id'; + final focusNode = FocusNode(); + + final context = await tester // + .createDocument() + .fromMarkdown('Single line document.') + .withFocusNode(focusNode) + .withTapRegionGroupId(tapRegionGroupId) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: TapRegion( + groupId: tapRegionGroupId, + onTapOutside: (e) { + // Unfocus on tap outside so that we're sure that the test + // pass when using TapRegion's for focus, because apps should be able + // to do that. + focusNode.unfocus(); + }, + child: superEditor, + ), + ), + ), + ) + .pump(); + + final nodeId = context.document.first.id; + + // Place the caret at the start of the document to show the drag handle. + await tester.placeCaretInParagraph(nodeId, 0); + await tester.pump(kDoubleTapTimeout); + + // Drag the handle all the way to the end of the content. + final gesture = await tester.pressDownOnCollapsedMobileHandle(); + await gesture.moveBy(const Offset(500, 0)); + await tester.pump(); + + // Ensure the selection was placed at the end of the document. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 21), + ), + ), + ), + ); + }); + + testWidgetsOnMobile("allows interaction with expanded handle", (tester) async { + const tapRegionGroupId = 'super_editor_group_id'; + final focusNode = FocusNode(); + + final context = await tester // + .createDocument() + .fromMarkdown('Single line document.') + .withFocusNode(focusNode) + .withTapRegionGroupId(tapRegionGroupId) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: TapRegion( + groupId: tapRegionGroupId, + onTapOutside: (e) { + // Unfocus on tap outside so that we're sure that the test + // pass when using TapRegion's for focus, because apps should be able + // to do that. + focusNode.unfocus(); + }, + child: superEditor, + ), + ), + ), + ) + .pump(); + + final nodeId = context.document.first.id; + + // Double tap to show the expanded handle. + await tester.doubleTapInParagraph(nodeId, 0); + + // Drag the downstream handle all the way to the end of the content. + final gesture = await tester.pressDownOnDownstreamMobileHandle(); + await gesture.moveBy(const Offset(500, 0)); + await tester.pump(); + + // Ensure the selection expanded to the end of the document. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection( + base: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 21), + ), + ), + ), + ); + + // Pump with enough time to expire the tap recognizer timer. + await tester.pump(kTapTimeout); + }); + }); +} diff --git a/super_editor/test/super_editor/document_test_tools_test.dart b/super_editor/test/super_editor/supereditor_test_tools_test.dart similarity index 95% rename from super_editor/test/super_editor/document_test_tools_test.dart rename to super_editor/test/super_editor/supereditor_test_tools_test.dart index e560c508c1..5c1d5be155 100644 --- a/super_editor/test/super_editor/document_test_tools_test.dart +++ b/super_editor/test/super_editor/supereditor_test_tools_test.dart @@ -1,9 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor_test.dart'; -import '../test_tools.dart'; -import 'document_test_tools.dart'; - void main() { group("SuperEditor test tools", () { group("configures document from markdown", () { diff --git a/super_editor/test/super_editor/supereditor_text_layout_test.dart b/super_editor/test/super_editor/supereditor_text_layout_test.dart new file mode 100644 index 0000000000..fd0b3548a1 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_text_layout_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_text_layout/super_text_layout.dart'; +import 'package:super_text_layout/super_text_layout_inspector.dart'; + +void main() { + group('SuperEditor', () { + testWidgetsOnAllPlatforms('respects the OS text scaling preference', (tester) async { + // Pump an editor with a custom textScaleFactor. + await tester + .createDocument() + .withSingleParagraph() + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(1.5)), + child: superEditor, + ), + ), + ), + ) + .pump(); + + // Ensure the configure textScaleFactor was applied. + expect(SuperTextInspector.findTextScaler().scale(1.0), 1.5); + }); + + testWidgetsOnAllPlatforms('does not rebuild unmodified nodes', (tester) async { + final document = MutableDocument( + nodes: [ + ParagraphNode(id: "paragraph-1", text: AttributedText("Paragraph one")), + ParagraphNode(id: "paragraph-2", text: AttributedText("Paragraph two")), + ParagraphNode(id: "paragraph-3", text: AttributedText("Paragraph three")), + ListItemNode.unordered(id: "unordered-1", text: AttributedText("Unordered list item one")), + ListItemNode.unordered(id: "unordered-2", text: AttributedText("Unordered list item two")), + ListItemNode.unordered(id: "unordered-3", text: AttributedText("Unordered list item three")), + ListItemNode.ordered(id: "ordered-1", text: AttributedText("Ordered list item one")), + ListItemNode.ordered(id: "ordered-2", text: AttributedText("Ordered list item two")), + ListItemNode.ordered(id: "ordered-3", text: AttributedText("Ordered list item three")), + TaskNode(id: "task-1", text: AttributedText("Task one"), isComplete: false), + TaskNode(id: "task-2", text: AttributedText("Task two"), isComplete: false), + TaskNode(id: "task-3", text: AttributedText("Task three"), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + // Keeps track of the build count for each node. + Map buildCountPerNode = { + for (final node in document) // + node.id: 0, + }; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperTextAnalytics( + trackBuilds: true, + child: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ), + ); + + // Ensure each node was built a single time. + Map newBuildCountPerNode = _findRebuildCountPerNode(document); + for (final pair in newBuildCountPerNode.entries) { + expect(pair.value, 1, reason: 'Node with id ${pair.key} was rebuilt more times than expected'); + } + + // Update the current build count to perform the subsequent expectations. + buildCountPerNode = newBuildCountPerNode; + + // Modify the first paragraph. + await tester.placeCaretInParagraph('paragraph-1', 0); + await tester.typeImeText('a'); + + // Ensure only that paragraph was rebuilt. + newBuildCountPerNode = _findRebuildCountPerNode(document); + _ensureOnlyExpectedNodesRebuilt( + previousBuildCount: buildCountPerNode, + currentBuildCount: newBuildCountPerNode, + expectedRebuiltNodes: {'paragraph-1'}, + ); + + // Update the current build count to perform the subsequent expectations. + buildCountPerNode = newBuildCountPerNode; + + // Modify the first unordered list item. + await tester.placeCaretInParagraph('unordered-1', 0); + await tester.typeImeText('a'); + + // Ensure only that list item and the previously selected paragraph were rebuilt. + newBuildCountPerNode = _findRebuildCountPerNode(document); + _ensureOnlyExpectedNodesRebuilt( + previousBuildCount: buildCountPerNode, + currentBuildCount: newBuildCountPerNode, + expectedRebuiltNodes: {'paragraph-1', 'unordered-1'}, + ); + + // Update the current build count to perform the subsequent expectations. + buildCountPerNode = newBuildCountPerNode; + + // Modify the first ordered list item. + await tester.placeCaretInParagraph('ordered-1', 0); + await tester.typeImeText('a'); + + // Ensure only that unoreded list item and the previously selected list item were rebuilt. + newBuildCountPerNode = _findRebuildCountPerNode(document); + _ensureOnlyExpectedNodesRebuilt( + previousBuildCount: buildCountPerNode, + currentBuildCount: newBuildCountPerNode, + expectedRebuiltNodes: {'unordered-1', 'ordered-1'}, + ); + + // Update the current build count to perform the subsequent expectations. + buildCountPerNode = newBuildCountPerNode; + + // Modify the first task. + await tester.placeCaretInParagraph('task-1', 0); + await tester.typeImeText('a'); + + // Ensure only that task and the previously selected list item were rebuilt. + newBuildCountPerNode = _findRebuildCountPerNode(document); + _ensureOnlyExpectedNodesRebuilt( + previousBuildCount: buildCountPerNode, + currentBuildCount: newBuildCountPerNode, + expectedRebuiltNodes: {'ordered-1', 'task-1'}, + ); + }); + }); +} + +/// Returns a map with an entry for each document's node, where the key is the node id +/// and the value is the number of times this node was rebuilt since the widget tree was pumped. +/// +/// Only works for nodes that include a `SuperText` in its tree. +Map _findRebuildCountPerNode(Document document) { + final rebuildCountPerNode = {}; + for (final node in document) { + final widget = SuperEditorInspector.findWidgetForComponent(node.id); + + final superTextState = (find + .descendant(of: find.byWidget(widget), matching: find.byType(SuperText)) + .evaluate() + .first as StatefulElement) + .state as SuperTextState; + + rebuildCountPerNode[node.id] = superTextState.textBuildCount; + } + + return rebuildCountPerNode; +} + +/// Ensures that only the nodes present in [expectedRebuiltNodes] have increased the build count. +void _ensureOnlyExpectedNodesRebuilt({ + required Map previousBuildCount, + required Map currentBuildCount, + required Set expectedRebuiltNodes, +}) { + for (final pair in previousBuildCount.entries) { + if (expectedRebuiltNodes.contains(pair.key)) { + // Ensure that this node was rebuilt. + expect(currentBuildCount[pair.key], greaterThan(pair.value), + reason: 'Node with id ${pair.key} wasn\'t rebuilt when it should'); + } else { + // Ensure that this node wasn't rebuilt. + expect(currentBuildCount[pair.key], equals(pair.value), + reason: 'Node with id ${pair.key} was rebuilt when it shouldn\'t'); + } + } +} diff --git a/super_editor/test/super_editor/supereditor_theme_switching_test.dart b/super_editor/test/super_editor/supereditor_theme_switching_test.dart new file mode 100644 index 0000000000..fceb2b410a --- /dev/null +++ b/super_editor/test/super_editor/supereditor_theme_switching_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import 'test_documents.dart'; + +void main() { + group('SuperEditor > theme switching', () { + testWidgetsOnArbitraryDesktop('switches caret color', (tester) async { + final brightnessNotifier = ValueNotifier(Brightness.light); + + await _pumpThemeSwitchingTestApp(tester, brightnessNotifier: brightnessNotifier); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the caret is green, because the theme is light. + expect(_findDesktopCaretColor(tester), Colors.green.shade500); + + // Switch the theme to dark. + brightnessNotifier.value = Brightness.dark; + await tester.pumpAndSettle(); + + // Ensure the caret is red, because the theme is dark. + expect(_findDesktopCaretColor(tester), Colors.red.shade500); + }); + + testWidgetsOnArbitraryDesktop('switches caret color after typing', (tester) async { + final brightnessNotifier = ValueNotifier(Brightness.light); + + await _pumpThemeSwitchingTestApp(tester, brightnessNotifier: brightnessNotifier); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Ensure the caret is green, because the theme is light. + expect(_findDesktopCaretColor(tester), Colors.green.shade500); + + // Switch the theme to dark. + brightnessNotifier.value = Brightness.dark; + await tester.pumpAndSettle(); + + // Type a character to trigger a re-layout. + await tester.typeImeText('a'); + + // Ensure the caret is red, because the theme is dark. + expect(_findDesktopCaretColor(tester), Colors.red.shade500); + }); + }); +} + +/// Pumps a widget tree that rebuilds when the [brightnessNotifier] changes. +/// +/// The widget tree contains a [SuperEditor] with a custom caret overlay that +/// changes color based on the brightness of the theme. +Future _pumpThemeSwitchingTestApp( + WidgetTester tester, { + required ValueNotifier brightnessNotifier, +}) async { + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor( + document: singleParagraphDoc(), + composer: composer, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: brightnessNotifier, + builder: (context, brightness, child) { + return Theme( + data: ThemeData( + brightness: brightness, + ), + child: SuperEditor( + editor: editor, + documentOverlayBuilders: [ + // Copy all default overlay builders except the caret overlay builder. + ...defaultSuperEditorDocumentOverlayBuilders.where( + (builder) => builder is! DefaultCaretOverlayBuilder, + ), + DefaultCaretOverlayBuilder( + caretStyle: CaretStyle( + color: brightness == Brightness.light ? Colors.green : Colors.red, + ), + ) + ], + ), + ); + }, + ), + ), + ), + ); +} + +Color _findDesktopCaretColor(WidgetTester tester) { + final caret = tester.widget(find.byKey(DocumentKeys.caret)); + return (caret.decoration as BoxDecoration).color!; +} diff --git a/super_editor/test/super_editor/supereditor_undeletable_content_test.dart b/super_editor/test/super_editor/supereditor_undeletable_content_test.dart new file mode 100644 index 0000000000..a38937fc69 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_undeletable_content_test.dart @@ -0,0 +1,2887 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../test_tools.dart'; + +void main() { + group('SuperEditor > undeletable content > prevents deletion > ', () { + // Instead of only testing a single arbitrary desktop, the desktop tests run for all desktop + // platforms because on mac the backspace is handled by selectors and on the other platforms + // it is handled by the keyboard handlers. See `MacOsSelectors` for more information. + group('with collapsed selection > ', () { + group('with backspace', () { + testWidgetsOnDesktop('at the downstream edge of the node', (tester) async { + await _pumpHrThenParagraphTestApp(tester); + + const hrDownstreamEdgePosition = DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ); + + // Place the caret at the downstream edge of the horizontal rule. + await tester.tapAtDocumentPosition(hrDownstreamEdgePosition); + + // Ensure the selection is at the downstream edge of the horizontal rule. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: hrDownstreamEdgePosition, + ), + ); + + // Press backspace a few times trying to delete the node. + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection didn't change. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: hrDownstreamEdgePosition, + ), + ); + }); + + testWidgetsOnDesktop('at the beginning of the downstream node', (tester) async { + await _pumpParagraphThenHrThenParagraphTestApp(tester); + + // Place the caret at the beginning of the second paragraph. + await tester.placeCaretInParagraph('2', 0); + + // Press backspace trying to delete the horizontal rule. + await tester.pressBackspace(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the two paragraphs were merged. + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'Paragraph 1Paragraph 2', + ); + + // Ensure the caret moved to the end of existing test of the first paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + ), + ), + ); + }); + }); + + group('with delete', () { + testWidgetsOnDesktop('at the upstream edge of the node', (tester) async { + await _pumpParagraphThenHrTestApp(tester); + + const hrUpstreamEdgePosition = DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ); + + // Place the caret at the upstream edge of the horizontal rule. + await tester.tapAtDocumentPosition(hrUpstreamEdgePosition); + + // Ensure the selection is at the beginning of the horizontal rule. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: hrUpstreamEdgePosition, + ), + ); + + // Press delete a few times trying to delete the node. + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection didn't change. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: hrUpstreamEdgePosition, + ), + ); + }); + + testWidgetsOnDesktop('at the end of the upstream node', (tester) async { + await _pumpParagraphThenHrThenParagraphTestApp(tester); + + // Place the caret at the end of the first paragraph. + await tester.placeCaretInParagraph('1', 11); + + // Press backspace trying to delete the horizontal rule. + await tester.pressDelete(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the two paragraphs were merged. + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'Paragraph 1Paragraph 2', + ); + + // Ensure the caret stayed where it was. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + ), + ), + ); + }); + }); + + group('with backspace in software keyboard', () { + testWidgetsOnMobile('at the downstream edge of the node', (tester) async { + await _pumpHrThenParagraphTestApp(tester); + + const hrDownstreamPosition = DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ); + + // Place the caret at the downstream edge of the horizontal rule. + await tester.tapAtDocumentPosition(hrDownstreamPosition); + await tester.pump(); + + // Ensure the caret is at the downstream edge of the horizontal rule. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: hrDownstreamPosition, + ), + ); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. ~', + selection: TextSelection(baseOffset: 2, extentOffset: 3), + composing: TextRange(start: -1, end: -1), + ), + const TextEditingDeltaDeletion( + oldText: '. ~', + deletedRange: TextSelection(baseOffset: 2, extentOffset: 3), + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection was kept where it was. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: hrDownstreamPosition, + ), + ); + }); + }); + }); + + group('with expanded selection > ', () { + group('with backspace', () { + testWidgetsOnDesktop('for downstream selection', (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select the whole hr. Use a command instead of a user gesture to have + // precise control over the selection. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace a few times trying to delete the node. + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('for upstream selection', (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select the whole hr. Use a command instead of a user gesture to have + // precise control over the selection. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace a few times trying to delete the node. + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnAllPlatforms('when multiple deletable and undeletable nodes are selected', (tester) async { + final testContext = await _pumpMultipleDeletableAndUndeletableNodesTestApp(tester); + + // Select from "Para>graph 1" to "Paragraph <3". + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: TextNodePosition(offset: 10), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected nodes. + await tester.pressBackspace(); + + // Ensure that the deletable content was deleted and the selection moved to upstream edge + // of the selection. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 4); + expect(SuperEditorInspector.findTextInComponent('1'), AttributedText('Para3')); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 4)), + ), + ), + ); + + // Ensure that the undeletable content was not deleted. + expect(document.getNodeById('hr1'), isNotNull); + expect(document.getNodeById('hr1'), isA()); + + expect(document.getNodeById('hr2'), isNotNull); + expect(document.getNodeById('hr2'), isA()); + + expect(document.getNodeById('hr3'), isNotNull); + expect(document.getNodeById('hr3'), isA()); + }); + + testWidgetsOnDesktop('when selection starts at upstream edge and ends at a downstream deletable node', + (tester) async { + final testContext = await _pumpHrThenParagraphTestApp(tester); + + // Select from the upstream edge of the horizontal rule to "Para|graph 1". + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected content. + await tester.pressBackspace(); + + // Ensure that the deletable content was deleted and selection moved to the beginning + // of the selected paragraph. + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'graph 1', + ); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnDesktop('when selection starts at downstream edge and ends at an upstream deletable node', + (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select from the downstream edge of the horizontal rule to "Para|graph 1". + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected content. + await tester.pressBackspace(); + + // Ensure that the deletable content was deleted and selection moved to the upstream edge + // of the selection + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'Para', + ); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 4)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnDesktop('when selection starts at an upstream deletable node and ends at the downstream edge', + (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select from the "Para|graph 1" to the downstream edge of the horizontal rule. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected content. + await tester.pressBackspace(); + + // Ensure that the deletable content was deleted and selection moved to the beginning + // of the selected paragraph. + expect(SuperEditorInspector.findTextInComponent('1').toPlainText(), 'Para'); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 4)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnDesktop('when selection starts at a downstream deletable node and ends at the upstream edge', + (tester) async { + final testContext = await _pumpHrThenParagraphTestApp(tester); + + // Select from the "Para|graph 2" to the upstream edge of the second horizontal rule. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected content. + await tester.pressBackspace(); + + // Ensure that the deletable content was deleted and selection moved to the beginning + // of the selected paragraph. + expect(SuperEditorInspector.findTextInComponent('1').toPlainText(), 'graph 1'); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnDesktop( + 'when selection starts at the dowstream edge and ends at the beginning of the downstream node', + (tester) async { + final testContext = await _pumpHrThenParagraphTestApp(tester); + + const selection = DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ); + + // Select from the end of the horizontal rule to the beginning of the downstream node. + testContext.editor.execute([ + const ChangeSelectionRequest( + selection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected content. + await tester.pressBackspace(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection didn't change. + expect(SuperEditorInspector.findDocumentSelection(), selection); + }); + + testWidgetsOnDesktop( + 'when selection starts at the upstream edge and ends at the beginning of the downstream node', + (tester) async { + final testContext = await _pumpHrThenParagraphTestApp(tester); + + const selection = DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ); + + // Select from the beginning of the horizontal rule to the beginning of the downstream node. + testContext.editor.execute([ + const ChangeSelectionRequest( + selection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected content. + await tester.pressBackspace(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection didn't change. + expect(SuperEditorInspector.findDocumentSelection(), selection); + }); + + testWidgetsOnDesktop('when selection starts at upstream edge and ends at the end of the upstream node', + (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select from the beginning of the horizontal rule to the end of the upstream node. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected content. + await tester.pressBackspace(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection moved to the end of the upstream node. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('when selection starts at downstream edge and ends at the end of the upstream node', + (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select from the end of the horizontal rule to the end of the downstream node. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected content. + await tester.pressBackspace(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection moved to the end of the upstream node. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('when the whole document is selected and starts with a non-deletable node', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '1', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode( + id: '2', + text: AttributedText('This is some text'), + ), + ], + ), + ) + .pump(); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("2", 0); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Delete all content. + await tester.pressBackspace(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the horizontal rule was kept, the paragraph was deleted, + // and a new empty paragraph was added to the end of the document. + expect(document.nodeCount, equals(2)); + expect(document.first, isA()); + expect(document.last, isA()); + expect((document.last as TextNode).text.toPlainText(), equals('')); + + // Ensure the caret was placed at the beginning of the newly inserted paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnDesktop('when the whole document is selected and ends with a non-deletable node', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is some text'), + ), + HorizontalRuleNode(id: '2', metadata: { + NodeMetadata.isDeletable: false, + }), + ], + ), + ) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // Delete all content. + await tester.pressBackspace(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the horizontal rule was kept, the paragraph was deleted, + // and a new empty paragraph was added to the end of the document. + expect(document.nodeCount, equals(2)); + expect(document.first, isA()); + expect(document.last, isA()); + expect((document.last as TextNode).text.toPlainText(), equals('')); + + // Ensure the caret was placed at the beginning of the newly inserted paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnDesktop('when the whole document is selected and starts and ends with non-deletable nodes', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '1', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode( + id: '2', + text: AttributedText('This is some text'), + ), + HorizontalRuleNode(id: '3', metadata: { + NodeMetadata.isDeletable: false, + }), + ], + ), + ) + .pump(); + + await tester.placeCaretInParagraph("2", 0); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // Delete all content. + await tester.pressBackspace(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the horizontal rules were kept, the paragraph was deleted, + // and a new empty paragraph was added to the end of the document. + expect(document.nodeCount, equals(3)); + expect(document.getNodeAt(0), isA()); + expect(document.getNodeAt(1), isA()); + expect(document.getNodeAt(2), isA()); + expect((document.last as TextNode).text.toPlainText(), equals('')); + + // Ensure the caret was placed at the beginning of the newly inserted paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnDesktop('when all nodes are non-deletable', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '1', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '2', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '3', metadata: { + NodeMetadata.isDeletable: false, + }), + ], + ), + ) + .pump(); + + // Select the first horizontal rule. + await tester.tapAtDocumentPosition( + const DocumentPosition( + nodeId: "1", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // Try to delete all content. + await tester.pressBackspace(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure nothing was deleted. + expect(document.nodeCount, equals(3)); + expect(document.getNodeAt(0), isA()); + expect(document.getNodeAt(1), isA()); + expect(document.getNodeAt(2), isA()); + + // Ensure the selection was kept. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + }); + + testWidgetsOnDesktop('when all nodes in selection are non-deletable and document contains deletable nodes', + (tester) async { + final testContext = await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode(id: '1', text: AttributedText()), + HorizontalRuleNode(id: '2', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '3', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '4', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode(id: '5', text: AttributedText()), + ], + ), + ) + .pump(); + + // Select the first horizontal rule. + await tester.tapAtDocumentPosition( + const DocumentPosition( + nodeId: "2", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ); + + // Select all non-deletable nodes. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '2', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Try to delete all content. + await tester.pressBackspace(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure nothing was deleted. + expect(document.nodeCount, equals(5)); + expect(document.getNodeAt(0), isA()); + expect(document.getNodeAt(1), isA()); + expect(document.getNodeAt(2), isA()); + expect(document.getNodeAt(3), isA()); + expect(document.getNodeAt(4), isA()); + + // Ensure the selection was kept. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '2', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + }); + }); + + group('with delete', () { + testWidgetsOnDesktop('for downstream selection', (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select the whole hr. Use a command instead of a user gesture to have + // precise control over the selection. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press delete a few times trying to delete the node. + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnDesktop('for upstream selection', (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select the whole hr. Use a command instead of a user gesture to have + // precise control over the selection. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press delete a few times trying to delete the node. + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnDesktop('when multiple deletable and undeletable nodes are selected', (tester) async { + final testContext = await _pumpMultipleDeletableAndUndeletableNodesTestApp(tester); + + // Select from "Para>graph 1" to "Paragraph <3". + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: TextNodePosition(offset: 10), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press delete to delete the selected nodes. + await tester.pressDelete(); + + // Ensure that the deletable content was deleted and selection moved to upstream edge + // of the first deletable node. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 4); + expect(SuperEditorInspector.findTextInComponent('1'), AttributedText('Para3')); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 4)), + ), + ), + ); + + // Ensure that the undeletable content was not deleted. + expect(document.getNodeById('hr1'), isNotNull); + expect(document.getNodeById('hr1'), isA()); + + expect(document.getNodeById('hr2'), isNotNull); + expect(document.getNodeById('hr2'), isA()); + + expect(document.getNodeById('hr3'), isNotNull); + expect(document.getNodeById('hr3'), isA()); + }); + + testWidgetsOnDesktop('when selection starts at upstream edge and ends at a downstream deletable node', + (tester) async { + final testContext = await _pumpHrThenParagraphTestApp(tester); + + // Select from the upstream edge of the horizontal rule to "Para|graph 1". + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press delete to remove the selected content. + await tester.pressDelete(); + + // Ensure that the deletable content was deleted and selection moved to the beginning + // of the selected paragraph. + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'graph 1', + ); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnDesktop('when selection starts at downstream edge and ends at an upstream deletable node', + (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select from the downstream edge of the horizontal rule to "Para|graph 1". + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press delete to remove the selected content. + await tester.pressDelete(); + + // Ensure that the deletable content was deleted and selection moved to the upstream edge + // of the selection + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'Para', + ); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 4)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnDesktop('when selection starts at an upstream deletable node and ends at the downstream edge', + (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select from the "Para|graph 1" to the downstream edge of the horizontal rule. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press delete to remove the selected content. + await tester.pressDelete(); + + // Ensure that the deletable content was deleted and selection moved to the beginning + // of the selected paragraph. + expect(SuperEditorInspector.findTextInComponent('1').toPlainText(), 'Para'); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 4)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnDesktop('when selection starts at a downstream deletable node and ends at the upstream edge', + (tester) async { + final testContext = await _pumpHrThenParagraphTestApp(tester); + + // Select from the "Para|graph 2" to the upstream edge of the second horizontal rule. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press delete to remove the selected content. + await tester.pressDelete(); + + // Ensure that the deletable content was deleted and selection moved to the beginning + // of the selected paragraph. + expect(SuperEditorInspector.findTextInComponent('1').toPlainText(), 'graph 1'); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnDesktop( + 'when selection starts at downstream edge and ends at the beginning of the downstream node', + (tester) async { + final testContext = await _pumpHrThenParagraphTestApp(tester); + + // Select from the end of horizontal rule to the beginning of the downstream node. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected content. + await tester.pressDelete(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection moved to the beginning of the downstream node. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('when selection starts at upstream edge and ends at the beginning of the downstream node', + (tester) async { + final testContext = await _pumpHrThenParagraphTestApp(tester); + + // Select from the beginning of horizontal rule to the beginning of the downstream node. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press backspace to delete the selected content. + await tester.pressDelete(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection moved to the beginning of the downstream node. + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + ), + ), + ); + }); + + testWidgetsOnDesktop('when selection starts at upstream edge and ends at the end of the upstream node', + (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + const selection = DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + ); + + // Select from the beginning of the horizontal rule to the end of the upstream node. + testContext.editor.execute([ + const ChangeSelectionRequest( + selection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press delete to remove the selected content. + await tester.pressDelete(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection didn't change. + expect(SuperEditorInspector.findDocumentSelection(), selection); + }); + + testWidgetsOnDesktop('when selection starts at downstream edge and ends at the end of the upstream node', + (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + const selection = DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 11), + ), + ); + + // Select from the end of the horizontal rule to the end of the downstream node. + testContext.editor.execute([ + const ChangeSelectionRequest( + selection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Press delete to remove the selected content. + await tester.pressDelete(); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + + // Ensure the selection didn't change. + expect(SuperEditorInspector.findDocumentSelection(), selection); + }); + + testWidgetsOnDesktop('when the whole document is selected and starts with a non-deletable node', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '1', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode( + id: '2', + text: AttributedText('This is some text'), + ), + ], + ), + ) + .pump(); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("2", 0); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Delete all content. + await tester.pressDelete(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the horizontal rule was kept, the paragraph was deleted, + // and a new empty paragraph was added to the end of the document. + expect(document.nodeCount, equals(2)); + expect(document.first, isA()); + expect(document.last, isA()); + expect((document.last as TextNode).text.toPlainText(), equals('')); + + // Ensure the caret was placed at the beginning of the newly inserted paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnDesktop('when the whole document is selected and ends with a non-deletable node', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is some text'), + ), + HorizontalRuleNode(id: '2', metadata: { + NodeMetadata.isDeletable: false, + }), + ], + ), + ) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Select all content + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: '2', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // Delete all content. + await tester.pressDelete(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the horizontal rule was kept, the paragraph was deleted, + // and a new empty paragraph was added to the end of the document. + expect(document.nodeCount, equals(2)); + expect(document.first, isA()); + expect(document.last, isA()); + expect((document.last as TextNode).text.toPlainText(), equals('')); + + // Ensure the caret was placed at the beginning of the newly inserted paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnDesktop('when the whole document is selected and starts and ends with non-deletable nodes', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '1', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode( + id: '2', + text: AttributedText('This is some text'), + ), + HorizontalRuleNode(id: '3', metadata: { + NodeMetadata.isDeletable: false, + }), + ], + ), + ) + .pump(); + + await tester.placeCaretInParagraph("2", 0); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // Delete all content. + await tester.pressDelete(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the horizontal rules were kept, the paragraph was deleted, + // and a new empty paragraph was added to the end of the document. + expect(document.nodeCount, equals(3)); + expect(document.getNodeAt(0), isA()); + expect(document.getNodeAt(1), isA()); + expect(document.getNodeAt(2), isA()); + expect((document.last as TextNode).text.toPlainText(), equals('')); + + // Ensure the caret was placed at the beginning of the newly inserted paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnDesktop('when all nodes are non-deletable', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '1', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '2', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '3', metadata: { + NodeMetadata.isDeletable: false, + }), + ], + ), + ) + .pump(); + + // Select the first horizontal rule. + await tester.tapAtDocumentPosition( + const DocumentPosition( + nodeId: "1", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // Try to delete all content. + await tester.pressDelete(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure nothing was deleted. + expect(document.nodeCount, equals(3)); + expect(document.getNodeAt(0), isA()); + expect(document.getNodeAt(1), isA()); + expect(document.getNodeAt(2), isA()); + + // Ensure the selection was kept. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + }); + + testWidgetsOnDesktop('when all nodes in selection are non-deletable and document contains deletable nodes', + (tester) async { + final testContext = await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode(id: '1', text: AttributedText()), + HorizontalRuleNode(id: '2', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '3', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '4', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode(id: '5', text: AttributedText()), + ], + ), + ) + .pump(); + + // Select the first horizontal rule. + await tester.tapAtDocumentPosition( + const DocumentPosition( + nodeId: "2", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ); + + // Select all non-deletable nodes. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '2', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Try to delete all content. + await tester.pressDelete(); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure nothing was deleted. + expect(document.nodeCount, equals(5)); + expect(document.getNodeAt(0), isA()); + expect(document.getNodeAt(1), isA()); + expect(document.getNodeAt(2), isA()); + expect(document.getNodeAt(3), isA()); + expect(document.getNodeAt(4), isA()); + + // Ensure the selection was kept. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '2', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + }); + }); + + group('when typing', () { + testWidgetsOnDesktop('with multiple nodes selected', (tester) async { + final testContext = await _pumpMultipleDeletableAndUndeletableNodesTestApp(tester); + + // Select from "Para>graph 1" to "Paragraph <3". + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: TextNodePosition(offset: 10), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Type a character to replace the selected nodes. + await tester.typeImeText('X'); + + // Ensure that the deletable content was deleted and the text was inserted at the upstream + // edge of the selection. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 4); + expect(SuperEditorInspector.findTextInComponent('1'), AttributedText('ParaX3')); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + ), + ), + ); + + // Ensure that the undeletable content was not deleted. + expect(document.getNodeById('hr1'), isNotNull); + expect(document.getNodeById('hr1'), isA()); + + expect(document.getNodeById('hr2'), isNotNull); + expect(document.getNodeById('hr2'), isA()); + + expect(document.getNodeById('hr3'), isNotNull); + expect(document.getNodeById('hr3'), isA()); + }); + }); + + group('with backspace in software keyboard', () { + testWidgetsOnMobile('for downstream selection', (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select the whole hr. Use a command instead of a user gesture to have + // precise control over the selection. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. ~', + selection: TextSelection(baseOffset: 2, extentOffset: 3), + composing: TextRange(start: -1, end: -1), + ), + const TextEditingDeltaDeletion( + oldText: '. ~', + deletedRange: TextSelection(baseOffset: 2, extentOffset: 3), + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnMobile('for upstream selection', (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select the whole hr. Use a command instead of a user gesture to have + // precise control over the selection. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. ~', + selection: TextSelection(baseOffset: 2, extentOffset: 3), + composing: TextRange(start: -1, end: -1), + ), + const TextEditingDeltaDeletion( + oldText: '. ~', + deletedRange: TextSelection(baseOffset: 2, extentOffset: 3), + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnMobile('when multiple deletable and undeletable nodes are selected', (tester) async { + final testContext = await _pumpMultipleDeletableAndUndeletableNodesTestApp(tester); + + // Select from "Para>graph 1" to "Paragraph <3". + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: TextNodePosition(offset: 10), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. Paragraph 1\n~\n~\nParagraph 2\n~\nParagraph 3', + selection: TextSelection(baseOffset: 6, extentOffset: 42), + composing: TextRange(start: -1, end: -1), + ), + const TextEditingDeltaDeletion( + oldText: '. Paragraph 1\n~\n~\nParagraph 2\n~\nParagraph 3', + deletedRange: TextSelection(baseOffset: 6, extentOffset: 42), + selection: TextSelection.collapsed(offset: 6), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Ensure that the deletable content was deleted and selection moved to upstream edge + // of the first deletable node. + final document = SuperEditorInspector.findDocument()!; + expect(document.nodeCount, 4); + expect(SuperEditorInspector.findTextInComponent('1'), AttributedText('Para3')); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 4)), + ), + ), + ); + + // Ensure that the undeletable content was not deleted. + expect(document.getNodeById('hr1'), isNotNull); + expect(document.getNodeById('hr1'), isA()); + + expect(document.getNodeById('hr2'), isNotNull); + expect(document.getNodeById('hr2'), isA()); + + expect(document.getNodeById('hr3'), isNotNull); + expect(document.getNodeById('hr3'), isA()); + }); + + testWidgetsOnMobile('when selection starts at upstream edge and ends at a downstream deletable node', + (tester) async { + final testContext = await _pumpHrThenParagraphTestApp(tester); + + // Select from the upstream edge of the horizontal rule to "Para|graph 1". + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. ~\nParagraph 1', + selection: TextSelection(baseOffset: 2, extentOffset: 8), + composing: TextRange(start: -1, end: -1), + ), + const TextEditingDeltaDeletion( + oldText: '. ~\nParagraph 1', + deletedRange: TextSelection(baseOffset: 2, extentOffset: 8), + selection: TextSelection.collapsed(offset: 6), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Ensure that the deletable content was deleted and selection moved to the beginning + // of the selected paragraph. + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'graph 1', + ); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnMobile('when selection starts at downstream edge and ends at an upstream deletable node', + (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select from the downstream edge of the horizontal rule to "Para|graph 1". + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + extent: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. Paragraph 1\n~', + selection: TextSelection(baseOffset: 15, extentOffset: 6), + composing: TextRange(start: -1, end: -1), + ), + const TextEditingDeltaDeletion( + oldText: '. Paragraph 1\n~', + deletedRange: TextSelection(baseOffset: 15, extentOffset: 6), + selection: TextSelection.collapsed(offset: 6), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Ensure that the deletable content was deleted and selection moved to the upstream edge + // of the selection + expect( + SuperEditorInspector.findTextInComponent('1').toPlainText(), + 'Para', + ); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 4)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnMobile('when selection starts at an upstream deletable node and ends at the downstream edge', + (tester) async { + final testContext = await _pumpParagraphThenHrTestApp(tester); + + // Select from the "Para|graph 1" to the downstream edge of the horizontal rule. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. Paragraph 1\n~', + selection: TextSelection(baseOffset: 6, extentOffset: 15), + composing: TextRange(start: -1, end: -1), + ), + const TextEditingDeltaDeletion( + oldText: '. Paragraph 1\n~', + deletedRange: TextSelection(baseOffset: 6, extentOffset: 15), + selection: TextSelection.collapsed(offset: 6), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Ensure that the deletable content was deleted and selection moved to the beginning + // of the selected paragraph. + expect(SuperEditorInspector.findTextInComponent('1').toPlainText(), 'Para'); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 4)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnMobile('when selection starts at a downstream deletable node and ends at the upstream edge', + (tester) async { + final testContext = await _pumpHrThenParagraphTestApp(tester); + + // Select from the "Para|graph 1" to the upstream edge of the second horizontal rule. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: TextNodePosition(offset: 4), + ), + extent: DocumentPosition( + nodeId: 'hr', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. ~\nParagraph 1', + selection: TextSelection(baseOffset: 8, extentOffset: 2), + composing: TextRange(start: -1, end: -1), + ), + const TextEditingDeltaDeletion( + oldText: '. ~\nParagraph 1', + deletedRange: TextSelection(baseOffset: 8, extentOffset: 2), + selection: TextSelection.collapsed(offset: 2), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + + // Ensure that the deletable content was deleted and selection moved to the beginning + // of the selected paragraph. + expect(SuperEditorInspector.findTextInComponent('1').toPlainText(), 'graph 1'); + expect( + SuperEditorInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + ), + ), + ); + + // Ensure that the horizontal rule was not deleted. + final document = SuperEditorInspector.findDocument()!; + expect(document.getNodeById('hr'), isNotNull); + expect(document.getNodeById('hr'), isA()); + }); + + testWidgetsOnMobile('when the whole document is selected and starts with a non-deletable node', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '1', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode( + id: '2', + text: AttributedText('This is some text'), + ), + ], + ), + ) + .pump(); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("2", 0); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. ~\nThis is some text', + selection: TextSelection(baseOffset: 0, extentOffset: 21), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: '. ~\nThis is some text', + deletedRange: TextSelection(baseOffset: 0, extentOffset: 21), + selection: TextSelection.collapsed(offset: 0), + composing: TextRange.empty, + ), + ], getter: imeClientGetter); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the horizontal rule was kept, the paragraph was deleted, + // and a new empty paragraph was added to the end of the document. + expect(document.nodeCount, equals(2)); + expect(document.first, isA()); + expect(document.last, isA()); + expect((document.last as TextNode).text.toPlainText(), equals('')); + + // Ensure the caret was placed at the beginning of the newly inserted paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnMobile('when the whole document is selected and ends with a non-deletable node', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('This is some text'), + ), + HorizontalRuleNode(id: '2', metadata: { + NodeMetadata.isDeletable: false, + }), + ], + ), + ) + .pump(); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + await tester.pump(); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. This is some text\n~', + selection: TextSelection(baseOffset: 0, extentOffset: 21), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: '. This is some text\n~', + deletedRange: TextSelection(baseOffset: 0, extentOffset: 21), + selection: TextSelection.collapsed(offset: 0), + composing: TextRange.empty, + ), + ], getter: imeClientGetter); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the horizontal rule was kept, the paragraph was deleted, + // and a new empty paragraph was added to the end of the document. + expect(document.nodeCount, equals(2)); + expect(document.first, isA()); + expect(document.last, isA()); + expect((document.last as TextNode).text.toPlainText(), equals('')); + + // Ensure the caret was placed at the beginning of the newly inserted paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnDesktop('when the whole document is selected and starts and ends with non-deletable nodes', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '1', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode( + id: '2', + text: AttributedText('This is some text'), + ), + HorizontalRuleNode(id: '3', metadata: { + NodeMetadata.isDeletable: false, + }), + ], + ), + ) + .pump(); + + await tester.placeCaretInParagraph("2", 0); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Ensure everything is selected. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. ~\nThis is some text\n~', + selection: TextSelection(baseOffset: 0, extentOffset: 23), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: '. ~\nThis is some text\n~', + deletedRange: TextSelection(baseOffset: 0, extentOffset: 23), + selection: TextSelection.collapsed(offset: 0), + composing: TextRange.empty, + ), + ], getter: imeClientGetter); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure the horizontal rules were kept, the paragraph was deleted, + // and a new empty paragraph was added to the end of the document. + expect(document.nodeCount, equals(3)); + expect(document.getNodeAt(0), isA()); + expect(document.getNodeAt(1), isA()); + expect(document.getNodeAt(2), isA()); + expect((document.last as TextNode).text.toPlainText(), equals('')); + + // Ensure the caret was placed at the beginning of the newly inserted paragraph. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + testWidgetsOnMobile('when all nodes are non-deletable', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: '1', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '2', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '3', metadata: { + NodeMetadata.isDeletable: false, + }), + ], + ), + ) + .pump(); + + // Select the first horizontal rule. + await tester.tapAtDocumentPosition( + const DocumentPosition( + nodeId: "1", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ); + + // Select all content. + if (CurrentPlatform.isApple) { + await tester.pressCmdA(); + } else { + await tester.pressCtlA(); + } + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. ~\n~\n~', + selection: TextSelection(baseOffset: 0, extentOffset: 7), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: '. ~\n~\n~', + deletedRange: TextSelection(baseOffset: 0, extentOffset: 7), + selection: TextSelection.collapsed(offset: 0), + composing: TextRange.empty, + ), + ], getter: imeClientGetter); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure nothing was deleted. + expect(document.nodeCount, equals(3)); + expect(document.getNodeAt(0), isA()); + expect(document.getNodeAt(1), isA()); + expect(document.getNodeAt(2), isA()); + + // Ensure the selection was kept. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '1', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '3', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + }); + + testWidgetsOnDesktop('when all nodes in selection are non-deletable and document contains deletable nodes', + (tester) async { + final testContext = await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode(id: '1', text: AttributedText()), + HorizontalRuleNode(id: '2', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '3', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: '4', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode(id: '5', text: AttributedText()), + ], + ), + ) + .pump(); + + // Select the first horizontal rule. + await tester.tapAtDocumentPosition( + const DocumentPosition( + nodeId: "2", + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + ); + + // Select all non-deletable nodes. + testContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: '2', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ) + ]); + await tester.pump(); + + // Simulate the user pressing backspace. The IME first generates a + // selection change and then a deletion. Each block node is represented by a "~" + // in the IME. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. ~\n~\n~', + selection: TextSelection(baseOffset: 0, extentOffset: 7), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: '. ~\n~\n~', + deletedRange: TextSelection(baseOffset: 0, extentOffset: 7), + selection: TextSelection.collapsed(offset: 0), + composing: TextRange.empty, + ), + ], getter: imeClientGetter); + + final document = SuperEditorInspector.findDocument()!; + + // Ensure nothing was deleted. + expect(document.nodeCount, equals(5)); + expect(document.getNodeAt(0), isA()); + expect(document.getNodeAt(1), isA()); + expect(document.getNodeAt(2), isA()); + expect(document.getNodeAt(3), isA()); + expect(document.getNodeAt(4), isA()); + + // Ensure the selection was kept. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: '2', + nodePosition: UpstreamDownstreamNodePosition.upstream(), + ), + extent: DocumentPosition( + nodeId: '4', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + ); + }); + }); + }); + }); +} + +/// Pumps a widget tree with a paragraph followed by a non-deletable horizontal rule. +Future _pumpParagraphThenHrTestApp(WidgetTester tester) async { + return await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Paragraph 1'), + ), + HorizontalRuleNode(id: 'hr', metadata: { + NodeMetadata.isDeletable: false, + }), + ], + ), + ) + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); +} + +/// Pumps a widget tree with a non-deletable horizontal rule followed by a paragraph. +Future _pumpHrThenParagraphTestApp(WidgetTester tester) async { + return await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + HorizontalRuleNode(id: 'hr', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode( + id: '1', + text: AttributedText('Paragraph 1'), + ), + ], + ), + ) + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); +} + +/// Pumps a widget tree with containing: +/// - Paragraph +/// - Horizontal rule (non-selectable, non-deletable) +/// - Paragraph +Future _pumpParagraphThenHrThenParagraphTestApp(WidgetTester tester) async { + return await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Paragraph 1'), + ), + HorizontalRuleNode(id: 'hr', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode( + id: '2', + text: AttributedText('Paragraph 2'), + ), + ], + ), + ) + .withAddedComponents([const _UnselectableHrComponentBuilder()]) + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); +} + +/// Pumps a widget tree containing: +/// +/// - Paragraph. +/// - Horizontal rule. +/// - Horizontal rule. +/// - Paragraph. +/// - Horizontal rule. +/// - Paragraph. +Future _pumpMultipleDeletableAndUndeletableNodesTestApp(WidgetTester tester) async { + return await tester + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText('Paragraph 1'), + ), + HorizontalRuleNode(id: 'hr1', metadata: { + NodeMetadata.isDeletable: false, + }), + HorizontalRuleNode(id: 'hr2', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode( + id: '2', + text: AttributedText('Paragraph 2'), + ), + HorizontalRuleNode(id: 'hr3', metadata: { + NodeMetadata.isDeletable: false, + }), + ParagraphNode( + id: '3', + text: AttributedText('Paragraph 3'), + ), + ], + ), + ) + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); +} + +/// SuperEditor [ComponentBuilder] that builds a horizontal rule that is +/// not selectable. +class _UnselectableHrComponentBuilder implements ComponentBuilder { + const _UnselectableHrComponentBuilder(); + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + // This builder can work with the standard horizontal rule view model, so + // we'll defer to the standard horizontal rule builder. + return null; + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + if (componentViewModel is! HorizontalRuleComponentViewModel) { + return null; + } + + return _UnselectableHorizontalRuleComponent( + componentKey: componentContext.componentKey, + ); + } +} + +class _UnselectableHorizontalRuleComponent extends StatelessWidget { + const _UnselectableHorizontalRuleComponent({ + Key? key, + required this.componentKey, + }) : super(key: key); + + final GlobalKey componentKey; + + @override + Widget build(BuildContext context) { + return BoxComponent( + key: componentKey, + isVisuallySelectable: false, + child: const Divider( + color: Color(0xFF000000), + thickness: 1.0, + ), + ); + } +} diff --git a/super_editor/test/super_editor/test_documents.dart b/super_editor/test/super_editor/test_documents.dart index 1436ee1bf4..bed35af4fb 100644 --- a/super_editor/test/super_editor/test_documents.dart +++ b/super_editor/test/super_editor/test_documents.dart @@ -1,16 +1,17 @@ +import 'package:flutter/material.dart'; import 'package:super_editor/super_editor.dart'; MutableDocument paragraphThenHrThenParagraphDoc() => MutableDocument( nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "This is the first node in a document.")), + ParagraphNode(id: "1", text: AttributedText("This is the first node in a document.")), HorizontalRuleNode(id: "2"), - ParagraphNode(id: "3", text: AttributedText(text: "This is the third node in a document.")), + ParagraphNode(id: "3", text: AttributedText("This is the third node in a document.")), ], ); MutableDocument paragraphThenHrDoc() => MutableDocument( nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "Paragraph 1")), + ParagraphNode(id: "1", text: AttributedText("Paragraph 1")), HorizontalRuleNode(id: "2"), ], ); @@ -18,31 +19,57 @@ MutableDocument paragraphThenHrDoc() => MutableDocument( MutableDocument hrThenParagraphDoc() => MutableDocument( nodes: [ HorizontalRuleNode(id: "1"), - ParagraphNode(id: "2", text: AttributedText(text: "Paragraph 1")), + ParagraphNode(id: "2", text: AttributedText("Paragraph 1")), ], ); -MutableDocument singleParagraphEmptyDoc() => MutableDocument( +MutableDocument singleParagraphEmptyDoc() => MutableDocument.empty("1"); + +MutableDocument singleParagraphDoc() => MutableDocument( nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "")), + ParagraphNode( + id: "1", + text: AttributedText( + // String length is 445 + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + ), + ), ], ); -MutableDocument twoParagraphEmptyDoc() => MutableDocument( +MutableDocument singleParagraphDocShortText() => MutableDocument( nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "")), - ParagraphNode(id: "2", text: AttributedText(text: "")), + ParagraphNode( + id: "1", + text: AttributedText( + // End position is 37. + "This is the first node in a document.", + ), + ), ], ); -MutableDocument singleParagraphDoc() => MutableDocument( +MutableDocument singleParagraphWithLinkDoc() => MutableDocument( nodes: [ ParagraphNode( id: "1", text: AttributedText( - // String length is 445 - text: - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + // "link" is 26->30 + "This paragraph includes a link that the user can tap", + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: LinkAttribution("https://fake.url"), + offset: 26, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: LinkAttribution("https://fake.url"), + offset: 30, + markerType: SpanMarkerType.end, + ), + ], + ), ), ), ], @@ -54,33 +81,316 @@ MutableDocument singleBlockDoc() => MutableDocument( ], ); +MutableDocument singleParagraphWithPartialColor() => MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'abcdefghij', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 5, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 9, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ) + ], + ); + +MutableDocument singleParagraphFullColor() => MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText( + 'abcdefghij', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 9, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ) + ], + ); + +MutableDocument singleParagraphDocAllBold() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This is the first node in a document.", + AttributedSpans(attributions: [ + const SpanMarker( + attribution: boldAttribution, + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 36, + markerType: SpanMarkerType.end, + ), + ]), + ), + ), + ], + ); + +MutableDocument twoParagraphEmptyDoc() => MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText()), + ParagraphNode(id: "2", text: AttributedText()), + ], + ); + +MutableDocument twoParagraphDoc() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This is the first node in a document.", + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + "This is the second node in a document.", + ), + ), + ], + ); + +MutableDocument twoParagraphDocAllBold() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This is the first node in a document.", + AttributedSpans(attributions: [ + const SpanMarker( + attribution: boldAttribution, + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 36, + markerType: SpanMarkerType.end, + ), + ]), + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + "This is the second node in a document.", + AttributedSpans(attributions: [ + const SpanMarker( + attribution: boldAttribution, + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 37, + markerType: SpanMarkerType.end, + ), + ]), + ), + ), + ], + ); + +MutableDocument threeParagraphDoc() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This is the first node in a document.", + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + "This is the second node in a document.", + ), + ), + ParagraphNode( + id: "3", + text: AttributedText( + "This is the third node in a document.", + ), + ), + ], + ); + MutableDocument longTextDoc() => MutableDocument( nodes: [ ParagraphNode( id: "1", text: AttributedText( - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', ), ), ParagraphNode( id: "2", text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), ), ParagraphNode( id: "3", text: AttributedText( - text: - 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', ), ), ParagraphNode( id: "4", text: AttributedText( - text: - 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ], + ); + +MutableDocument longDoc() => MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "3", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "4", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ParagraphNode( + id: "5", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "6", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "7", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "8", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ParagraphNode( + id: "9", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "10", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "11", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "12", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ParagraphNode( + id: "13", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "14", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "15", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "16", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', + ), + ), + ParagraphNode( + id: "17", + text: AttributedText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + ), + ), + ParagraphNode( + id: "18", + text: AttributedText( + 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.', + ), + ), + ParagraphNode( + id: "19", + text: AttributedText( + 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', + ), + ), + ParagraphNode( + id: "20", + text: AttributedText( + 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', ), ), ], diff --git a/super_editor/test/src/default_editor/attributions_test.dart b/super_editor/test/super_editor/text_entry/attributions_test.dart similarity index 60% rename from super_editor/test/src/default_editor/attributions_test.dart rename to super_editor/test/super_editor/text_entry/attributions_test.dart index 3fbb6c9419..0e42369e6e 100644 --- a/super_editor/test/src/default_editor/attributions_test.dart +++ b/super_editor/test/super_editor/text_entry/attributions_test.dart @@ -6,42 +6,38 @@ void main() { group('Default editor attributions', () { group('links', () { test('different link attributions cannot overlap', () { - final text = AttributedText( - text: 'one two three', - ); + final text = AttributedText('one two three'); // Add link across "one two" text.addAttribution( - LinkAttribution(url: Uri.parse('https://flutter.dev')), - const SpanRange(start: 0, end: 6), + const LinkAttribution('https://flutter.dev'), + const SpanRange(0, 6), ); // Try to add a different link across "two three" and expect // an exception expect(() { text.addAttribution( - LinkAttribution(url: Uri.parse('https://pub.dev')), - const SpanRange(start: 4, end: 12), + const LinkAttribution('https://pub.dev'), + const SpanRange(4, 12), ); }, throwsA(isA())); }); test('identical link attributions can overlap', () { - final text = AttributedText( - text: 'one two three', - ); + final text = AttributedText('one two three'); - final linkAttribution = LinkAttribution(url: Uri.parse('https://flutter.dev')); + const linkAttribution = LinkAttribution('https://flutter.dev'); // Add link across "one two" text.addAttribution( linkAttribution, - const SpanRange(start: 0, end: 6), + const SpanRange(0, 6), ); text.addAttribution( - LinkAttribution(url: Uri.parse('https://flutter.dev')), - const SpanRange(start: 4, end: 12), + const LinkAttribution('https://flutter.dev'), + const SpanRange(4, 12), ); expect(text.spans.hasAttributionsWithin(attributions: {linkAttribution}, start: 0, end: 12), true); diff --git a/super_editor/test/super_editor/text_entry/dash_conversion_test.dart b/super_editor/test/super_editor/text_entry/dash_conversion_test.dart new file mode 100644 index 0000000000..cdfce68935 --- /dev/null +++ b/super_editor/test/super_editor/text_entry/dash_conversion_test.dart @@ -0,0 +1,544 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/default_editor/default_document_editor.dart'; + +import 'package:super_editor/src/default_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/tasks.dart'; +import 'package:super_editor/src/infrastructure/strings.dart'; +import 'package:super_editor/src/infrastructure/text_input.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_runners.dart'; + +void main() { + group('SuperEditor dash conversion', () { + group('converts two dashes to an em dash', () { + testAllInputsOnAllPlatforms('at the beginning of an empty paragraph', ( + tester, { + required TextInputSource inputSource, + }) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(inputSource) + .pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph('1', 0); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect(SuperEditorInspector.findTextInComponent('1').toPlainText(), '-'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(context.document.nodeCount, 1); + expect(SuperEditorInspector.findTextInComponent('1').toPlainText(), SpecialCharacters.emDash); + + // Type some arbitrary text. + await tester.typeTextAdaptive(' is an em-dash'); + + // Ensure the text was inserted. + expect(SuperEditorInspector.findTextInComponent('1').toPlainText(), '— is an em-dash'); + }); + + testAllInputsOnAllPlatforms('at the beginning of a non-empty paragraph', ( + tester, { + required TextInputSource inputSource, + }) async { + final context = await tester // + .createDocument() + .fromMarkdown('was inserted') + .withInputSource(inputSource) + .pump(); + + final nodeId = context.document.first.id; + + // Place the caret at the beginning of a paragraph. + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), '-was inserted'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(context.document.nodeCount, 1); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), '—was inserted'); + + // Type some arbitrary text. + await tester.typeTextAdaptive('(em-dash) '); + + // Ensure the text was inserted. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), '—(em-dash) was inserted'); + }); + + testAllInputsOnAllPlatforms('at the middle of a paragraph', ( + tester, { + required TextInputSource inputSource, + }) async { + final context = await tester // + .createDocument() + .fromMarkdown('Inserting with a reaction') + .withInputSource(inputSource) + .pump(); + + final nodeId = context.document.first.id; + + // Place the caret at "|with". + await tester.placeCaretInParagraph(nodeId, 10); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'Inserting -with a reaction'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(context.document.nodeCount, 1); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'Inserting —with a reaction'); + + // Type some arbitrary text. + await tester.typeTextAdaptive(' typing two dashes '); + + // Type three dashes. The first two should be converted to an em-dash + // and the second should be inserted as is. + await tester.typeTextAdaptive('---'); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), + 'Inserting — typing two dashes —-with a reaction'); + + // Type another dash. The previously inserted dash and the current one + // should be converted to an em-dash. + await tester.typeTextAdaptive('-'); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), + 'Inserting — typing two dashes ——with a reaction'); + }); + + testAllInputsOnAllPlatforms('at the end of a paragraph', ( + tester, { + required TextInputSource inputSource, + }) async { + final context = await tester // + .createDocument() + .fromMarkdown('Inserting') + .withInputSource(inputSource) + .pump(); + + final nodeId = context.document.first.id; + + // Place the caret at the end of the paragraph and add a space + // just to separate the first word from the dash. + // Converting from markdown removes the trailing spaces. + await tester.placeCaretInParagraph(nodeId, 9); + await tester.typeTextAdaptive(' '); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'Inserting -'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(context.document.nodeCount, 1); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'Inserting —'); + + // Type some arbitrary text. + await tester.typeTextAdaptive(' by typing two dashes'); + + // Ensure the text was inserted. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'Inserting — by typing two dashes'); + }); + + testAllInputsOnAllPlatforms('at the beginning of an empty list item', ( + tester, { + required TextInputSource inputSource, + }) async { + final context = await tester // + .createDocument() + .fromMarkdown('* ') + .withInputSource(inputSource) + .pump(); + + final nodeId = context.document.first.id; + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), '-'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(context.document.nodeCount, 1); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), '—'); + + // Type some arbitrary text. + await tester.typeTextAdaptive(' is an em-dash'); + + // Ensure the text was inserted. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), '— is an em-dash'); + }); + + testAllInputsOnAllPlatforms('at the beginning of a non-empty list item', ( + tester, { + required TextInputSource inputSource, + }) async { + final context = await tester // + .createDocument() + .fromMarkdown('* was inserted') + .withInputSource(inputSource) + .pump(); + + final nodeId = context.document.first.id; + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), '-was inserted'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(context.document.nodeCount, 1); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), '—was inserted'); + + // Type a third dash. + await tester.typeTextAdaptive('-'); + + // Ensure a dash was inserted and no other nodes were added. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), '—-was inserted'); + expect(context.document.nodeCount, 1); + + // Type some arbitrary text. + await tester.typeTextAdaptive('(em-dash) '); + + // Ensure the text was inserted. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), '—-(em-dash) was inserted'); + }); + + testAllInputsOnAllPlatforms('at the middle of a list item', ( + tester, { + required TextInputSource inputSource, + }) async { + final context = await tester // + .createDocument() + .fromMarkdown('* Inserting with a reaction') + .withInputSource(inputSource) + .pump(); + + final nodeId = context.document.first.id; + + // Place the caret at "|with". + await tester.placeCaretInParagraph(nodeId, 10); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'Inserting -with a reaction'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(context.document.nodeCount, 1); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'Inserting —with a reaction'); + + // Type some arbitrary text. + await tester.typeTextAdaptive(' typing two dashes '); + + // Type three dashes. The first two should be converted to an em-dash + // and the second should be inserted as is. + await tester.typeTextAdaptive('---'); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), + 'Inserting — typing two dashes —-with a reaction'); + + // Type another dash. The previously inserted dash and the current one + // should be converted to an em-dash. + await tester.typeTextAdaptive('-'); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), + 'Inserting — typing two dashes ——with a reaction'); + }); + + testAllInputsOnAllPlatforms('at the end of a list item', ( + tester, { + required TextInputSource inputSource, + }) async { + final context = await tester // + .createDocument() + .fromMarkdown('* Inserting') + .withInputSource(inputSource) + .pump(); + + final nodeId = context.document.first.id; + + // Place the caret at the end of the list item and add a space + // just to separate the first word from the dash. + // Converting from markdown removes the trailing spaces. + await tester.placeCaretInParagraph(nodeId, 9); + await tester.typeTextAdaptive(' '); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'Inserting -'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(context.document.nodeCount, 1); + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'Inserting —'); + + // Type some arbitrary text. + await tester.typeTextAdaptive(' by typing two dashes'); + + // Ensure the text was inserted. + expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), 'Inserting — by typing two dashes'); + }); + + testAllInputsOnAllPlatforms('at the beginning of an empty task', ( + tester, { + required TextInputSource inputSource, + }) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText(""), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + final nodeId = document.first.id; + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph(nodeId, 0); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect((document.first as TaskNode).text.toPlainText(), '-'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(document.nodeCount, 1); + expect((document.first as TaskNode).text.toPlainText(), '—'); + + // Type some arbitrary text. + await tester.typeTextAdaptive(' is an em-dash'); + + // Ensure the text was inserted. + expect((document.first as TaskNode).text.toPlainText(), '— is an em-dash'); + }); + + testAllInputsOnAllPlatforms('at the beginning of a non-empty task', ( + tester, { + required TextInputSource inputSource, + }) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("was inserted"), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph(document.first.id, 0); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect((document.first as TaskNode).text.toPlainText(), '-was inserted'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(document.nodeCount, 1); + expect((document.first as TaskNode).text.toPlainText(), '—was inserted'); + + // Type a third dash. + await tester.typeTextAdaptive('-'); + + // Ensure a dash was inserted and no other nodes were added. + expect((document.first as TaskNode).text.toPlainText(), '—-was inserted'); + expect(document.nodeCount, 1); + + // Type some arbitrary text. + await tester.typeTextAdaptive('(em-dash) '); + + // Ensure the text was inserted. + expect((document.first as TaskNode).text.toPlainText(), '—-(em-dash) was inserted'); + }); + + testAllInputsOnAllPlatforms('at the middle of a task', ( + tester, { + required TextInputSource inputSource, + }) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("Inserting with a reaction"), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + // Place the caret at "|with". + await tester.placeCaretInParagraph(document.first.id, 10); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect((document.first as TaskNode).text.toPlainText(), 'Inserting -with a reaction'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(document.nodeCount, 1); + expect((document.first as TaskNode).text.toPlainText(), 'Inserting —with a reaction'); + + // Type some arbitrary text. + await tester.typeTextAdaptive(' typing two dashes '); + + // Type three dashes. The first two should be converted to an em-dash + // and the second should be inserted as is. + await tester.typeTextAdaptive('---'); + expect((document.first as TaskNode).text.toPlainText(), 'Inserting — typing two dashes —-with a reaction'); + + // Type another dash. The previously inserted dash and the current one + // should be converted to an em-dash. + await tester.typeTextAdaptive('-'); + expect((document.first as TaskNode).text.toPlainText(), 'Inserting — typing two dashes ——with a reaction'); + }); + + testAllInputsOnAllPlatforms('at the end of a task', ( + tester, { + required TextInputSource inputSource, + }) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("Inserting"), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + // Place the caret at the end of the task and add a space + // just to separate the first word from the dash. + // Converting from markdown removes the trailing spaces. + await tester.placeCaretInParagraph(document.first.id, 9); + await tester.typeTextAdaptive(' '); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion happened. + expect((document.first as TaskNode).text.toPlainText(), 'Inserting -'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect(document.nodeCount, 1); + expect((document.first as TaskNode).text.toPlainText(), 'Inserting —'); + + // Type some arbitrary text. + await tester.typeTextAdaptive(' by typing two dashes'); + + // Ensure the text was inserted. + expect((document.first as TaskNode).text.toPlainText(), 'Inserting — by typing two dashes'); + }); + }); + }); +} diff --git a/super_editor/test/super_editor/text_entry/inline_widgets_test.dart b/super_editor/test/super_editor/text_entry/inline_widgets_test.dart new file mode 100644 index 0000000000..4eb0733058 --- /dev/null +++ b/super_editor/test/super_editor/text_entry/inline_widgets_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_inspector.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group("Super Editor > inline widgets >", () { + testWidgetsOnArbitraryDesktop("can insert an inline widget in the middle of typing", (tester) async { + final editor = await _pumpScaffold(tester); + + await tester.typeImeText("Hello "); + editor.execute([ + const InsertInlinePlaceholderAtCaretRequest(_TestPlaceholder()), + ]); + await tester.typeImeText(" inline widgets"); + + expect( + SuperEditorInspector.findTextInComponent("1"), + AttributedText( + "Hello inline widgets", + null, + { + 6: const _TestPlaceholder(), + }, + ), + ); + }); + + testWidgetsOnArbitraryDesktop("can backspace delete an inline placeholder", (tester) async { + final editor = await _pumpScaffold(tester); + + // Insert text with an inline placeholder. + await tester.typeImeText("Hello "); + editor.execute([ + const InsertInlinePlaceholderAtCaretRequest(_TestPlaceholder()), + ]); + await tester.pump(); + + // Ensure we inserted the placeholder. + expect( + SuperEditorInspector.findTextInComponent("1"), + AttributedText( + "Hello ", + null, + { + 6: const _TestPlaceholder(), + }, + ), + ); + + // Backspace to delete the placeholder. + await tester.pressBackspace(); + + // Ensure the inline placeholder was deleted. + expect( + SuperEditorInspector.findTextInComponent("1"), + AttributedText( + "Hello ", + null, + {}, + ), + ); + }); + + testWidgetsOnArbitraryDesktop("can select text and apply a style change without losing placeholder", + (tester) async { + final editor = await _pumpScaffold(tester); + + // Insert text with an inline placeholder in the middle. + await tester.typeImeText("Hello "); + editor.execute([ + const InsertInlinePlaceholderAtCaretRequest(_TestPlaceholder()), + ]); + await tester.typeImeText(" inline widgets"); + + // Select text and also the inline placeholder. + // TODO: Create tester extension to drag and select text on desktop + editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 14), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + await tester.pump(); + + // Apply bold to the text. + await tester.pressCmdB(); + + // Ensure the inline placeholder is still there. + expect( + SuperEditorInspector.findTextInComponent("1"), + AttributedText( + "Hello inline widgets", + AttributedSpans( + attributions: const [ + SpanMarker(attribution: boldAttribution, offset: 6, markerType: SpanMarkerType.start), + SpanMarker(attribution: boldAttribution, offset: 13, markerType: SpanMarkerType.end), + ], + ), + { + 6: const _TestPlaceholder(), + }, + ), + ); + + // Un-apply bold to the text. + await tester.pressCmdB(); + + // Ensure the inline placeholder is still there. + expect( + SuperEditorInspector.findTextInComponent("1"), + AttributedText( + "Hello inline widgets", + null, + { + 6: const _TestPlaceholder(), + }, + ), + ); + }); + + testWidgetsOnArbitraryDesktop("can insert an inline widget with attributions in an empty paragraph", + (tester) async { + final editor = await _pumpScaffold(tester); + + // Insert an empty text containing a placeholder with an attribution around it. + editor.execute([ + InsertStyledTextAtCaretRequest( + AttributedText( + '', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: _emojiAttribution, + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: _emojiAttribution, + offset: 0, + markerType: SpanMarkerType.end, + ), + ], + ), + { + 0: const _TestPlaceholder(), + }), + ), + ]); + await tester.pump(); + + // Ensure we can type after the inline placeholder was added. + await tester.typeImeText('hello'); + + // Ensure the inline widget was kept, the text was inserted, and the attribution + // was not applied to the inserted characters. + expect( + SuperEditorInspector.findTextInComponent('1'), + AttributedText( + 'hello', + AttributedSpans( + attributions: const [ + SpanMarker(attribution: _emojiAttribution, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: _emojiAttribution, offset: 0, markerType: SpanMarkerType.end), + ], + ), + { + 0: const _TestPlaceholder(), + }, + ), + ); + }); + }); +} + +Future _pumpScaffold(WidgetTester tester) async { + final context = await tester + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .useStylesheet(defaultStylesheet.copyWith( + inlineWidgetBuilders: [_buildInlineTestWidget], + )) + .autoFocus(true) + .pump(); + + return context.editor; +} + +Widget? _buildInlineTestWidget(BuildContext context, TextStyle style, Object placeholder) { + if (placeholder is! _TestPlaceholder) { + return null; + } + + return Container( + width: 16, + height: 16, + color: Colors.black, + ); +} + +class _TestPlaceholder { + const _TestPlaceholder(); +} + +const _emojiAttribution = NamedAttribution('emoji'); diff --git a/super_editor/test/super_editor/text_entry/links_test.dart b/super_editor/test/super_editor/text_entry/links_test.dart new file mode 100644 index 0000000000..01e0e70e0e --- /dev/null +++ b/super_editor/test/super_editor/text_entry/links_test.dart @@ -0,0 +1,2240 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperEditor link editing >', () { + group('recognizes a URL with https and www and converts it to a link', () { + testWidgetsOnAllPlatforms('when typing', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Type a URL. It shouldn't linkify until we add a space. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "https://www.google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(0, text.length - 1), + ), + isEmpty, + ); + + // Type a space, to cause a linkify reaction. + await tester.typeImeText(" "); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "https://www.google.com "); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 0, + end: text.length - 2, + ), + }, + ); + }); + + testWidgetsOnAllPlatforms('when pressing ENTER at the end of a paragraph', (tester) async { + final textContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "https://www.google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(0, text.length - 1), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new paragraph. + await tester.pressEnter(); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 0, + end: text.length - 1, + ), + }, + ); + + // Ensure we added a new empty paragraph. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ""); + }); + + testWidgetsOnAllPlatforms('when pressing ENTER at the middle of a paragraph', (tester) async { + final textContext = await tester // + .createDocument() + .fromMarkdown('Before link after link') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = textContext.document.first.id; + + // Place the caret at "Before link |after link". + await tester.placeCaretInParagraph(nodeId, 12); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.comafter link"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: const SpanRange(12, 34), + ), + isEmpty, + ); + + // Press enter to linkify the URL and split the paragraph. + await tester.pressEnter(); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 12, + end: text.length - 1, + ), + }, + ); + + // Ensure we split the paragraph. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), "after link"); + }); + + testWidgetsOnAndroid('when pressing the newline button on the software keyboard at the end of a paragraph', + (tester) async { + final textContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "https://www.google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(0, text.length - 1), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new paragraph. + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText('\n'); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 0, + end: text.length - 1, + ), + }, + ); + + // Ensure we added a new empty paragraph. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ""); + }); + + testWidgetsOnAndroid('when pressing the newline button on the software keyboard at the middle of a paragraph', + (tester) async { + final textContext = await tester // + .createDocument() + .fromMarkdown('Before link after link') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = textContext.document.first.id; + + // Place the caret at "Before link |after link". + await tester.placeCaretInParagraph(nodeId, 12); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.comafter link"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: const SpanRange(12, 34), + ), + isEmpty, + ); + + // Press enter to linkify the URL and split the paragraph. + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText('\n'); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 12, + end: text.length - 1, + ), + }, + ); + + // Ensure we split the paragraph. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), "after link"); + }); + + testWidgetsOnIos('when pressing the newline button on the software keyboard at the end of a paragraph', + (tester) async { + final textContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "https://www.google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(0, text.length - 1), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new paragraph. + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + await tester.pump(); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 0, + end: text.length - 1, + ), + }, + ); + + // Ensure we added a new empty line. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), ""); + }); + + testWidgetsOnIos('when pressing the newline button on the software keyboard at the middle of a paragraph', + (tester) async { + final textContext = await tester // + .createDocument() + .fromMarkdown('Before link after link') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = textContext.document.first.id; + + // Place the caret at "Before link |after link". + await tester.placeCaretInParagraph(nodeId, 12); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.comafter link"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: const SpanRange(12, 34), + ), + isEmpty, + ); + + // Press enter to linkify the URL and split the paragraph. + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + await tester.pump(); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 12, + end: text.length - 1, + ), + }, + ); + + // Ensure we split the paragraph. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ParagraphNode).text.toPlainText(), "after link"); + }); + + testWidgetsOnAllPlatforms('when pressing ENTER at the end of a list item', (tester) async { + final textContext = await tester // + .createDocument() + .fromMarkdown('* Item') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = textContext.document.first.id; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(nodeId, 4); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText(" https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Item https://www.google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(5, text.length - 1), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new list item. + await tester.pressEnter(); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Item https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 5, + end: text.length - 1, + ), + }, + ); + + // Ensure we added a new empty list item. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ListItemNode).text.toPlainText(), ""); + }); + + testWidgetsOnAllPlatforms('when pressing ENTER at the middle of a list item', (tester) async { + final textContext = await tester // + .createDocument() + .fromMarkdown('* Before link after link') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = textContext.document.first.id; + + // Place the caret at "Before link |after link". + await tester.placeCaretInParagraph(nodeId, 12); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.comafter link"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: const SpanRange(12, 34), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new list item. + await tester.pressEnter(); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 12, + end: text.length - 1, + ), + }, + ); + + // Ensure we split the list item. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ListItemNode).text.toPlainText(), "after link"); + }); + + testWidgetsOnAndroid('when pressing the newline button on the software keyboard at the end of a list item', + (tester) async { + final textContext = await tester // + .createDocument() + .fromMarkdown('* Item') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = textContext.document.first.id; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(nodeId, 4); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText(" https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Item https://www.google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(5, text.length - 1), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new list item. + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText('\n'); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Item https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 5, + end: text.length - 1, + ), + }, + ); + + // Ensure we added a new empty list item. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ListItemNode).text.toPlainText(), ""); + }); + + testWidgetsOnAndroid('when pressing the newline button on the software keyboard at the middle of a list item', + (tester) async { + final textContext = await tester // + .createDocument() + .fromMarkdown('* Before link after link') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = textContext.document.first.id; + + // Place the caret at "Before link |after link". + await tester.placeCaretInParagraph(nodeId, 12); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.comafter link"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: const SpanRange(12, 34), + ), + isEmpty, + ); + + // Press enter to linkify the URL and split the list item. + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText('\n'); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 12, + end: text.length - 1, + ), + }, + ); + + // Ensure we split the list item. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ListItemNode).text.toPlainText(), "after link"); + }); + + testWidgetsOnIos('when pressing the newline button on the software keyboard at the end of a list item', + (tester) async { + final textContext = await tester // + .createDocument() + .fromMarkdown('* Item') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = textContext.document.first.id; + + // Place the caret at the end of the list item. + await tester.placeCaretInParagraph(nodeId, 4); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText(" https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Item https://www.google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(5, text.length - 1), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new list item. + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + await tester.pump(); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Item https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 5, + end: text.length - 1, + ), + }, + ); + + // Ensure we added a new empty list item. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ListItemNode).text.toPlainText(), ""); + }); + + testWidgetsOnIos('when pressing the newline button on the software keyboard at the middle of a list item', + (tester) async { + final textContext = await tester // + .createDocument() + .fromMarkdown('* Before link after link') + .withInputSource(TextInputSource.ime) + .pump(); + + final nodeId = textContext.document.first.id; + + // Place the caret at "Before link |after link". + await tester.placeCaretInParagraph(nodeId, 12); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.comafter link"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: const SpanRange(12, 34), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new list item. + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + await tester.pump(); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "Before link https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 12, + end: text.length - 1, + ), + }, + ); + + // Ensure we split the list item. + expect(textContext.document.nodeCount, 2); + expect(textContext.document.getNodeAt(1)!, isA()); + expect((textContext.document.getNodeAt(1)! as ListItemNode).text.toPlainText(), "after link"); + }); + + testWidgetsOnAllPlatforms('when pressing ENTER at the end of a task', (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task "), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + // Place the caret at the end of the task. + await tester.placeCaretInParagraph("1", 15); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = document.first.asTask.text; + + expect(text.toPlainText(), "This is a task https://www.google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(15, text.length - 1), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new task. + await tester.pressEnter(); + + // Ensure it's linkified. + text = document.first.asTask.text; + + expect(text.toPlainText(), "This is a task https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 15, + end: text.length - 1, + ), + }, + ); + + // Ensure we added a new empty task. + expect(document.nodeCount, 2); + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as TaskNode).text.toPlainText(), ""); + }); + + testWidgetsOnAllPlatforms('when pressing ENTER at the middle of a task', (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("Before link after link"), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + // Place the caret at "Before link |after link". + await tester.placeCaretInParagraph("1", 12); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = document.first.asTask.text; + + expect(text.toPlainText(), "Before link https://www.google.comafter link"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: const SpanRange(12, 34), + ), + isEmpty, + ); + + // Press enter to linkify the URL and split the task. + await tester.pressEnter(); + + // Ensure it's linkified. + text = document.first.asTask.text; + + expect(text.toPlainText(), "Before link https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 12, + end: text.length - 1, + ), + }, + ); + + // Ensure we split the task + expect(document.nodeCount, 2); + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as TaskNode).text.toPlainText(), "after link"); + }); + + testWidgetsOnAndroid('when pressing the newline button on the software keyboard at the end of a task', + (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task "), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + // Place the caret at the end of the task. + await tester.placeCaretInParagraph("1", 15); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = document.first.asTask.text; + + expect(text.toPlainText(), "This is a task https://www.google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(15, text.length - 1), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new task. + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText('\n'); + + // Ensure it's linkified. + text = document.first.asTask.text; + + expect(text.toPlainText(), "This is a task https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 15, + end: text.length - 1, + ), + }, + ); + + // Ensure we added a new empty task. + expect(document.nodeCount, 2); + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as TaskNode).text.toPlainText(), ""); + }); + + testWidgetsOnAndroid('when pressing the newline button on the software keyboard at the middle of a task', + (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("Before link after link"), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + // Place the caret at "Before link |after link". + await tester.placeCaretInParagraph("1", 12); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = document.first.asTask.text; + + expect(text.toPlainText(), "Before link https://www.google.comafter link"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: const SpanRange(12, 34), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new task. + // On Android, pressing ENTER generates a "\n" insertion. + await tester.typeImeText('\n'); + + // Ensure it's linkified. + text = document.first.asTask.text; + + expect(text.toPlainText(), "Before link https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 12, + end: text.length - 1, + ), + }, + ); + + // Ensure we split the task. + expect(document.nodeCount, 2); + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as TaskNode).text.toPlainText(), "after link"); + }); + + testWidgetsOnIos('when pressing the newline button on the software keyboard at the end of a task', + (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("This is a task "), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + // Place the caret at the end of the task. + await tester.placeCaretInParagraph("1", 15); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = document.first.asTask.text; + + expect(text.toPlainText(), "This is a task https://www.google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(15, text.length - 1), + ), + isEmpty, + ); + + // Press enter to linkify the URL and insert a new task. + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + await tester.pump(); + + // Ensure it's linkified. + text = document.first.asTask.text; + + expect(text.toPlainText(), "This is a task https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 15, + end: text.length - 1, + ), + }, + ); + + // Ensure we added a new empty task. + expect(document.nodeCount, 2); + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as TaskNode).text.toPlainText(), ""); + }); + + testWidgetsOnIos('when pressing the newline button on the software keyboard at the middle of a task', + (tester) async { + final document = MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText("Before link after link"), isComplete: false), + ], + ); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + ), + ), + ), + ); + + // Place the caret at "Before link |after link". + await tester.placeCaretInParagraph("1", 12); + + // Type a URL. It shouldn't linkify until the user presses ENTER. + await tester.typeImeText("https://www.google.com"); + + // Ensure it's not linkified yet. + var text = document.first.asTask.text; + + expect(text.toPlainText(), "Before link https://www.google.comafter link"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: const SpanRange(12, 34), + ), + isEmpty, + ); + + // Press enter to linkify the URL and split the task. + // On iOS, pressing ENTER generates a newline action. + await tester.testTextInput.receiveAction(TextInputAction.newline); + await tester.pump(); + + // Ensure it's linkified. + text = document.first.asTask.text; + + expect(text.toPlainText(), "Before link https://www.google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 12, + end: text.length - 1, + ), + }, + ); + + // Ensure we split the task. + expect(document.nodeCount, 2); + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as TaskNode).text.toPlainText(), "after link"); + }); + }); + + group('URL protocol >', () { + testWidgetsOnAllPlatforms('inserts https scheme if it is missing', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Type a URL. It shouldn't linkify until we add a space. + await tester.typeImeText("www.google.com"); + + // Type a space, to cause a linkify reaction. + await tester.typeImeText(" "); + + // Ensure it's linkified with a URL schema. + var text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "www.google.com "); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 0, + end: 13, + ), + }, + ); + }); + + testWidgetsOnAllPlatforms('recognizes an app URL', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Type an app URL. + await tester.typeImeText("obsidian://open?vault=MyVault"); + + // Type a space, to cause a linkify reaction. + await tester.typeImeText(" "); + + // Ensure it's linkified with a URL schema. + var text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "obsidian://open?vault=MyVault "); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("obsidian://open?vault=MyVault")), + start: 0, + end: 28, + ), + }, + ); + }); + + testWidgetsOnAllPlatforms('recognizes a URL without https and www and converts it to a link', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Type a URL without the www. It shouldn't linkify until we add a space. + await tester.typeImeText("google.com"); + + // Ensure it's not linkified yet. + var text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "google.com"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => true, + range: SpanRange(0, text.length - 1), + ), + isEmpty, + ); + + // Type a space, to cause a linkify reaction. + await tester.typeImeText(" "); + + // Ensure it's linkified. + text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "google.com "); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://google.com")), + start: 0, + end: 9, + ), + }, + ); + }); + + testWidgetsOnDesktop('recognizes a pasted URL with www and converts it to a link', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Paste text with a URL. + tester.simulateClipboard(); + await tester.setSimulatedClipboardContent("Hello https://www.google.com world"); + // TODO: create and use something like tester.pressPasteAdaptive() + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + await tester.pressCmdV(); + } else { + await tester.pressCtlV(); + } + + // Ensure the URL is linkified. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "Hello https://www.google.com world"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 6, + end: 27, + ), + }, + ); + }); + + testWidgetsOnDesktop('recognizes a pasted URL and inserts https scheme if it is missing', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Paste text with a URL. + tester.simulateClipboard(); + await tester.setSimulatedClipboardContent("Hello www.google.com world"); + // TODO: create and use something like tester.pressPasteAdaptive() + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + await tester.pressCmdV(); + } else { + await tester.pressCtlV(); + } + + // Ensure it's linkified with a URL schema. + var text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "Hello www.google.com world"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 6, + end: 19, + ), + }, + ); + }); + + testWidgetsOnDesktop('recognizes a pasted URL without https or www and converts it to a link', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Paste text with a URL. + tester.simulateClipboard(); + await tester.setSimulatedClipboardContent("Hello google.com world"); + // TODO: create and use something like tester.pressPasteAdaptive() + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + await tester.pressCmdV(); + } else { + await tester.pressCtlV(); + } + + // Ensure the URL is linkified. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "Hello google.com world"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://google.com")), + start: 6, + end: 15, + ), + }, + ); + }); + + testWidgetsOnDesktop('recognizes multiple pasted URLs', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Paste text with multiple URLs. + tester.simulateClipboard(); + await tester.setSimulatedClipboardContent( + "Some URLS: google.com https://google.com somebody@gmail.com mailto:somebody@gmail.com obsidian://open?vault=my-vault", + ); + // TODO: create and use something like tester.pressPasteAdaptive() + if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { + await tester.pressCmdV(); + } else { + await tester.pressCtlV(); + } + + // Ensure all URLs were linkified. + final text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.toPlainText(), + "Some URLS: google.com https://google.com somebody@gmail.com mailto:somebody@gmail.com obsidian://open?vault=my-vault", + ); + + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://google.com")), + start: 11, + end: 20, + ), + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://google.com")), + start: 22, + end: 39, + ), + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("mailto:somebody@gmail.com")), + start: 41, + end: 58, + ), + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("mailto:somebody@gmail.com")), + start: 60, + end: 84, + ), + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("obsidian://open?vault=my-vault")), + start: 86, + end: 115, + ), + }, + ); + }); + }); + + group('URI protocol >', () { + testWidgetsOnAllPlatforms('recognizes an email URI', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Type a URL. It shouldn't linkify until we add a space. + await tester.typeImeText("me@gmail.com"); + + // Type a space, to cause a linkify reaction. + await tester.typeImeText(" "); + + // Ensure it's linkified with a URL schema. + var text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "me@gmail.com "); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromEmail("me@gmail.com"), + start: 0, + end: 11, + ), + }, + ); + }); + }); + + testWidgetsOnAllPlatforms('recognizes a second URL when typing and converts it to a link', (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the empty document. + await tester.placeCaretInParagraph("1", 0); + + // Type text with two URLs. + await tester.typeImeText("https://www.google.com and https://flutter.dev "); + + // Ensure both URLs are linkified with the correct URLs. + final text = SuperEditorInspector.findTextInComponent("1"); + + expect(text.toPlainText(), "https://www.google.com and https://flutter.dev "); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://www.google.com")), + start: 0, + end: 21, + ), + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("https://flutter.dev")), + start: 27, + end: 45, + ), + }, + ); + }); + + group('does not expand the link when inserting before the link', () { + testWidgetsOnAllPlatforms('when configured to preserve links on change', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret in the first paragraph at the start of the link. + await tester.placeCaretInParagraph(doc.first.id, 0); + + // Type some text by simulating hardware keyboard key presses. + await tester.typeKeyboardText('Go to '); + + // Ensure that the link is unchanged. + expect( + SuperEditorInspector.findDocument(), + equalsMarkdown("Go to [www.google.com](www.google.com)"), + ); + }); + + testWidgetsOnAllPlatforms('when configured to update links on change', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.update)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret in the first paragraph at the start of the link. + await tester.placeCaretInParagraph(doc.first.id, 0); + + // Type some text by simulating hardware keyboard key presses. + await tester.typeKeyboardText('Go to '); + + // Ensure that the link is unchanged. + expect( + SuperEditorInspector.findDocument(), + equalsMarkdown("Go to [www.google.com](www.google.com)"), + ); + }); + + testWidgetsOnAllPlatforms('when configured to remove links on change', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.remove)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret in the first paragraph at the start of the link. + await tester.placeCaretInParagraph(doc.first.id, 0); + + // Type some text by simulating hardware keyboard key presses. + await tester.typeKeyboardText('Go to '); + + // Ensure that the link is unchanged. + expect( + SuperEditorInspector.findDocument(), + equalsMarkdown("Go to [www.google.com](www.google.com)"), + ); + }); + }); + + group('does not expand the link when inserting after the link', () { + testWidgets('when configured to preserve links on change', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret in the first paragraph at the start of the link. + await tester.placeCaretInParagraph(doc.first.id, 14); + + // Type some text by simulating hardware keyboard key presses. + await tester.typeKeyboardText(' to learn anything'); + + // Ensure that the link is unchanged. + expect( + SuperEditorInspector.findDocument(), + equalsMarkdown("[www.google.com](www.google.com) to learn anything"), + ); + }); + + testWidgets('when configured to update links on change', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.update)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret in the first paragraph at the start of the link. + await tester.placeCaretInParagraph(doc.first.id, 14); + + // Type some text by simulating hardware keyboard key presses. + await tester.typeKeyboardText(' to learn anything'); + + // Ensure that the link is unchanged. + expect( + SuperEditorInspector.findDocument(), + equalsMarkdown("[www.google.com](www.google.com) to learn anything"), + ); + }); + + testWidgets('when configured to remove links on change', (tester) async { + // Configure and render a document. + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.remove)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret in the first paragraph at the start of the link. + await tester.placeCaretInParagraph(doc.first.id, 14); + + // Type some text by simulating hardware keyboard key presses. + await tester.typeKeyboardText(' to learn anything'); + + // Ensure that the link is unchanged. + expect( + SuperEditorInspector.findDocument(), + equalsMarkdown("[www.google.com](www.google.com) to learn anything"), + ); + }); + }); + + group('can insert characters in the middle of a link', () { + testWidgetsOnAllPlatforms('without updating the attribution', (tester) async { + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.goog|le.com" + await tester.placeCaretInParagraph(doc.first.id, 8); + + // Add characters. + await tester.typeImeText("oooo"); + + // Ensure the characters were inserted, the whole link is still attributed. + final nodeId = doc.first.id; + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "www.googoooole.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("www.google.com")), + start: 0, + end: text.length - 1, + ), + }, + ); + }); + + testWidgetsOnAllPlatforms('updating the attribution', (tester) async { + final scheme = _urlSchemeVariant.currentValue; + await tester // + .createDocument() + .fromMarkdown("[www.google.com](${scheme}www.google.com)") + .withInputSource(TextInputSource.ime) + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.update)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.goog|le.com". + await tester.placeCaretInParagraph(doc.first.id, 8); + + // Add characters. + await tester.typeImeText("oooo"); + + // Ensure the characters were inserted and the link was updated. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "www.googoooole.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution("${scheme}www.googoooole.com"), + start: 0, + end: text.length - 1, + ), + }, + ); + }, variant: _urlSchemeVariant); + + testWidgetsOnAllPlatforms('removing the attribution', (tester) async { + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withInputSource(TextInputSource.ime) + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.remove)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.goog|le.com". + await tester.placeCaretInParagraph(doc.first.id, 8); + + // Add characters. + await tester.typeImeText("oooo"); + + // Ensure the characters were inserted and the attribution was removed. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "www.googoooole.com"); + expect(text.spans.markers, isEmpty); + }); + }); + + group('can delete characters at the beginning of a link', () { + testWidgetsOnAllPlatforms('without updating the attribution', (tester) async { + final scheme = _urlSchemeVariant.currentValue; + await tester // + .createDocument() + .fromMarkdown("[www.google.com](${scheme}www.google.com)") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "|www.google.com". + await tester.placeCaretInParagraph(doc.first.id, 0); + + // Delete downstream characters. + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + + // Ensure the characters were inserted, the whole link is still attributed. + final nodeId = doc.first.id; + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("${scheme}www.google.com")), + start: 0, + end: text.length - 1, + ), + }, + ); + }, variant: _urlSchemeVariant); + + testWidgetsOnAllPlatforms('updating the attribution', (tester) async { + final scheme = _urlSchemeVariant.currentValue; + await tester // + .createDocument() + .fromMarkdown("[www.google.com](${scheme}www.google.com)") + .withInputSource(TextInputSource.ime) + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.update)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "|www.google.com". + await tester.placeCaretInParagraph(doc.first.id, 0); + + // Delete downstream characters. + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + + // Ensure the characters were deleted and link attribution was updated. + // + // We expect the leading "www." to removed, but we expect to retain the + // scheme. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "google.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution("${scheme}google.com"), + start: 0, + end: text.length - 1, + ), + }, + ); + + // Delete 9 more characters, leaving only the last "m". + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + + // Ensure the attribution was updated. + final textAfter = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(textAfter.toPlainText(), "m"); + expect( + (textAfter.getAllAttributionsAt(0).first as LinkAttribution).plainTextUri.toString(), + "${scheme}m", + ); + + // Press delete to remove the last character. + await tester.pressDelete(); + + // Ensure the text was deleted. + expect(SuperEditorInspector.findTextInComponent(doc.first.id).toPlainText(), isEmpty); + }, variant: _urlSchemeVariant); + + testWidgetsOnAllPlatforms('removing the attribution', (tester) async { + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withInputSource(TextInputSource.ime) + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.remove)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "|www.google.com". + await tester.placeCaretInParagraph(doc.first.id, 0); + + // Delete downstream characters. + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + await tester.pressDelete(); + + // Ensure the characters were delete and link attribution was removed. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "google.com"); + expect(text.spans.markers, isEmpty); + }); + }); + + group('can delete characters in the middle of a link', () { + testWidgetsOnAllPlatforms('without updating the attribution', (tester) async { + final scheme = _urlSchemeVariant.currentValue; + await tester // + .createDocument() + .fromMarkdown("[www.google.com](${scheme}www.google.com)") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.google.com|". + await tester.placeCaretInParagraph(doc.first.id, 10); + + // Delete upstream characters. + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Ensure the characters were deleted and the whole link is still attributed. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "www.g.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("${scheme}www.google.com")), + start: 0, + end: text.length - 1, + ), + }, + ); + }, variant: _urlSchemeVariant); + + testWidgetsOnAllPlatforms('updating the attribution', (tester) async { + final scheme = _urlSchemeVariant.currentValue; + await tester // + .createDocument() + .fromMarkdown("[www.google.com](${scheme}www.google.com)") + .withInputSource(TextInputSource.ime) + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.update)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.google|.com". + await tester.placeCaretInParagraph(doc.first.id, 10); + + // Remove characters. + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Type another text. + await tester.typeImeText('duckduckgo'); + + // Ensure the text and the link were updated. + var text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "www.duckduckgo.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("${scheme}www.duckduckgo.com")), + start: 0, + end: text.length - 1, + ), + }, + ); + }, variant: _urlSchemeVariant); + + testWidgetsOnAllPlatforms('removing the attribution', (tester) async { + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withInputSource(TextInputSource.ime) + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.remove)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.google|.com". + await tester.placeCaretInParagraph(doc.first.id, 10); + + // Remove a single character. + await tester.pressBackspace(); + + // Ensure the text was updated and the attribution was removed. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "www.googl.com"); + expect(text.spans.markers, isEmpty); + }); + }); + + group('can delete characters at the end of a link', () { + testWidgetsOnAllPlatforms('without updating the attribution', (tester) async { + final scheme = _urlSchemeVariant.currentValue; + await tester // + .createDocument() + .fromMarkdown("[www.google.com](${scheme}www.google.com)") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.google.com|". + await tester.placeCaretInParagraph(doc.first.id, 14); + + // Delete upstream characters. + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Ensure the characters were inserted, the whole link is still attributed. + final nodeId = doc.first.id; + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "www.google"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("${scheme}www.google.com")), + start: 0, + end: text.length - 1, + ), + }, + ); + }, variant: _urlSchemeVariant); + + testWidgetsOnAllPlatforms('updating the attribution', (tester) async { + final scheme = _urlSchemeVariant.currentValue; + await tester // + .createDocument() + .fromMarkdown("[www.google.com](${scheme}www.google.com)") + .withInputSource(TextInputSource.ime) + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.update)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.google.com|". + await tester.placeCaretInParagraph(doc.first.id, 14); + + // Delete upstream characters. + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Ensure the characters were deleted and the link was updated. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "www.google.c"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("${scheme}www.google.c")), + start: 0, + end: text.length - 1, + ), + }, + ); + }, variant: _urlSchemeVariant); + + testWidgetsOnAllPlatforms('removing the attribution', (tester) async { + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withInputSource(TextInputSource.ime) + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.remove)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.google.com|". + await tester.placeCaretInParagraph(doc.first.id, 14); + + // Delete an upstream characters. + await tester.pressBackspace(); + + // Ensure the character was deleted and the link was removed. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "www.google.co"); + expect(text.spans.markers, isEmpty); + }); + }); + + group('can replace characters in the middle of a link', () { + testWidgetsOnAllPlatforms('without updating the attribution', (tester) async { + final scheme = _urlSchemeVariant.currentValue; + await tester // + .createDocument() + .fromMarkdown("[www.google.com](${scheme}www.google.com)") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Double tap to select "google". + await tester.doubleTapInParagraph(doc.first.id, 5); + + // Replace "google" with "duckduckgo". + await tester.typeImeText('duckduckgo'); + + // Ensure the text and the link were updated. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "www.duckduckgo.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("${scheme}www.google.com")), + start: 0, + end: text.length - 1, + ), + }, + ); + }, variant: _urlSchemeVariant); + + testWidgetsOnAllPlatforms('updating the attribution', (tester) async { + final scheme = _urlSchemeVariant.currentValue; + await tester // + .createDocument() + .fromMarkdown("[www.google.com](${scheme}www.google.com)") + .withInputSource(TextInputSource.ime) + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.update)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Double tap to select "google". + await tester.doubleTapInParagraph(doc.first.id, 5); + + // Replace "google" with "duckduckgo". + await tester.typeImeText('duckduckgo'); + + // Ensure the text and the link were updated. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "www.duckduckgo.com"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("${scheme}www.duckduckgo.com")), + start: 0, + end: text.length - 1, + ), + }, + ); + }, variant: _urlSchemeVariant); + + testWidgetsOnAllPlatforms('removing the attribution', (tester) async { + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withInputSource(TextInputSource.ime) + .withAddedReactions([const LinkifyReaction(updatePolicy: LinkUpdatePolicy.remove)]) // + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Double tap to select "google". + await tester.doubleTapInParagraph(doc.first.id, 5); + + // Replace "google" with "duckduckgo". + await tester.typeImeText('duckduckgo'); + + // Ensure the text and the link were updated. + final text = SuperEditorInspector.findTextInComponent(doc.first.id); + expect(text.toPlainText(), "www.duckduckgo.com"); + expect(text.spans.markers, isEmpty); + }); + }); + + testWidgetsOnAllPlatforms('user can delete characters at the end of a link and then keep typing', (tester) async { + final scheme = _urlSchemeVariant.currentValue; + await tester // + .createDocument() + .fromMarkdown("[www.google.com](${scheme}www.google.com)") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.google.com|". + await tester.placeCaretInParagraph(doc.first.id, 14); + + // Delete a character at the end of the link. + await tester.pressBackspace(); + + // Start typing new content, which shouldn't become part of the link. + await tester.typeImeText(" hello"); + + // Ensure the text were inserted, and only the URL is linkified. + final nodeId = doc.first.id; + var text = SuperEditorInspector.findTextInComponent(nodeId); + + expect(text.toPlainText(), "www.google.co hello"); + expect( + text.getAttributionSpansByFilter((a) => a is LinkAttribution), + { + AttributionSpan( + attribution: LinkAttribution.fromUri(Uri.parse("${scheme}www.google.com")), + start: 0, + end: 12, + ), + }, + ); + expect( + text.hasAttributionsThroughout( + attributions: { + LinkAttribution.fromUri(Uri.parse("${scheme}www.google.com")), + }, + range: SpanRange(13, text.length - 1), + ), + isFalse, + ); + }, variant: _urlSchemeVariant); + + testWidgetsOnAllPlatforms('does not extend link to new paragraph', (tester) async { + await tester // + .createDocument() + .fromMarkdown("[www.google.com](www.google.com)") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Place the caret at "www.google.com|". + await tester.placeCaretInParagraph(doc.first.id, 14); + + // Create a new paragraph. + await tester.pressEnter(); + + // We had an issue where link attributions were extended to the beginning of + // an empty paragraph, but were removed after the user started typing. So, first, + // ensure that no link markers were added to the empty paragraph. + expect(doc.nodeCount, 2); + final newParagraphId = doc.getNodeAt(1)!.id; + AttributedText newParagraphText = SuperEditorInspector.findTextInComponent(newParagraphId); + expect(newParagraphText.spans.markers, isEmpty); + + // Type some text. + await tester.typeImeText("New paragraph"); + + // Ensure the text we typed didn't re-introduce a link attribution. + newParagraphText = SuperEditorInspector.findTextInComponent(newParagraphId); + expect(newParagraphText.toPlainText(), "New paragraph"); + expect( + newParagraphText.getAttributionSpansInRange( + attributionFilter: (a) => a is LinkAttribution, + range: SpanRange(0, newParagraphText.length - 1), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms('does not extend link to new list item', (tester) async { + await tester // + .createDocument() + .fromMarkdown(" * [www.google.com](www.google.com)") + .withInputSource(TextInputSource.ime) + .pump(); + + final doc = SuperEditorInspector.findDocument()!; + + // Ensure the Markdown correctly created a list item. + expect(doc.first, isA()); + + // Place the caret at "www.google.com|". + await tester.placeCaretInParagraph(doc.first.id, 14); + + // Create a new list item. + await tester.pressEnter(); + + // We had an issue where link attributions were extended to the beginning of + // an empty list item, but were removed after the user started typing. So, first, + // ensure that no link markers were added to the empty list item. + expect(doc.nodeCount, 2); + expect(doc.getNodeAt(1)!, isA()); + final newListItemId = doc.getNodeAt(1)!.id; + AttributedText newListItemText = SuperEditorInspector.findTextInComponent(newListItemId); + expect(newListItemText.spans.markers, isEmpty); + + // Type some text. + await tester.typeImeText("New list item"); + + // Ensure the text we typed didn't re-introduce a link attribution. + newListItemText = SuperEditorInspector.findTextInComponent(newListItemId); + expect(newListItemText.toPlainText(), "New list item"); + expect( + newListItemText.getAttributionSpansInRange( + attributionFilter: (a) => a is LinkAttribution, + range: SpanRange(0, newListItemText.length - 1), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms('plays nice with Markdown link when Markdown parsing is disabled', (tester) async { + // Based on bug #2074 - https://github.com/superlistapp/super_editor/issues/2074 + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + await tester.typeImeText("[google](www.google.com) "); + + // Ensure that the Markdown was ignored and nothing was linkified. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "[google](www.google.com) "); + expect(text.getAttributionSpansByFilter((a) => true), isEmpty); + }); + + testWidgetsOnMac('plays nice with Markdown link when pasting a Markdown link', (tester) async { + // Based on bug #2074 - https://github.com/superlistapp/super_editor/issues/2074 + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Simulate copying a Markdown link to the clipboard. + tester.simulateClipboard(); + await tester.setSimulatedClipboardContent("Hello [google](www.google.com) "); + + // Simulate pasting the Markdown link into the document. + await tester.pressCmdV(); + + // Ensure that the Markdown was ignored and nothing was linkified. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "Hello [google](www.google.com) "); + expect(text.getAttributionSpansByFilter((a) => true), isEmpty); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 31), + ), + ), + ); + }); + + // TODO: once it's easier to configure task components (#1295), add a test that checks link attributions when inserting a new task + }); +} + +/// A variety of URL schemes, including an empty scheme. +/// +/// Comparing empty vs non-empty schemes is especially important because URL +/// schemes are often omitted, and we need to ensure that link attribution +/// adjustments preserve existing schemes, but that we don't add schemes when +/// they didn't exist in the first place. +final _urlSchemeVariant = ValueVariant({"", "https://"}); diff --git a/super_editor/test/super_editor/text_entry/paragraph_conversions_test.dart b/super_editor/test/super_editor/text_entry/paragraph_conversions_test.dart new file mode 100644 index 0000000000..bd05d67d27 --- /dev/null +++ b/super_editor/test/super_editor/text_entry/paragraph_conversions_test.dart @@ -0,0 +1,497 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_runners.dart'; + +void main() { + group("SuperEditor content conversion >", () { + group("paragraph to headers >", () { + testWidgetsOnAllPlatforms( + "with '#'", + (tester) async { + final headerVariant = _headerVariant.currentValue!; + + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + // Type the token that should cause an auto-conversion. + await tester.typeImeText(headerVariant.$1); + + // Ensure that the paragraph is now a header, and it's content is empty. + final document = context.findEditContext().document; + final paragraph = document.first as ParagraphNode; + + expect(paragraph.metadata['blockType'], headerVariant.$2); + expect(paragraph.text.toPlainText().isEmpty, isTrue); + }, + variant: _headerVariant, + ); + + testWidgetsOnAllPlatforms("does not convert with 7 or more #", (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + // Type a header token that's longer than the smallest supported header + await tester.typeImeText("####### "); + + // Ensure that the paragraph hasn't changed. + final document = context.findEditContext().document; + final paragraph = document.first as ParagraphNode; + + expect(paragraph.metadata['blockType'], paragraphAttribution); + expect(paragraph.text.toPlainText(), "####### "); + }); + }); + + group("paragraph to unordered list >", () { + testWidgetsOnAllPlatforms('with', (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + final unorderedListItemPattern = _unorderedListVariant.currentValue!; + await tester.typeImeText(unorderedListItemPattern); + + final listItemNode = context.findEditContext().document.first; + expect(listItemNode, isA()); + expect((listItemNode as ListItemNode).type, ListItemType.unordered); + expect(listItemNode.text.toPlainText().isEmpty, isTrue); + }, variant: _unorderedListVariant); + + testWidgetsOnAllPlatforms('does not convert "1 "', (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + await tester.typeImeText("1 "); + + final paragraphNode = context.findEditContext().document.first; + expect(paragraphNode, isA()); + expect((paragraphNode as ParagraphNode).text.toPlainText(), "1 "); + }); + + testWidgetsOnAllPlatforms('does not convert " 1 "', (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + await tester.typeImeText(" 1 "); + + final paragraphNode = context.findEditContext().document.first; + expect(paragraphNode, isA()); + expect((paragraphNode as ParagraphNode).text.toPlainText(), " 1 "); + }); + }); + + group("paragraph to ordered list >", () { + testWidgetsOnAllPlatforms('with', (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + final orderedListItemPattern = _orderedListVariant.currentValue!; + await tester.typeImeText(orderedListItemPattern); + + final listItemNode = context.findEditContext().document.first; + expect(listItemNode, isA()); + expect((listItemNode as ListItemNode).type, ListItemType.ordered); + expect(listItemNode.text.toPlainText().isEmpty, isTrue); + }, variant: _orderedListVariant); + + testWidgetsOnAllPlatforms('with a number that continues the sequence', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(''' +1. First item +2. Second item +3. Third item + + +''') + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + final document = context.document; + await tester.placeCaretInParagraph(document.getNodeAt(3)!.id, 0); + + // Type a list pattern with the number 4. + await tester.typeImeText(_orderedListNumberVariant.currentValue!.replaceAll('n', '4')); + + // Ensure the paragraph was converted. + final listItemNode = context.findEditContext().document.getNodeAt(3)!; + expect(listItemNode, isA()); + expect((listItemNode as ListItemNode).type, ListItemType.ordered); + expect(listItemNode.text.toPlainText().isEmpty, isTrue); + }, variant: _orderedListNumberVariant); + + testWidgetsOnAllPlatforms('does not convert with a number that does not continues the sequence', (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown(''' +1. First item +2. Second item +3. Third item + + +''') + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + final document = context.document; + await tester.placeCaretInParagraph(document.getNodeAt(3)!.id, 0); + + // Type a list pattern with the number 5. + final orderedListItemPattern = _orderedListNumberVariant.currentValue!.replaceAll('n', '5'); + await tester.typeImeText(orderedListItemPattern); + + // Ensure the paragraph was not converted and the typed text was kept. + final editingNode = context.findEditContext().document.getNodeAt(3)!; + expect(editingNode, isA()); + expect((editingNode as ParagraphNode).text.toPlainText(), orderedListItemPattern); + }, variant: _orderedListNumberVariant); + + testWidgetsOnAllPlatforms('does not start a list with a number bigger than one', (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + final document = context.document; + await tester.placeCaretInParagraph('1', 0); + + // Type a list pattern with the number 2. + final orderedListItemPattern = _orderedListNumberVariant.currentValue!.replaceAll('n', '2'); + await tester.typeImeText(orderedListItemPattern); + + // Ensure the paragraph was not converted and the typed text was kept. + final editingNode = document.first; + expect(editingNode, isA()); + expect((editingNode as ParagraphNode).text.toPlainText(), orderedListItemPattern); + }, variant: _orderedListNumberVariant); + + testWidgetsOnAllPlatforms('does not convert "1 "', (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + await tester.typeImeText("1 "); + + final paragraphNode = context.findEditContext().document.first; + expect(paragraphNode, isA()); + expect((paragraphNode as ParagraphNode).text.toPlainText(), "1 "); + }); + + testWidgetsOnAllPlatforms('does not convert " 1 "', (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + await tester.typeImeText(" 1 "); + + final paragraphNode = context.findEditContext().document.first; + expect(paragraphNode, isA()); + expect((paragraphNode as ParagraphNode).text.toPlainText(), " 1 "); + }); + }); + + group("paragraph to horizontal rule >", () { + testAllInputsOnAllPlatforms("with --- at the beginning of an empty paragraph", ( + tester, { + required TextInputSource inputSource, + }) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(inputSource) + .autoFocus(true) + .pump(); + + await tester.typeTextAdaptive("--- "); + + // Ensure that we now have two nodes, and the first one is an HR. + final document = context.findEditContext().document; + expect(document.nodeCount, 2); + + expect(document.first, isA()); + expect(document.last, isA()); + expect((document.last as ParagraphNode).text.toPlainText().isEmpty, isTrue); + }); + + testAllInputsOnAllPlatforms('with --- at the beginning of an non-empty paragraph', ( + tester, { + required TextInputSource inputSource, + }) async { + final context = await tester // + .createDocument() + .fromMarkdown('Existing paragraph') + .withInputSource(inputSource) + .pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph(context.document.first.id, 0); + + // Type the first dash. + await tester.typeTextAdaptive('-'); + + // Ensure no conversion was performed. + expect((context.document.first as ParagraphNode).text.toPlainText(), '-Existing paragraph'); + + // Type the second dash. + await tester.typeTextAdaptive('-'); + + // Ensure the two dashes were converted to an em-dash. + expect((context.document.first as ParagraphNode).text.toPlainText(), '—Existing paragraph'); + + // Type the third dash. + await tester.typeTextAdaptive('- '); + + // Ensure a horizontal rule was inserted before the existing paragraph. + expect(context.document.nodeCount, 2); + expect(context.document.first, isA()); + expect(context.document.last, isA()); + expect((context.document.last as ParagraphNode).text.toPlainText(), 'Existing paragraph'); + }); + + testWidgetsOnAllPlatforms('does not convert non-HR dashes', (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + final nonHrInputAndResult = _nonHrVariant.currentValue!; + final input = nonHrInputAndResult.input; + final expectedResult = nonHrInputAndResult.expectedResult; + + await tester.typeImeText(input); + + final paragraphNode = context.findEditContext().document.first; + expect(paragraphNode, isA()); + expect((paragraphNode as ParagraphNode).text.toPlainText(), expectedResult); + }, variant: _nonHrVariant); + }); + + group("paragraph to blockquote >", () { + testWidgetsOnAllPlatforms("with '> '", (tester) async { + final context = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .autoFocus(true) + .pump(); + + await tester.typeImeText("> "); + + // Ensure that the paragraph is now a blockquote, and it's content is empty. + final document = context.findEditContext().document; + final paragraph = document.first as ParagraphNode; + + expect(paragraph.metadata['blockType'], blockquoteAttribution); + expect(paragraph.text.toPlainText().isEmpty, isTrue); + }); + }); + + group("converts to paragraph when backspace is pressed >", () { + testWidgetsOnAllPlatforms("headers", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown("# My Header") + .withInputSource(TextInputSource.ime) + .pump(); + final document = context.document; + + await tester.placeCaretInParagraph(document.first.id, 0); + + // Ensure that we're starting with a header. + expect(document.first.metadata["blockType"], header1Attribution); + + // Simulate a backspace deletion delta. + await tester.ime.sendDeltas( + [ + const TextEditingDeltaNonTextUpdate( + oldText: ". My Header", + selection: TextSelection(baseOffset: 1, extentOffset: 2), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: ". My Header", + selection: TextSelection.collapsed(offset: 1), + deletedRange: TextRange(start: 1, end: 2), + composing: TextRange.empty, + ), + ], + getter: imeClientGetter, + ); + + // Ensure that the header became a paragraph. + expect(document.first.metadata["blockType"], paragraphAttribution); + expect(SuperEditorInspector.findTextInComponent(document.first.id).toPlainText(), "My Header"); + }); + + testWidgetsOnAllPlatforms("blockquotes", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown("> My Blockquote") + .withInputSource(TextInputSource.ime) + .pump(); + final document = context.document; + + await tester.placeCaretInParagraph(document.first.id, 0); + + // Ensure that we're starting with a blockquote. + expect(document.first.metadata["blockType"], blockquoteAttribution); + + // Simulate a backspace deletion delta. + await tester.ime.sendDeltas( + [ + const TextEditingDeltaNonTextUpdate( + oldText: ". My Blockquote", + selection: TextSelection(baseOffset: 1, extentOffset: 2), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: ". My Blockquote", + selection: TextSelection.collapsed(offset: 1), + deletedRange: TextRange(start: 1, end: 2), + composing: TextRange.empty, + ), + ], + getter: imeClientGetter, + ); + + // Ensure that the blockquote became a paragraph. + expect(document.first.metadata["blockType"], paragraphAttribution); + expect(SuperEditorInspector.findTextInComponent(document.first.id).toPlainText(), "My Blockquote"); + }); + + testWidgetsOnAllPlatforms("ordered list items", (tester) async { + final context = await tester // + .createDocument() + .fromMarkdown("1. My list item") + .withInputSource(TextInputSource.ime) + .pump(); + final document = context.document; + + await tester.placeCaretInParagraph(document.first.id, 0); + + // Ensure that we're starting with list item. + expect(document.first, isA()); + + // Simulate a backspace deletion delta. + await tester.ime.sendDeltas( + [ + const TextEditingDeltaNonTextUpdate( + oldText: ". My list item", + selection: TextSelection(baseOffset: 1, extentOffset: 2), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: ". My list item", + selection: TextSelection.collapsed(offset: 1), + deletedRange: TextRange(start: 1, end: 2), + composing: TextRange.empty, + ), + ], + getter: imeClientGetter, + ); + + // Ensure that the list item became a paragraph. + final newNode = document.first; + expect(newNode, isA()); + expect(newNode.metadata["blockType"], paragraphAttribution); + expect(SuperEditorInspector.findTextInComponent(document.first.id).toPlainText(), "My list item"); + }); + }); + }); +} + +final _headerVariant = ValueVariant({ + ("# ", header1Attribution), + ("## ", header2Attribution), + ("### ", header3Attribution), + ("#### ", header4Attribution), + ("##### ", header5Attribution), + ("###### ", header6Attribution), +}); + +final _unorderedListVariant = ValueVariant({ + "* ", + " * ", + "- ", + " - ", +}); + +final _orderedListVariant = ValueVariant({ + "1. ", + " 1. ", + "1) ", + " 1) ", +}); + +final _orderedListNumberVariant = ValueVariant({ + "n. ", + " n. ", + "n) ", + " n) ", +}); + +/// Holds sequence of character that shouldn't produce a horizontal rule +/// and the expected resulting text after running the editor reactions. +final _nonHrVariant = ValueVariant(const { + // We ignore " - " because that is a conversion for unordered list items + _TestInput(input: "-- ", expectedResult: "— "), + _TestInput(input: "---- ", expectedResult: "—— "), + _TestInput(input: " --- ", expectedResult: " —- "), +}); + +/// A test text input and the expected resulting text after running +/// the editor reactions. +class _TestInput { + const _TestInput({ + required this.input, + required this.expectedResult, + }); + + final String input; + final String expectedResult; + + @override + String toString() { + return "[input: $input, expectedResult: $expectedResult]"; + } +} diff --git a/super_editor/test/super_editor/text_entry/super_editor_common_text_entry_test.dart b/super_editor/test/super_editor/text_entry/super_editor_common_text_entry_test.dart new file mode 100644 index 0000000000..f69fbfd8b0 --- /dev/null +++ b/super_editor/test/super_editor/text_entry/super_editor_common_text_entry_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group("SuperEditor common text entry >", () { + testWidgetsOnDesktop("control keys don't impact content", (tester) async { + await _pumpApp(tester, _desktopInputSourceAndControlKeyVariant.currentValue!.inputSource); + + final initialParagraphText = SuperEditorInspector.findTextInComponent("1"); + + // Select some content -> "Lorem |ipsum| dolor sit..." + await tester.doubleTapInParagraph("1", 8); + const expectedSelection = DocumentSelection( + base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 6)), + extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 11)), + ); + expect(SuperEditorInspector.findDocumentSelection(), expectedSelection); + + // Press a control key. + await tester.sendKeyEvent( + _desktopInputSourceAndControlKeyVariant.currentValue!.controlKey, + platform: _platformNames[defaultTargetPlatform]!, + ); + + // Make sure the content and selection remains the same. + expect(SuperEditorInspector.findTextInComponent("1"), initialParagraphText); + expect(SuperEditorInspector.findDocumentSelection(), expectedSelection); + }, variant: _desktopInputSourceAndControlKeyVariant); + + testWidgetsOnMobile("control keys don't impact content", (tester) async { + await _pumpApp(tester, _mobileInputSourceAndControlKeyVariant.currentValue!.inputSource); + + final initialParagraphText = SuperEditorInspector.findTextInComponent("1"); + + // Select some content -> "Lorem |ipsum| dolor sit..." + await tester.doubleTapInParagraph("1", 8); + const expectedSelection = DocumentSelection( + base: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 6)), + extent: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 11)), + ); + expect(SuperEditorInspector.findDocumentSelection(), expectedSelection); + + // Press a control key. + await tester.sendKeyEvent( + _mobileInputSourceAndControlKeyVariant.currentValue!.controlKey, + platform: _platformNames[defaultTargetPlatform]!, + ); + + // Make sure the content and selection remains the same. + expect(SuperEditorInspector.findTextInComponent("1"), initialParagraphText); + expect(SuperEditorInspector.findDocumentSelection(), expectedSelection); + }, variant: _mobileInputSourceAndControlKeyVariant); + }); +} + +Future _pumpApp(WidgetTester tester, TextInputSource inputSource) async { + await tester // + .createDocument() + .withSingleParagraph() + .withInputSource(inputSource) + .withCustomWidgetTreeBuilder((superEditor) { + return MaterialApp( + home: Scaffold( + body: Column( + children: [ + // Add focusable widgets before and after SuperEditor so that we + // catch any keys that try to move focus forward or backward. + const Focus(child: SizedBox(width: double.infinity, height: 54)), + Expanded( + child: superEditor, + ), + const Focus(child: SizedBox(width: double.infinity, height: 54)), + ], + ), + ), + ); + }).pump(); +} + +final _mobileInputSourceAndControlKeyVariant = ValueVariant({ + for (final inputSource in TextInputSource.values) + for (final controlKey in _allPlatformControlKeys) // + _InputSourceAndControlKey(inputSource, controlKey), +}); + +final _desktopInputSourceAndControlKeyVariant = ValueVariant({ + for (final inputSource in TextInputSource.values) + for (final controlKey in _desktopControlKeys) // + _InputSourceAndControlKey(inputSource, controlKey), +}); + +// TODO: Replace raw strings with constants when Flutter offers them (https://github.com/flutter/flutter/issues/133295) +final _platformNames = { + TargetPlatform.android: "android", + TargetPlatform.iOS: "ios", + TargetPlatform.macOS: "macos", + TargetPlatform.windows: "windows", + TargetPlatform.linux: "linux", +}; + +class _InputSourceAndControlKey { + _InputSourceAndControlKey( + this.inputSource, + this.controlKey, + ); + + final TextInputSource inputSource; + final LogicalKeyboardKey controlKey; + + @override + String toString() => "$inputSource, ${controlKey.keyLabel}"; +} + +final _allPlatformControlKeys = { + LogicalKeyboardKey.capsLock, + LogicalKeyboardKey.shift, + LogicalKeyboardKey.control, + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.controlRight, + LogicalKeyboardKey.meta, + LogicalKeyboardKey.metaLeft, + LogicalKeyboardKey.metaRight, + LogicalKeyboardKey.alt, + LogicalKeyboardKey.altLeft, + LogicalKeyboardKey.altRight, + LogicalKeyboardKey.f1, + LogicalKeyboardKey.f2, + LogicalKeyboardKey.f3, + LogicalKeyboardKey.f4, + LogicalKeyboardKey.f5, + LogicalKeyboardKey.f6, + LogicalKeyboardKey.f7, + LogicalKeyboardKey.f8, + LogicalKeyboardKey.f9, + LogicalKeyboardKey.f10, + LogicalKeyboardKey.f11, + LogicalKeyboardKey.f12, +}; + +// Apparently Flutter blows up if you simulate certain keys on mobile. Those +// keys are separated here as desktop-only control keys. +final _desktopControlKeys = { + ..._allPlatformControlKeys, + LogicalKeyboardKey.f13, + LogicalKeyboardKey.f14, + LogicalKeyboardKey.f15, + LogicalKeyboardKey.f16, + LogicalKeyboardKey.f17, + LogicalKeyboardKey.f18, + LogicalKeyboardKey.f19, + LogicalKeyboardKey.f20, + // The following keys don't appear to be supported on desktop as of Aug 24, 2023. + // LogicalKeyboardKey.f21, + // LogicalKeyboardKey.f22, + // LogicalKeyboardKey.f23, + // LogicalKeyboardKey.f24, +}; diff --git a/super_editor/test/src/default_editor/super_editor_content_conversion_test.dart b/super_editor/test/super_editor/text_entry/super_editor_content_conversion_test.dart similarity index 91% rename from super_editor/test/src/default_editor/super_editor_content_conversion_test.dart rename to super_editor/test/super_editor/text_entry/super_editor_content_conversion_test.dart index 10dba59f5c..9f457cf254 100644 --- a/super_editor/test/src/default_editor/super_editor_content_conversion_test.dart +++ b/super_editor/test/super_editor/text_entry/super_editor_content_conversion_test.dart @@ -1,11 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; -import 'package:super_editor_markdown/super_editor_markdown.dart'; - -import '../../super_editor/document_test_tools.dart'; -import '../../test_tools.dart'; void main() { group('SuperEditor', () { @@ -51,7 +48,7 @@ void main() { .createDocument() .withCustomContent(MutableDocument( nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "Some text")), + ParagraphNode(id: "1", text: AttributedText("Some text")), ], )) .forDesktop() @@ -81,7 +78,7 @@ void main() { .createDocument() .withCustomContent(MutableDocument( nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "Some text")), + ParagraphNode(id: "1", text: AttributedText("Some text")), ], )) .forDesktop() @@ -111,7 +108,7 @@ void main() { .createDocument() .withCustomContent(MutableDocument( nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "Some text")), + ParagraphNode(id: "1", text: AttributedText("Some text")), ], )) .forDesktop() @@ -216,16 +213,16 @@ MutableDocument _singleParagraphWithLinkDoc() { ParagraphNode( id: "1", text: AttributedText( - text: "https://google.com", - spans: AttributedSpans( + "https://google.com", + AttributedSpans( attributions: [ - SpanMarker( - attribution: LinkAttribution(url: Uri.parse('https://google.com')), + const SpanMarker( + attribution: LinkAttribution('https://google.com'), offset: 0, markerType: SpanMarkerType.start, ), - SpanMarker( - attribution: LinkAttribution(url: Uri.parse('https://google.com')), + const SpanMarker( + attribution: LinkAttribution('https://google.com'), offset: 17, markerType: SpanMarkerType.end, ), diff --git a/super_editor/test/super_editor/text_entry/super_editor_list_item_conversion_test.dart b/super_editor/test/super_editor/text_entry/super_editor_list_item_conversion_test.dart new file mode 100644 index 0000000000..13efc81459 --- /dev/null +++ b/super_editor/test/super_editor/text_entry/super_editor_list_item_conversion_test.dart @@ -0,0 +1,175 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_inspector.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_test_tools.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group("Super Editor > list items > auto-conversion >", () { + group("unordered list items >", () { + testWidgetsOnAllPlatforms("converts with prefix in empty paragraph", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type the prefix for an unordered list item. + await tester.typeImeText(_unorderedListItemPrefixes.currentValue!); + + // Ensure that we converted the paragraph to a list item. + expect( + SuperEditorInspector.findDocument(), + documentEquivalentTo(MutableDocument( + nodes: [ + ListItemNode(id: "1", itemType: ListItemType.unordered, text: AttributedText()), + ], + )), + ); + }, variant: _unorderedListItemPrefixes); + + testWidgetsOnAllPlatforms("converts with prefix in non-empty paragraph", (tester) async { + await tester // + .createDocument() + .withSingleShortParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type the prefix for an unordered list item. + await tester.typeImeText(_unorderedListItemPrefixes.currentValue!); + final startingText = SuperEditorInspector.findTextInComponent("1"); + + // Ensure that we converted the paragraph to a list item. + expect( + SuperEditorInspector.findDocument(), + documentEquivalentTo(MutableDocument( + nodes: [ + ListItemNode(id: "1", itemType: ListItemType.unordered, text: startingText), + ], + )), + ); + }, variant: _unorderedListItemPrefixes); + }); + + group("ordered list items >", () { + testWidgetsOnAllPlatforms("converts with '1' prefix in empty paragraph", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type the prefix for an ordered list item. + await tester.typeImeText(_orderedListItemPrefixes.currentValue!); + + // Ensure that we converted the paragraph to a list item. + expect( + SuperEditorInspector.findDocument(), + documentEquivalentTo(MutableDocument( + nodes: [ + ListItemNode(id: "1", itemType: ListItemType.ordered, text: AttributedText()), + ], + )), + ); + }, variant: _orderedListItemPrefixes); + + testWidgetsOnAllPlatforms("converts with '1' prefix in non-empty paragraph", (tester) async { + await tester // + .createDocument() + .withSingleShortParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type the prefix for an ordered list item. + await tester.typeImeText(_orderedListItemPrefixes.currentValue!); + final startingText = SuperEditorInspector.findTextInComponent("1"); + + // Ensure that we converted the paragraph to a list item. + expect( + SuperEditorInspector.findDocument(), + documentEquivalentTo(MutableDocument( + nodes: [ + ListItemNode(id: "1", itemType: ListItemType.ordered, text: startingText), + ], + )), + ); + }, variant: _orderedListItemPrefixes); + + testWidgetsOnAllPlatforms("converts with '2' prefix in empty paragraph", (tester) async { + await tester // + .createDocument() + .withOrderedListItemFollowedByEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("2", 0); + + // Type the prefix for the 2nd ordered list item. + await tester.typeImeText(_secondOrderedListItemPrefixes.currentValue!); + + // Ensure that we converted the paragraph to a list item. + expect( + SuperEditorInspector.findDocument(), + documentEquivalentTo(MutableDocument( + nodes: [ + ListItemNode( + id: "1", + itemType: ListItemType.ordered, + text: SuperEditorInspector.findTextInComponent("1"), + ), + ListItemNode(id: "2", itemType: ListItemType.ordered, text: AttributedText()), + ], + )), + ); + }, variant: _secondOrderedListItemPrefixes); + + testWidgetsOnAllPlatforms("converts with '2' prefix in non-empty paragraph", (tester) async { + await tester // + .createDocument() + .withOrderedListItemFollowedByEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("2", 0); + + // Type the prefix for an unordered list item. + await tester.typeImeText(_orderedListItemPrefixes.currentValue!); + final startingText = SuperEditorInspector.findTextInComponent("2"); + + // Ensure that we converted the paragraph to a list item. + expect( + SuperEditorInspector.findDocument(), + documentEquivalentTo(MutableDocument( + nodes: [ + ListItemNode( + id: "1", + itemType: ListItemType.ordered, + text: SuperEditorInspector.findTextInComponent("1"), + ), + ListItemNode(id: "2", itemType: ListItemType.ordered, text: startingText), + ], + )), + ); + }, variant: _secondOrderedListItemPrefixes); + }); + }); +} + +final _unorderedListItemPrefixes = ValueVariant({ + "- ", + " - ", + " • ", +}); + +final _orderedListItemPrefixes = ValueVariant({ + "1. ", + " 1. ", +}); + +final _secondOrderedListItemPrefixes = ValueVariant({ + "2. ", + " 2. ", +}); diff --git a/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart b/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart new file mode 100644 index 0000000000..864cd75d0d --- /dev/null +++ b/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart @@ -0,0 +1,1082 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_documents.dart'; + +void main() { + group("SuperEditor action tags >", () { + group("composing >", () { + testWidgetsOnAllPlatforms("can start at the beginning of a paragraph", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "/header"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 0), + const SpanRange(0, 6), + ); + }); + + testWidgetsOnAllPlatforms("can start between words", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before /header after"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 13), + ); + }); + + testWidgetsOnAllPlatforms("can start at the beginning of a word", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |after" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag, typing at "|after". + await tester.typeImeText("/header"); + + // Ensure that "/header" was attributed but "after" was left unnattributed. + final spans = SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 19), + ); + expect(spans.length, 1); + expect( + spans.first, + const AttributionSpan( + attribution: actionTagComposingAttribution, + start: 7, + end: 13, + ), + ); + }); + + testWidgetsOnAllPlatforms("by default does not continue after a space", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag and more content after a space. + await tester.typeImeText("/header after"); + + // Ensure that there's no more composing attribution because the tag + // should have been submitted. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before /header after"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 18), + ), + isEmpty, + ); + expect( + text.getAttributedRange({actionTagCancelledAttribution}, 7), + const SpanRange(7, 7), + ); + }); + + testWidgetsOnAllPlatforms("can be configured to continue after a space", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + tagRule: TagRule(trigger: "/"), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Ensure that we started a composing tag before adding a space. + var text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before /header"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 13), + ); + + await tester.typeImeText(" after"); + + // Ensure that the composing attribution continues after the space. + text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before /header after"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 19), + ); + }); + + testWidgetsOnAllPlatforms("can be configured to use a different trigger", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + tagRule: TagRule(trigger: "@", excludedCharacters: {" "}), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag. + await tester.typeImeText("@john"); + + // Ensure that we're composing an action tag. + var text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @john"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 11), + ); + }); + + testWidgetsOnAllPlatforms("does not continue when user expands the selection upstream", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ParagraphNode( + id: "2", + text: AttributedText(""), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Ensure we're composing a tag. + AttributedText text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 13), + ); + + // Expand the selection to "before /heade|r|" + await tester.pressShiftLeftArrow(); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 14), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 13), + ), + ), + ); + + // Ensure we're not composing anymore. + text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(7, 13), + ), + isEmpty, + ); + + // Expand the selection to "before |/header|" + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + + // Ensure we're still not composing. + text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(7, 13), + ), + isEmpty, + ); + + // Expand the selection to "befor|e /header|" + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + + // Ensure we're still not composing. + text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(7, 13), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("does not continue when user expands the selection downstream", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ParagraphNode( + id: "2", + text: AttributedText(), + ), + ], + ), + ); + + // Place the caret at "before | after" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Ensure we're composing a tag. + AttributedText text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 13), + ); + + // Move the caret to "before /|header". + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + + // Expand the selection to "before /|header a|fter" + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 8), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 16), + ), + ), + ); + + // Ensure we're not composing anymore. + text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(7, 13), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("cancels when the user moves the caret", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ParagraphNode( + id: "2", + text: AttributedText(), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Move the selection somewhere else. + await tester.placeCaretInParagraph("2", 0); + expect( + SuperEditorInspector.findDocumentSelection()!.extent, + const DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 0), + ), + ); + + // Ensure that the tag was submitted. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before /header"); + expect( + text.getAttributedRange({actionTagCancelledAttribution}, 7), + const SpanRange(7, 7), + ); + }); + + testWidgetsOnAllPlatforms("cancels when upstream selection collapses outside of tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ParagraphNode( + id: "2", + text: AttributedText(), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag + await tester.typeImeText("/header"); + + // Expand the selection to "befor|e /header|" + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + + // Collapse the selection to the upstream position. + await tester.pressLeftArrow(); + + // Ensure that the action tag was cancelled. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before /header"); + expect( + text.getAttributedRange({actionTagCancelledAttribution}, 7), + const SpanRange(7, 7), + ); + }); + + testWidgetsOnAllPlatforms("cancels when downstream selection collapses outside of tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ParagraphNode( + id: "2", + text: AttributedText(), + ), + ], + ), + ); + + // Place the caret at "before | after" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Move caret to "before /|header after" + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + + // Expand the selection to "before @|header a|fter" + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + + // Collapse the selection to the downstream position. + await tester.pressRightArrow(); + + // Ensure that the action tag was cancelled. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before /header after"); + expect( + text.getAttributedRange({actionTagCancelledAttribution}, 7), + const SpanRange(7, 7), + ); + }); + + testWidgetsOnAllPlatforms("cancels composing when the user presses ESC", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Start composing a tag. + await tester.typeImeText("/stuff"); + + // Ensure that we're composing. + var text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 12), + ); + + // Cancel composing. + await tester.pressEscape(); + + // Ensure that the composing was cancelled. + text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 12), + ), + isEmpty, + ); + expect( + text.getAttributedRange({actionTagCancelledAttribution}, 7), + const SpanRange(7, 7), + ); + + // Start typing again. + await tester.typeImeText(" "); + + // Ensure that we didn't start composing again. + text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before /stuff "); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 13), + ), + isEmpty, + ); + expect( + text.getAttributedRange({actionTagCancelledAttribution}, 7), + const SpanRange(7, 7), + ); + }); + + testWidgetsOnDesktop("cancels composing when deleting the trigger character", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |after" + await tester.placeCaretInParagraph("1", 7); + + // Start composing a tag. + await tester.typeImeText("/"); + + // Press backspace to delete the tag. + await tester.pressBackspace(); + + // Ensure nothing is attributed, because we didn't type any characters + // after the initial "/". + expect( + SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (candidate) => candidate == actionTagComposingAttribution, + range: const SpanRange(0, 13), + ), + isEmpty, + ); + + // Start composing the tag again. + await tester.typeImeText("/header"); + + // Ensure that "/header" is attributed. + final spans = SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 19), + ); + expect(spans.length, 1); + expect( + spans.first, + const AttributionSpan( + attribution: actionTagComposingAttribution, + start: 7, + end: 13, + ), + ); + }); + + testWidgetsOnMobile("cancels composing when deleting the trigger character with software keyboard", + (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |after" + await tester.placeCaretInParagraph("1", 7); + + // Start composing a tag. + await tester.typeImeText("/"); + + // Simulate the user pressing backspace on the software keyboard. + await tester.ime.sendDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: '. before /after', + selection: TextSelection(baseOffset: 9, extentOffset: 9), + composing: TextRange.empty, + ), + const TextEditingDeltaDeletion( + oldText: '. before /after', + deletedRange: TextSelection(baseOffset: 9, extentOffset: 10), + selection: TextSelection(baseOffset: 9, extentOffset: 9), + composing: TextRange.empty, + ), + ], getter: imeClientGetter); + + // Ensure nothing is attributed, because we didn't type any characters + // after the initial "/". + expect( + SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (candidate) => candidate == actionTagComposingAttribution, + range: const SpanRange(0, 13), + ), + isEmpty, + ); + + // Start composing the tag again. + await tester.typeImeText("/header"); + + // Ensure that "/header" is attributed. + final spans = SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 19), + ); + expect(spans.length, 1); + expect( + spans.first, + const AttributionSpan( + attribution: actionTagComposingAttribution, + start: 7, + end: 13, + ), + ); + }); + + testWidgetsOnAllPlatforms("does not re-apply a canceled tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before | after" + await tester.placeCaretInParagraph("1", 7); + + // Start composing a tag. + await tester.typeImeText("/"); + + // Ensure that we're composing. + var text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({actionTagComposingAttribution}, 7), + const SpanRange(7, 7), + ); + + // Move the caret to "before |/ after" + await tester.pressLeftArrow(); + + // Ensure we are not composing anymore. + expect( + SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (candidate) => candidate == actionTagComposingAttribution, + range: const SpanRange(0, 14), + ), + isEmpty, + ); + + // Move the caret to "before /| after" + await tester.pressRightArrow(); + + // Ensure we are still not composing. + expect( + SuperEditorInspector.findTextInComponent("1").getAttributionSpansInRange( + attributionFilter: (candidate) => candidate == actionTagComposingAttribution, + range: const SpanRange(0, 14), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("only notifies tag index listeners when tags change", (tester) async { + final actionTagPlugin = ActionTagsPlugin(); + + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + plugin: actionTagPlugin, + ); + await tester.placeCaretInParagraph("1", 0); + + // Listen for tag notifications. + int tagNotificationCount = 0; + actionTagPlugin.composingActionTag.addListener(() { + tagNotificationCount += 1; + }); + + // Type some non tag text. + await tester.typeImeText("hello "); + + // Ensure that no tag notifications were sent, because the typed text + // has no tag artifacts. + expect(tagNotificationCount, 0); + + // Start a tag. + await tester.typeImeText("/"); + + // Ensure that we received one notification when the tag started. + expect(tagNotificationCount, 1); + + // Create and update a tag. + await tester.typeImeText("world"); + + // Ensure that we received a notification for every character we typed. + expect(tagNotificationCount, 6); + + // Cancel the tag. + await tester.pressEscape(); + + // Ensure that we received a notification when the tag was cancelled. + expect(tagNotificationCount, 7); + }); + + testWidgetsOnAllPlatforms("does not start composing when placing the caret at an existing tag pattern", + (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("This is origin/main branch"), + ), + ], + ), + ); + + // Place the caret at "mai|n". + await tester.placeCaretInParagraph("1", 18); + + // Ensure that we are not composing a tag. + final text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 26), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("updates composing when moving the caret within an existing composing tag", + (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Ensure that the tag has a composing attribution. + final textBefore = SuperEditorInspector.findTextInComponent("1"); + expect( + textBefore.getAttributedRange({actionTagComposingAttribution}, 0), + const SpanRange(0, 6), + ); + + // Press the left arrow to move the caret within the tag. + await tester.pressLeftArrow(); + + // Ensure that the tag was updated. + final textAfter = SuperEditorInspector.findTextInComponent("1"); + expect( + textAfter.getAttributedRange({actionTagComposingAttribution}, 0), + const SpanRange(0, 5), + ); + }); + }); + + group("submissions >", () { + testWidgetsOnAllPlatforms("at the beginning of a paragraph", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + + // Place the caret in the empty paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Submit the tag. + await tester.pressEnter(); + + // Ensure that the action tag was removed after submission. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), ""); + }); + + testWidgetsOnAllPlatforms("after existing text", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag + await tester.typeImeText("/header"); + + // Submit the tag. + await tester.pressEnter(); + + // Ensure that the action tag was removed. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before "); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 6), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("in the middle of text", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before | after" + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag + await tester.typeImeText("/header"); + + // Submit the tag. + await tester.pressEnter(); + + // Ensure that the action tag was removed. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before after"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 12), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("at the beginning of a word", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |after". + await tester.placeCaretInParagraph("1", 7); + + // Compose an action tag. + await tester.typeImeText("/header"); + + // Ensure only "/header" is attributed. + AttributedText? text = SuperEditorInspector.findTextInComponent("1"); + final spans = text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 19), + ); + expect(spans.length, 1); + expect( + spans.first, + const AttributionSpan( + attribution: actionTagComposingAttribution, + start: 7, + end: 13, + ), + ); + + // Submit the tag. + await tester.pressEnter(); + + // Ensure that the action tag was removed. + text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before after"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: const SpanRange(0, 12), + ), + isEmpty, + ); + }); + }); + }); + + group("selections >", () { + testWidgetsOnArbitraryDesktop('does not extract a tag when the selection is expanded', (tester) async { + await _pumpTestEditor( + tester, + MutableDocument(nodes: [ + ParagraphNode(id: '1', text: AttributedText('A paragraph')), + // It's important that the second paragraph is longer than the first to ensure + // that we don't try to access a character in the first paragraph using an index + // from the second paragraph. + ParagraphNode(id: '2', text: AttributedText('Another paragraph with longer text')), + ]), + ); + + // Place the caret at the end of the second paragraph. + await tester.placeCaretInParagraph('2', 34); + + // Press CMD + SHIFT + ARROW UP to expand the selection to the beginning of + // the document. + await tester.pressShiftCmdUpArrow(); + + // Ensure nothing in the first paragraph is attributed. + final firstParagraphText = SuperEditorInspector.findTextInComponent("1"); + expect( + firstParagraphText.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: SpanRange(0, firstParagraphText.length), + ), + isEmpty, + ); + + // Ensure nothing in the second paragraph is attributed. + final secondParagraphText = SuperEditorInspector.findTextInComponent("1"); + expect( + secondParagraphText.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: SpanRange(0, secondParagraphText.length), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("does not extract a tag when expanding the selection from a non-text node", + (tester) async { + await _pumpTestEditor( + tester, + paragraphThenHrDoc(), + ); + + // Create cancelled action tag + await tester.placeCaretInParagraph("1", 0); + await tester.typeImeText("/header "); + + // Place cursor at the end of the horizontal rule/block node + await tester.pressDownArrow(); + await tester.pressRightArrow(); + + // Expand the selection to the first paragraph. + await tester.pressShiftLeftArrow(); + await tester.pressShiftUpArrow(); + + // Ensure nothing in the paragraph is attributed. + final text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == actionTagComposingAttribution, + range: SpanRange(0, text.length), + ), + isEmpty, + ); + }); + }); +} + +Future _pumpTestEditor( + WidgetTester tester, + MutableDocument document, { + TagRule? tagRule, + ActionTagsPlugin? plugin, +}) async { + assert(tagRule == null || plugin == null, + "You can provide a custom tagRule, or a custom ActionsTagPlugin, but not both"); + + final actionTagPlugin = plugin ?? ActionTagsPlugin(tagRule: tagRule ?? defaultActionTagRule); + + return await tester // + .createDocument() + .withCustomContent(document) + .withAddedKeyboardActions(prepend: [ + // In real apps, the app needs to decide when to submit an action tag. + // For the purpose of tests, we'll arbitrarily choose to submit on enter. + _submitOnEnter, + ]) + .withPlugin(actionTagPlugin) + .pump(); +} + +ExecutionInstruction _submitOnEnter({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.enter) { + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + const SubmitComposingActionTagRequest(), + ]); + + return ExecutionInstruction.haltExecution; +} diff --git a/super_editor/test/super_editor/text_entry/tagging/pattern_tags_test.dart b/super_editor/test/super_editor/text_entry/tagging/pattern_tags_test.dart new file mode 100644 index 0000000000..30390adf78 --- /dev/null +++ b/super_editor/test/super_editor/text_entry/tagging/pattern_tags_test.dart @@ -0,0 +1,524 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_documents.dart'; + +void main() { + group("SuperEditor pattern tags >", () { + group("composing >", () { + testWidgetsOnAllPlatforms("doesn't attribute a single #", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Insert a single "#". + await tester.typeImeText("#"); + + // Ensure that no hash tag was created. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "#"); + expect( + text.hasAttributionAt(0, attribution: const PatternTagAttribution()), + isFalse, + ); + }); + + testWidgetsOnAllPlatforms("can start at the beginning of a paragraph", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Compose a pattern tag. + await tester.typeImeText("#flutter"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "#flutter"); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 0), + const SpanRange(0, 7), + ); + }); + + testWidgetsOnAllPlatforms("can start between words", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |". + await tester.placeCaretInParagraph("1", 7); + + // Compose a pattern tag. + await tester.typeImeText("#flutter"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before #flutter after"); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 7), + const SpanRange(7, 14), + ); + }); + + testWidgetsOnAllPlatforms("can start at the beginning of an existing word", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before flutter after"), + ), + ], + ), + ); + + // Place the caret at "before |flutter". + await tester.placeCaretInParagraph("1", 7); + + // Type the trigger to start composing a tag. + await tester.typeImeText("#"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "before #flutter after"); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 7), + const SpanRange(7, 14), + ); + }); + + testWidgetsOnAllPlatforms("removes tag when deleting back to the #", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Compose a pattern tag. + await tester.typeImeText("#flutter"); + + // Delete all the way back to the "#". + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Ensure that the tag doesn't have a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "#"); + expect( + text.hasAttributionAt(0, attribution: const PatternTagAttribution()), + isFalse, + ); + }); + + testWidgetsOnAllPlatforms("does not continue after a space", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |". + await tester.placeCaretInParagraph("1", 7); + + // Compose a hash tag. + await tester.typeImeText("#flutter after"); + + // Ensure that there's no more composing attribution because the tag + // should have been committed. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before #flutter after"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution is PatternTagAttribution, + range: const SpanRange(0, 18), + ), + { + const AttributionSpan( + attribution: PatternTagAttribution(), + start: 7, + end: 14, + ), + }, + ); + }); + + testWidgetsOnAllPlatforms("does not continue after a period", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |". + await tester.placeCaretInParagraph("1", 7); + + // Compose a hash tag with a period after it. + await tester.typeImeText("#flutter. after"); + + // Ensure that the hash tag doesn't include the period. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before #flutter. after"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution is PatternTagAttribution, + range: const SpanRange(0, 19), + ), + { + const AttributionSpan( + attribution: PatternTagAttribution(), + start: 7, + end: 14, + ), + }, + ); + }); + + testWidgetsOnAllPlatforms("shrinks to wherever a period is added", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |". + await tester.placeCaretInParagraph("1", 7); + + // Compose a hash tag. + await tester.typeImeText("#flutterdart"); + + // Insert a period between "flutter" and "dart". + await tester.placeCaretInParagraph("1", 15); + await tester.typeImeText("."); + + // Ensure that the hash tag shrunk to where the period was inserted. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before #flutter.dart"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution is PatternTagAttribution, + range: const SpanRange(0, 19), + ), + { + const AttributionSpan( + attribution: PatternTagAttribution(), + start: 7, + end: 14, + ), + }, + ); + }); + + testWidgetsOnAllPlatforms("can create pattern tags back to back (no space)", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Compose a hash tag. + await tester.typeImeText("hello #flutter#d"); + + var text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "hello #flutter#d"); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 6), + const SpanRange(6, 13), + ); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 14), + const SpanRange(14, 15), + ); + + // Finish the second hash tag. + await tester.typeImeText("art"); + + // Ensure that the tag has a composing attribution. + text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "hello #flutter#dart"); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 6), + const SpanRange(6, 13), + ); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 14), + const SpanRange(14, 18), + ); + }); + + testWidgetsOnAllPlatforms("can create pattern tags back to back (with a space)", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Compose a pattern tag. + await tester.typeImeText("hello #flutter #dart"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "hello #flutter #dart"); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 6), + const SpanRange(6, 13), + ); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 15), + const SpanRange(15, 19), + ); + }); + + testWidgetsOnAllPlatforms("only notifies tag index listeners when tags change", (tester) async { + final testContext = await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Listen for tag notifications. + int tagNotificationCount = 0; + testContext.editor.context.patternTagIndex.addListener(() { + tagNotificationCount += 1; + }); + + // Type some non pattern text. + await tester.typeImeText("hello "); + + // Ensure that no tag notifications were sent, because the typed text + // has no tag artifacts. + expect(tagNotificationCount, 0); + + // Start a tag. + await tester.typeImeText("#"); + + // Ensure that no tag notifications were sent, because we haven't completed + // a tag. + expect(tagNotificationCount, 0); + + // Create and update a tag. + await tester.typeImeText("world"); + + // Ensure that we received a notification for every letter in the tag. + expect(tagNotificationCount, 5); + }); + }); + + group("caret placement >", () { + testWidgetsOnAllPlatforms("doesn't prevent user from tapping to place caret in tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |". + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a hash tag. + await tester.typeImeText("#flutter after"); + + // Tap near the end of the tag. + await tester.placeCaretInParagraph("1", 10); + + // Ensure that the caret was placed where tapped. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + ), + ); + + // Tap near the beginning of the tag. + await tester.placeCaretInParagraph("1", 8); + + // Ensure that the caret was placed where tapped. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("pushes expanding downstream selection into the tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |". + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a hash tag. + await tester.typeImeText("#flutter after"); + + // Place the caret at "befor|e #flutter after". + await tester.placeCaretInParagraph("1", 5); + + // Expand downstream until we push one character into the tag. + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + + // Ensure that the extent was pushed into the tag. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 5), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 8), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("pushes expanding upstream selection into the tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |". + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a hash tag. + await tester.typeImeText("#flutter after"); + + // Place the caret at "before #flutter a|fter". + await tester.placeCaretInParagraph("1", 14); + + // Expand upstream until we push one character into the tag. + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + + // Ensure that the extent was pushed into the tag. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 14), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 11), + ), + ), + ); + }); + }); + + group("editing >", () { + testWidgetsOnAllPlatforms("user can delete pieces of tags", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Compose a pattern tag. + await tester.typeImeText("#abcdefghij "); + + // Delete part of the end. + await tester.placeCaretInParagraph("1", 11); + await tester.pressBackspace(); + + // Delete part of the middle. + await tester.placeCaretInParagraph("1", 6); + await tester.pressBackspace(); + + // Delete part of the beginning. + await tester.placeCaretInParagraph("1", 2); + await tester.pressBackspace(); + + // Ensure that the tag is still marked as a hash tag. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "#bcdfghi "); + expect( + text.getAttributedRange({const PatternTagAttribution()}, 0), + const SpanRange(0, 7), + ); + }); + }); + }); +} + +Future _pumpTestEditor(WidgetTester tester, MutableDocument document) async { + return await tester // + .createDocument() + .withCustomContent(document) + .withPlugin(PatternTagPlugin( + tagRule: hashTagRule, + )) + .pump(); +} diff --git a/super_editor/test/super_editor/text_entry/tagging/stable_tags_test.dart b/super_editor/test/super_editor/text_entry/tagging/stable_tags_test.dart new file mode 100644 index 0000000000..0083339556 --- /dev/null +++ b/super_editor/test/super_editor/text_entry/tagging/stable_tags_test.dart @@ -0,0 +1,1333 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import '../../test_documents.dart'; + +void main() { + group("SuperEditor stable tags >", () { + group("composing >", () { + testWidgetsOnAllPlatforms("starts with a trigger", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Compose a stable tag. + await tester.typeImeText("@"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "@"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 0), + const SpanRange(0, 0), + ); + }); + + testWidgetsOnAllPlatforms("can start at the beginning of a paragraph", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Compose a stable tag. + await tester.typeImeText("@john"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "@john"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 0), + const SpanRange(0, 4), + ); + }); + + testWidgetsOnAllPlatforms("can start between words", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose a stable tag. + await tester.typeImeText("@john"); + + // Ensure that the tag has a composing attribution. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @john after"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 7), + const SpanRange(7, 11), + ); + }); + + testWidgetsOnAllPlatforms("can start at the beginning of an existing word", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before john after"), + ), + ], + ), + ); + + // Place the caret at "before |john" + await tester.placeCaretInParagraph("1", 7); + + // Type the trigger to start composing a tag. + await tester.typeImeText("@"); + + // Ensure that "@john" was attributed. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "before @john after"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 7), + const SpanRange(7, 11), + ); + }); + + testWidgetsOnAllPlatforms("by default does not continue after a space", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose a stable tag. + await tester.typeImeText("@john after"); + + // Ensure that there's no more composing attribution because the tag + // should have been committed. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @john after"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == stableTagComposingAttribution, + range: const SpanRange(0, 18), + ), + isEmpty, + ); + }); + + testWidgetsOnAllPlatforms("can be configured to continue after a space", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + TagRule(trigger: "@"), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose a stable tag. + await tester.typeImeText("@john"); + + // Ensure that we started composing a tag before adding a space. + var text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @john"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 7), + const SpanRange(7, 11), + ); + + await tester.typeImeText(" after"); + + // Ensure that the composing attribution continues after the space. + text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @john after"); + expect( + text.getAttributionSpansByFilter((a) => a == stableTagComposingAttribution), + { + const AttributionSpan( + attribution: stableTagComposingAttribution, + start: 7, + end: 17, + ), + }, + ); + }); + + testWidgetsOnAllPlatforms("continues when user expands the selection upstream", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ParagraphNode( + id: "2", + text: AttributedText(), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose a stable tag. + await tester.typeImeText("@john"); + + // Expand the selection to "before @joh|n|" + await tester.pressShiftLeftArrow(); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 11), + ), + ), + ); + + // Ensure we're still composing + AttributedText text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 7), + const SpanRange(7, 11), + ); + + // Expand the selection to "before |@john|" + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + + // Ensure we're still composing + text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 7), + const SpanRange(7, 11), + ); + + // Expand the selection to "befor|e @john|" + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + + // Ensure we're still composing + text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 7), + const SpanRange(7, 11), + ); + }); + + testWidgetsOnAllPlatforms("continues when user expands the selection downstream", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ParagraphNode( + id: "2", + text: AttributedText(), + ), + ], + ), + ); + + // Place the caret at "before | after" + await tester.placeCaretInParagraph("1", 7); + + // Compose a stable tag. + await tester.typeImeText("@john"); + + // Move the caret to "before @|john". + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + + // Expand the selection to "before @|john a|fter" + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 8), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 14), + ), + ), + ); + + // Ensure we're still composing + AttributedText text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 7), + const SpanRange(7, 11), + ); + }); + + testWidgetsOnAllPlatforms("cancels composing when the user presses ESC", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Start composing a stable tag. + await tester.typeImeText("@"); + + // Ensure that we're composing. + var text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributedRange({stableTagComposingAttribution}, 7), + const SpanRange(7, 7), + ); + + // Cancel composing. + await tester.pressEscape(); + + // Ensure that the composing was cancelled. + text = SuperEditorInspector.findTextInComponent("1"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == stableTagComposingAttribution, + range: const SpanRange(0, 7), + ), + isEmpty, + ); + expect( + text.getAttributedRange({stableTagCancelledAttribution}, 7), + const SpanRange(7, 7), + ); + + // Start typing again. + await tester.typeImeText("j"); + + // Ensure that we didn't start composing again. + text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @j"); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == stableTagComposingAttribution, + range: const SpanRange(0, 8), + ), + isEmpty, + ); + expect( + text.getAttributedRange({stableTagCancelledAttribution}, 7), + const SpanRange(7, 7), + ); + + // Add a space, cause the tag to end. + await tester.typeImeText(" "); + + // Ensure that the cancelled tag wasn't committed, and didn't start composing again. + text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @j "); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution == stableTagComposingAttribution, + range: const SpanRange(0, 9), + ), + isEmpty, + ); + expect( + text.getAttributionSpansInRange( + attributionFilter: (attribution) => attribution is CommittedStableTagAttribution, + range: const SpanRange(0, 9), + ), + isEmpty, + ); + expect( + text.getAttributedRange({stableTagCancelledAttribution}, 7), + const SpanRange(7, 7), + ); + }); + + testWidgetsOnAllPlatforms("only notifies tag index listeners when tags change", (tester) async { + final testContext = await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + await tester.placeCaretInParagraph("1", 0); + + // Listen for tag notifications. + int tagNotificationCount = 0; + testContext.editor.context.stableTagIndex.addListener(() { + tagNotificationCount += 1; + }); + + // Type some non tag text. + await tester.typeImeText("hello "); + + // Ensure that no tag notifications were sent, because the typed text + // has no tag artifacts. + expect(tagNotificationCount, 0); + + // Start a tag. + await tester.typeImeText("@"); + + // Ensure that no tag notifications were sent, because we haven't completed + // a tag. + expect(tagNotificationCount, 0); + + // Create and update a tag. + await tester.typeImeText("world "); + + // Ensure that we received a notification when the tag was committed. + expect(tagNotificationCount, 1); + + // Delete the committed tag. + await tester.pressBackspace(); + await tester.pressBackspace(); + + // Ensure that we received a notification when the tag was deleted. + expect(tagNotificationCount, 2); + + // Create a tag and then cancel it. + await tester.typeImeText("@cancelled"); + await tester.pressEscape(); + + // Ensure that we received a notification when the tag was cancelled. + expect(tagNotificationCount, 3); + }); + }); + + group("commits >", () { + testWidgetsOnAllPlatforms("at the beginning of a paragraph", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + + // Place the caret in the empty paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Compose a stable tag. + await tester.typeImeText("@john after"); + + // Ensure that only the stable tag is attributed as a stable tag. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "@john after"); + expect( + text.getAttributedRange({const CommittedStableTagAttribution("john")}, 0), + const SpanRange(0, 4), + ); + }); + + testWidgetsOnAllPlatforms("at the beginning of an existing word", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before john after"), + ), + ], + ), + ); + + // Place the caret at "before |john" + await tester.placeCaretInParagraph("1", 7); + + // Type the trigger to start composing a tag. + await tester.typeImeText("@"); + + // Press left arrow to move away and commit the tag. + await tester.pressLeftArrow(); + + // Ensure that "@john" was attributed. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.text, "before @john after"); + expect( + text.getAttributedRange({const CommittedStableTagAttribution("john")}, 7), + const SpanRange(7, 11), + ); + }); + + testWidgetsOnAllPlatforms("after existing text", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose a stable tag. + await tester.typeImeText("@john after"); + + // Ensure that only the stable tag is attributed as a stable tag. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @john after"); + expect( + text.getAttributedRange({const CommittedStableTagAttribution("john")}, 7), + const SpanRange(7, 11), + ); + }); + + testWidgetsOnAllPlatforms("at end of text when user moves the caret", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ParagraphNode( + id: "2", + text: AttributedText(), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose a stable tag. + await tester.typeImeText("@john"); + + // Move the selection somewhere else. + await tester.placeCaretInParagraph("2", 0); + expect( + SuperEditorInspector.findDocumentSelection()!.extent, + const DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 0), + ), + ); + + // Ensure that the tag was submitted. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @john"); + expect( + text.getAttributedRange({const CommittedStableTagAttribution("john")}, 7), + const SpanRange(7, 11), + ); + }); + + testWidgetsOnAllPlatforms("when upstream selection collapses outside of tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ParagraphNode( + id: "2", + text: AttributedText(), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose a stable tag. + await tester.typeImeText("@john"); + + // Expand the selection to "befor|e @john|" + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + + // Collapse the selection to the upstream position. + await tester.pressLeftArrow(); + + // Ensure that the stable tag was submitted. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @john"); + expect( + text.getAttributedRange({const CommittedStableTagAttribution("john")}, 7), + const SpanRange(7, 11), + ); + }); + + testWidgetsOnAllPlatforms("when downstream selection collapses outside of tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before after"), + ), + ParagraphNode( + id: "2", + text: AttributedText(), + ), + ], + ), + ); + + // Place the caret at "before | after" + await tester.placeCaretInParagraph("1", 7); + + // Compose a stable tag. + await tester.typeImeText("@john"); + + // Move caret to "before @|john after" + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + + // Expand the selection to "before @|john a|fter" + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + + // Collapse the selection to the downstream position. + await tester.pressRightArrow(); + + // Ensure that the stable tag was submitted. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "before @john after"); + expect( + text.getAttributedRange({const CommittedStableTagAttribution("john")}, 7), + const SpanRange(7, 11), + ); + }); + }); + + group("committed >", () { + testWidgetsOnAllPlatforms("prevents user tapping to place caret in tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a stable tag. + await tester.typeImeText("@john after"); + + // Tap near the end of the tag. + await tester.placeCaretInParagraph("1", 10); + + // Ensure that the caret was pushed beyond the end of the tag. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + ), + ); + + // Tap near the beginning of the tag. + await tester.placeCaretInParagraph("1", 8); + + // Ensure that the caret was pushed beyond the beginning of the tag. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("selects entire tag when double tapped", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a stable tag. + await tester.typeImeText("@john after"); + + // Double tap on "john" + await tester.doubleTapInParagraph("1", 10); + + // Ensure that the selection surrounds the full tag, including the "@" + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("pushes caret downstream around the tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a stable tag. + await tester.typeImeText("@john after"); + + // Place the caret at "befor|e @john after" + await tester.placeCaretInParagraph("1", 5); + + // Push the caret downstream until we push one character into the tag. + await tester.pressRightArrow(); + await tester.pressRightArrow(); + await tester.pressRightArrow(); + + // Ensure that the caret was pushed beyond the end of the tag. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("pushes caret upstream around the tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a stable tag. + await tester.typeImeText("@john after"); + + // Place the caret at "before @john a|fter" + await tester.placeCaretInParagraph("1", 14); + + // Push the caret upstream until we push one character into the tag. + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + await tester.pressLeftArrow(); + + // Ensure that the caret pushed beyond the beginning of the tag. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("pushes expanding downstream selection around the tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a stable tag. + await tester.typeImeText("@john after"); + + // Place the caret at "befor|e @john after" + await tester.placeCaretInParagraph("1", 5); + + // Expand downstream until we push one character into the tag. + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + + // Ensure that the extent was pushed beyond the end of the tag. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 5), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("pushes expanding upstream selection around the tag", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a stable tag. + await tester.typeImeText("@john after"); + + // Place the caret at "before @john a|fter" + await tester.placeCaretInParagraph("1", 14); + + // Expand upstream until we push one character into the tag. + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); + + // Ensure that the extent was pushed beyond the beginning of the tag. + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 14), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("deletes entire tag when deleting a character upstream", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a stable tag. + await tester.typeImeText("@john after"); + + // Place the caret at "before @john| after" + await tester.placeCaretInParagraph("1", 12); + + // Press BACKSPACE to delete a character upstream. + await tester.pressBackspace(); + + // Ensure that the entire user tag was deleted. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "before after"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("deletes entire tag when deleting a character downstream", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("before "), + ), + ], + ), + ); + + // Place the caret at "before |" + await tester.placeCaretInParagraph("1", 7); + + // Compose and submit a stable tag. + await tester.typeImeText("@john after"); + + // Place the caret at "before |@john after" + await tester.placeCaretInParagraph("1", 7); + + // Press DELETE to delete a character downstream. + await tester.pressDelete(); + + // Ensure that the entire user tag was deleted. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "before after"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("deletes second tag and leaves first tag alone", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument.empty("1"), + ); + + await tester.placeCaretInParagraph("1", 0); + + // Compose two tags within text + await tester.typeImeText("one @john two @sally three"); + + // Place the caret at "one @john two @sally| three" + await tester.placeCaretInParagraph("1", 20); + + // Delete the 2nd tag. + await tester.pressBackspace(); + + // Ensure the 2nd tag was deleted, and the 1st tag remains. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "one @john two three"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 14), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("deletes multiple tags when partially selected in the same node", (tester) async { + final context = await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("one "), + ), + ], + ), + ); + + // Place the caret at "one |" + await tester.placeCaretInParagraph("1", 4); + + // Compose and submit two stable tags. + await tester.typeImeText("@john two @sally three"); + + // Expand the selection "one @jo|hn two @sa|lly three" + (context.findEditContext().composer as MutableDocumentComposer).setSelectionWithReason( + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + SelectionReason.userInteraction, + ); + + // Delete the selected content, which will leave two partial user tags. + await tester.pressBackspace(); + + // Ensure that both user tags were completely deleted. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "one three"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + ); + }); + + testWidgetsOnAllPlatforms("deletes multiple tags when partially selected across multiple nodes", (tester) async { + final context = await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText(), + ), + ParagraphNode( + id: "2", + text: AttributedText(), + ), + ], + ), + ); + + // Place the caret in the first paragraph and insert a user tag. + await tester.placeCaretInParagraph("1", 0); + await tester.typeImeText("one @john two"); + + // Move the caret to the second paragraph and insert a second user tag. + await tester.placeCaretInParagraph("2", 0); + await tester.typeImeText("three @sally four"); + + // Expand the selection to "one @jo|hn two\nthree @sa|lly three" + (context.findEditContext().composer as MutableDocumentComposer).setSelectionWithReason( + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 7), + ), + extent: DocumentPosition( + nodeId: "2", + nodePosition: TextNodePosition(offset: 9), + ), + ), + SelectionReason.userInteraction, + ); + + // Delete the selected content, which will leave two partial user tags. + await tester.pressBackspace(); + + // Ensure that both user tags were completely deleted. + expect(SuperEditorInspector.findTextInComponent("1").toPlainText(), "one four"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + ); + }); + }); + + group("emoji >", () { + testWidgetsOnAllPlatforms("can be typed as first character of a paragraph without crashing the editor", + (tester) async { + // Ensure we can type an emoji as first character without crashing + // https://github.com/superlistapp/super_editor/issues/1863 + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Type an emoji as first character 💙 + await tester.typeImeText("💙"); + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 2), + ), + ), + ); + + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "💙"); + }); + + testWidgetsOnAllPlatforms("caret can move around emoji without breaking editor", (tester) async { + // We are doing this to ensure the plugin doesn't make the editor crash when moving caret around emoji. + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("💙"), + ), + ], + ), + ); + + // Place the caret before the emoji: |💙 + await tester.placeCaretInParagraph("1", 0); + + // Place the caret after the emoji: 💙| + await tester.pressRightArrow(); + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 2), + ), + ), + ); + + // Move the caret back to initial position, before the emoji: |💙. + await tester.pressLeftArrow(); + + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + + // Ensure the paragraph string is well formed: 💙 + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "💙"); + }); + + testWidgetsOnAllPlatforms("can be captured with trigger", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Type @ to trigger a composing tag, followed by an emoji 💙 + await tester.typeImeText("@💙"); + + // Ensure the emoji is in the tag, and nothing went wrong with string formation. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "@💙"); + + // Ensure the composing tag includes the emoji. + expect( + SuperEditorInspector.findTextInComponent("1") + .getAttributionSpansByFilter((a) => a == stableTagComposingAttribution), + { + const AttributionSpan( + attribution: stableTagComposingAttribution, + start: 0, + end: 2, + ), + }, + ); + + // Commit the tag. + await tester.typeImeText(" "); + + // Ensure the committed tag is the emoji and the composing tag is removed + expect( + SuperEditorInspector.findTextInComponent("1") + .getAttributionSpansByFilter((a) => a is CommittedStableTagAttribution), + { + const AttributionSpan( + attribution: CommittedStableTagAttribution("💙"), + start: 0, + end: 2, + ), + }, + ); + }); + + testWidgetsOnAllPlatforms("can be used before a trigger", (tester) async { + await _pumpTestEditor( + tester, + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("💙"), + ), + ], + ), + ); + + // Place the caret after the emoji: 💙| + await tester.placeCaretInParagraph("1", 2); + + // Type @ to trigger a composing tag: 💙@ + await tester.typeImeText("@"); + + // Ensure nothing went wrong with the string construction. + final text = SuperEditorInspector.findTextInComponent("1"); + expect(text.toPlainText(), "💙@"); + + // Ensure the tag was committed with the emoji. + expect( + SuperEditorInspector.findTextInComponent("1") + .getAttributionSpansByFilter((a) => a == stableTagComposingAttribution), + { + const AttributionSpan( + attribution: stableTagComposingAttribution, + start: 2, + end: 2, + ), + }, + ); + }); + + testWidgetsOnAllPlatforms("can be used in the middle of a tag", (tester) async { + await _pumpTestEditor( + tester, + singleParagraphEmptyDoc(), + ); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Start composing a tag with an emoji in the middle + await tester.typeImeText("@Flutter💙SuperEditor "); + + // Ensure the tag was committed with the emoji. + expect( + SuperEditorInspector.findTextInComponent("1") + .getAttributionSpansByFilter((a) => a is CommittedStableTagAttribution), + { + const AttributionSpan( + attribution: CommittedStableTagAttribution("Flutter💙SuperEditor"), + start: 0, + end: 20, + ), + }, + ); + }); + }); + }); +} + +Future _pumpTestEditor( + WidgetTester tester, + MutableDocument document, [ + TagRule? tagRule, +]) async { + tagRule ??= userTagRule; + + return await tester + .createDocument() + .withCustomContent(document) + .withPlugin(StableTagPlugin( + tagRule: tagRule, + )) + .pump(); +} diff --git a/super_editor/test/src/default_editor/text_test.dart b/super_editor/test/super_editor/text_entry/text_test.dart similarity index 60% rename from super_editor/test/src/default_editor/text_test.dart rename to super_editor/test/super_editor/text_entry/text_test.dart index 9484ad28bb..1a8029e256 100644 --- a/super_editor/test/src/default_editor/text_test.dart +++ b/super_editor/test/super_editor/text_entry/text_test.dart @@ -1,11 +1,11 @@ import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; -import '../_document_test_tools.dart'; -import '../_text_entry_test_tools.dart'; - -void main() { +Future main() async { group('text.dart', () { group('ToggleTextAttributionsCommand', () { test('it toggles selected text and nothing more', () { @@ -13,14 +13,15 @@ void main() { nodes: [ ParagraphNode( id: 'paragraph', - text: AttributedText(text: ' make me bold '), + text: AttributedText(' make me bold '), ) ], ); - final editor = DocumentEditor(document: document); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); - final command = ToggleTextAttributionsCommand( - documentSelection: const DocumentSelection( + final request = ToggleTextAttributionsRequest( + documentRange: const DocumentSelection( base: DocumentPosition( nodeId: 'paragraph', nodePosition: TextNodePosition(offset: 1), @@ -38,9 +39,9 @@ void main() { attributions: {boldAttribution}, ); - editor.executeCommand(command); + editor.execute([request]); - final boldedText = (document.nodes.first as ParagraphNode).text; + final boldedText = (document.first as ParagraphNode).text; expect(boldedText.getAllAttributionsAt(0), {}); expect(boldedText.getAllAttributionsAt(1), {boldAttribution}); expect(boldedText.getAllAttributionsAt(12), {boldAttribution}); @@ -49,53 +50,17 @@ void main() { }); group('TextComposable text entry', () { - test('it does nothing when meta is pressed', () { - final editContext = _createEditContext(); - - // Press just the meta key. - var result = anyCharacterToInsertInTextContent( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.meta, - physicalKey: PhysicalKeyboardKey.metaLeft, - isMetaPressed: true, - isModifierKeyPressed: false, - ), - ), - ); - - // The handler should pass on handling the key. - expect(result, ExecutionInstruction.continueExecution); - - // Press "a" + meta key - result = anyCharacterToInsertInTextContent( - editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - isMetaPressed: true, - isModifierKeyPressed: false, - ), - ), - ); - // The handler should pass on handling the key. - expect(result, ExecutionInstruction.continueExecution); - }); - - test('it does nothing when nothing is selected', () { + test('it does nothing when nothing is selected', () async { final editContext = _createEditContext(); // Try to type a character. var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), + keyEvent: const KeyDownEvent( + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, ), ); @@ -107,33 +72,38 @@ void main() { final editContext = _createEditContext(); // Add a paragraph to the document. - (editContext.editor.document as MutableDocument).add( + (editContext.document as MutableDocument).add( ParagraphNode( id: 'paragraph', - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); // Select multiple characters in the paragraph - editContext.composer.selection = const DocumentSelection( - base: DocumentPosition( - nodeId: 'paragraph', - nodePosition: TextNodePosition(offset: 0), - ), - extent: DocumentPosition( - nodeId: 'paragraph', - nodePosition: TextNodePosition(offset: 1), + editContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: 'paragraph', + nodePosition: TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: 'paragraph', + nodePosition: TextNodePosition(offset: 1), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, ), - ); + ]); // Try to type a character. var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), + keyEvent: const KeyDownEvent( + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, ), ); @@ -145,26 +115,31 @@ void main() { final editContext = _createEditContext(); // Add a non-text node to the document. - (editContext.editor.document as MutableDocument).add( + (editContext.document as MutableDocument).add( HorizontalRuleNode(id: 'horizontal_rule'), ); // Select the horizontal rule node. - editContext.composer.selection = const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: 'horizontal_rule', - nodePosition: UpstreamDownstreamNodePosition.downstream(), + editContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: 'horizontal_rule', + nodePosition: UpstreamDownstreamNodePosition.downstream(), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, ), - ); + ]); // Try to type a character. var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), + keyEvent: const KeyDownEvent( + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, ), ); @@ -172,35 +147,39 @@ void main() { expect(result, ExecutionInstruction.continueExecution); }); - test('it does nothing when the key doesn\'t have a character', () { + testWidgets('it does nothing when the key doesn\'t have a character', (WidgetTester tester) async { final editContext = _createEditContext(); // Add a paragraph to the document. - (editContext.editor.document as MutableDocument).add( + (editContext.document as MutableDocument).add( ParagraphNode( id: 'paragraph', - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); // Select multiple characters in the paragraph - editContext.composer.selection = const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: 'paragraph', - nodePosition: TextNodePosition(offset: 0), + editContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: 'paragraph', + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, ), - ); + ]); // Press the "alt" key var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyEvent( + keyEvent: const KeyDownEvent( character: null, - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.alt, - physicalKey: PhysicalKeyboardKey.altLeft, - isModifierKeyPressed: true, - ), + logicalKey: LogicalKeyboardKey.alt, + physicalKey: PhysicalKeyboardKey.altLeft, + timeStamp: Duration.zero, ), ); @@ -210,12 +189,11 @@ void main() { // Press the "enter" key result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyEvent( + keyEvent: const KeyDownEvent( character: '', // Empirically, pressing enter sends '' as the character instead of null - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.enter, - physicalKey: PhysicalKeyboardKey.enter, - ), + logicalKey: LogicalKeyboardKey.enter, + physicalKey: PhysicalKeyboardKey.enter, + timeStamp: Duration.zero, ), ); @@ -227,76 +205,86 @@ void main() { final editContext = _createEditContext(); // Add a paragraph to the document. - (editContext.editor.document as MutableDocument).add( + (editContext.document as MutableDocument).add( ParagraphNode( id: 'paragraph', - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); // Select multiple characters in the paragraph - editContext.composer.selection = const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: 'paragraph', - nodePosition: TextNodePosition(offset: 0), + editContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: 'paragraph', + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, ), - ); + ]); // Press the "a" key var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyEvent( + keyEvent: const KeyDownEvent( character: 'a', - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, ), ); // The handler should insert a character expect(result, ExecutionInstruction.haltExecution); expect( - (editContext.editor.document.nodes.first as TextNode).text.text, + (editContext.document.first as TextNode).text.toPlainText(), 'aThis is some text', ); }); - test('it inserts a non-English character', () { + testWidgets('it inserts a non-English character', (WidgetTester tester) async { final editContext = _createEditContext(); // Add a paragraph to the document. - (editContext.editor.document as MutableDocument).add( + (editContext.document as MutableDocument).add( ParagraphNode( id: 'paragraph', - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); // Select multiple characters in the paragraph - editContext.composer.selection = const DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: 'paragraph', - nodePosition: TextNodePosition(offset: 0), + editContext.editor.execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: 'paragraph', + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, ), - ); + ]); // Type a non-English character var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyEvent( + keyEvent: const KeyDownEvent( character: 'ß', - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, ), ); // The handler should insert a character expect(result, ExecutionInstruction.haltExecution); expect( - (editContext.editor.document.nodes.first as TextNode).text.text, + (editContext.document.first as TextNode).text.toPlainText(), 'ßThis is some text', ); }); @@ -307,7 +295,7 @@ void main() { test('throws if passed other types of NodePosition', () { final node = TextNode( id: 'text node', - text: AttributedText(text: 'text'), + text: AttributedText('text'), ); expect( () => node.computeSelection( @@ -321,7 +309,7 @@ void main() { test('preserves the affinity of extent', () { final node = TextNode( id: 'text node', - text: AttributedText(text: 'text'), + text: AttributedText('text'), ); final selectionWithUpstream = node.computeSelection( @@ -375,17 +363,21 @@ void main() { }); } -EditContext _createEditContext() { +SuperEditorContext _createEditContext() { final document = MutableDocument(); - final documentEditor = DocumentEditor(document: document); + final composer = MutableDocumentComposer(); + final documentEditor = createDefaultDocumentEditor(document: document, composer: composer); final fakeLayout = FakeDocumentLayout(); - final composer = DocumentComposer(); - return EditContext( + return SuperEditorContext( + editorFocusNode: FocusNode(), editor: documentEditor, + document: document, getDocumentLayout: () => fakeLayout, composer: composer, + scroller: FakeSuperEditorScroller(), commonOps: CommonEditorOperations( editor: documentEditor, + document: document, composer: composer, documentLayoutResolver: () => fakeLayout, ), diff --git a/super_editor/test/src/default_editor/text_tools_test.dart b/super_editor/test/super_editor/text_entry/text_tools_test.dart similarity index 100% rename from super_editor/test/src/default_editor/text_tools_test.dart rename to super_editor/test/super_editor/text_entry/text_tools_test.dart diff --git a/super_editor/test/super_reader/components/markdown_tables_test.dart b/super_editor/test/super_reader/components/markdown_tables_test.dart new file mode 100644 index 0000000000..9cc218943f --- /dev/null +++ b/super_editor/test/super_reader/components/markdown_tables_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/default_editor/tables/table_markdown.dart'; + +import '../../../lib/src/test/super_reader_test/reader_test_tools.dart'; + +void main() { + group("Super Reader > components > Markdown tables >", () { + testWidgetsOnAllPlatforms("builds and renders in shrink to fit mode", (tester) async { + await _pumpScaffold( + tester, + columnWidth: const IntrinsicColumnWidth(), + fit: TableComponentFit.scale, + ); + + // Let everything shake out, such as scroll controller attachment, to make sure + // nothing blows up on frame 2+. + await tester.pumpAndSettle(); + + final findTable = find.byType(MarkdownTableComponent); + expect(findTable, findsOne); + + final tableWidget = (findTable.evaluate().first as StatefulElement).widget as MarkdownTableComponent; + expect(tableWidget.viewModel.fit, TableComponentFit.scale); + expect(tableWidget.viewModel.columnWidth, const IntrinsicColumnWidth()); + }); + + testWidgetsOnAllPlatforms("builds and renders in horizontal scrolling mode", (tester) async { + await _pumpScaffold( + tester, + columnWidth: const FixedColumnWidth(250), + fit: TableComponentFit.scroll, + ); + + // Let everything shake out, such as scroll controller attachment, to make sure + // nothing blows up on frame 2+. + await tester.pumpAndSettle(); + + final findTable = find.byType(MarkdownTableComponent); + expect(findTable, findsOne); + + final tableWidget = (findTable.evaluate().first as StatefulElement).widget as MarkdownTableComponent; + expect(tableWidget.viewModel.fit, TableComponentFit.scroll); + expect(tableWidget.viewModel.columnWidth, const FixedColumnWidth(250)); + + // Make sure we can swipe the scrollable without blowing up. + await tester.fling(findTable, const Offset(-250, 0), 3000); + await tester.pumpAndSettle(); + // If we get here without an error then there's no fundamental error with processing + // touch events on the scrollable table. + }); + }); +} + +Future _pumpScaffold( + WidgetTester tester, { + TableColumnWidth columnWidth = const IntrinsicColumnWidth(), + TableComponentFit fit = TableComponentFit.scroll, +}) async { + await tester // + .createDocument() + .fromMarkdown('''# Markdown Table document +This document contains a Markdown table. + +| Version | Release date | Description / Major changes | Release notes / URL / Supplemental info | +| ------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 1.0.0 | 2018-12-04 (or December 4, 2018) ([Wikipedia][1]) | First stable release of Flutter — mobile (iOS & Android) production-ready SDK. ([Wikipedia][1]) | Archive (old changelog) on Flutter site. ([Flutter Documentation][2]) | +| 1.5.0 | 2019-05-07 ([features-of-flutter-mobile-applications.my.canva.site][3]) | Minor but notable stable release (part of 1.x series) — incremental improvements. ([Flutter Documentation][4]) | See archived release-notes (e.g. 1.5.x) linked from Flutter “Archived release notes.” ([Flutter Documentation][2]) | +| 1.12.0 | 2019-12-11 ([Wikipedia][5]) | Larger 1.x release — included updated add-to-app APIs, improvements in web-preview support (web in beta/channel), and tooling updates. ([features-of-flutter-mobile-applications.my.canva.site][3]) | Archived release-notes page. ([Flutter Documentation][2]) | +| 1.17.0 | 2020-05-06 ([Wikipedia][1]) | Added support for Metal on iOS, performance and rendering improvements, updated widgets & tooling. ([Wikipedia][1]) | Included in Flutter SDK archive. ([Flutter Documentation][6]) | +| 1.20.0 | 2020-08-05 ([CSDN Blog][7]) | Performance improvements, UI enhancements, autofill improvements for mobile text fields, other fixes. ([Reddit][8]) | Archived release notes (via archive page) ([Flutter Documentation][2]) | +| 2.0.0 | 2021-03-03 ([Wikipedia][1]) | Major milestone: Web stable, desktop (Windows/macOS/Linux) support in beta, optional null-safety (with Dart 2.x), broader cross-platform ambitions. ([Wikipedia][1]) | Official release notes on Flutter site. ([Flutter Documentation][6]) | +| 2.2.0 | 2021-05-18 ([CSDN Blog][7]) | Performance improvements, enhancements in desktop tooling progress. ([Wikipedia][1]) | Release notes via Flutter archive. ([Flutter Documentation][2]) | +| 2.5.0 | 2021-09-08 ([CSDN Blog][7]) | New features and improvements over 2.2 — incremental but significant for many users. ([features-of-flutter-mobile-applications.my.canva.site][3]) | Release notes via Flutter archive. ([Flutter Documentation][2]) | +| 2.10.0 | 2022-02-03 (first Windows stable support) ([Wikipedia][1]) | Stable Windows desktop support added — broadening desktop platform support. ([Wikipedia][1]) | Flutter SDK archive / release notes. ([Flutter Documentation][6]) | +| 3.0.0 | 2022-05-12 ([Wikipedia][1]) | Full stable support for all desktop platforms (Windows, macOS, Linux) + continued cross-platform reinforcement. ([Wikipedia][1]) | Official release notes (on flutter.dev) and SDK archive. ([Flutter Documentation][6]) | + +This text appears after the table.''') // + .withAddedComponents( + [ + MarkdownTableComponentBuilder( + columnWidth: columnWidth, + fit: fit, + ) + ], // + ).pump(); +} diff --git a/super_editor/test/super_reader/components/task_test.dart b/super_editor/test/super_reader/components/task_test.dart new file mode 100644 index 0000000000..14341264ee --- /dev/null +++ b/super_editor/test/super_reader/components/task_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/super_editor_test/tasks_test_tools.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../../../lib/src/test/super_reader_test/reader_test_tools.dart'; + +void main() { + group('SuperReader tasks >', () { + testWidgetsOnAllPlatforms("are displayed in a read-only document", (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + TaskNode(id: "1", text: AttributedText(), isComplete: false), + TaskNode(id: "2", text: AttributedText(), isComplete: true), + ], + ), + ) + .pump(); + + // Ensure the default task component is rendered. + expect(find.byType(TaskComponent), findsNWidgets(2)); + }); + + testWidgetsOnAllPlatforms("cannot be toggled by the user", (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + TaskNode(id: '1', text: AttributedText(), isComplete: false), + TaskNode(id: '2', text: AttributedText(), isComplete: true), + ], + ), + ) + .pump(); + + // Tap on the first task's checkbox. + await tester.tapOnCheckbox('1'); + + // Ensure that the task's checkbox didn't change from unchecked to checked. + expect(TaskInspector.isChecked('1'), isFalse); + + // Tap on the second task's checkbox. + await tester.tapOnCheckbox('2'); + + // Ensure that the task's checkbox didn't change from checked to unchecked. + expect(TaskInspector.isChecked('1'), isFalse); + }); + }); +} diff --git a/super_editor/test/super_reader/mobile/super_reader_android_selection_test.dart b/super_editor/test/super_reader/mobile/super_reader_android_selection_test.dart new file mode 100644 index 0000000000..b92d9829c6 --- /dev/null +++ b/super_editor/test/super_reader/mobile/super_reader_android_selection_test.dart @@ -0,0 +1,515 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/magnifier.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/selection_handles.dart'; +import 'package:super_editor/src/test/super_reader_test/super_reader_inspector.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart' show SuperEditorRobot; +import 'package:super_editor/super_reader_test.dart'; + +void main() { + group("SuperReader mobile selection >", () { + group("Android >", () { + group("long press >", () { + testWidgetsOnAndroid("selects word under finger", (tester) async { + await _pumpAppWithLongText(tester); + + // Ensure that no overlay controls are visible. + _expectNoControlsAreVisible(); + + // Long press on the middle of "conse|ctetur" + await tester.longPressInParagraph("1", 33); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect(SuperReaderInspector.findDocumentSelection(), _wordConsecteturSelection); + + // Ensure the drag handles and toolbar are visible, but the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by word when dragging upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag upstream to the end of the previous word. + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -130 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + + // Now that we've started dragging, ensure the magnifier is visible and the + // toolbar is hidden. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsOneWidget); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + + // Now that the drag is done, ensure the handles and toolbar are visible and + // the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by character when dragging upstream in reverse", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag near the end of the upstream word. + // "Lorem i|psum dolor sit amet" + // ^ position 7 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -15.0; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + ), + ); + + // Drag in reverse toward the initial selection. + // + // Drag far enough to trigger a per-character selection, and then + // drag a little more to deselect some characters. + const downstreamDragDistance = 110 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that part of the upstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 10), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when jumping up a line and dragging upstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "adi|piscing". + final gesture = await tester.longPressDownInParagraph("1", 42); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordAdipiscingSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag up one line to select "dolor". + const dragIncrementCount = 10; + const verticalDragDistance = -24 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(0, verticalDragDistance)); + await tester.pump(); + } + + // Ensure the selection begins at the end of "adipiscing" and goes to the + // beginning of "dolor", which is upstream. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorStart), + ), + ), + ); + + // Drag upstream to select the previous word. + const upstreamDragDistance = -80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordIpsumStart), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when dragging downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordDolorSelection); + + // Ensure the toolbar is visible, but drag handles and magnifier aren't. + _expectOnlyToolbar(); + + // Drag downstream to the beginning of the next word. + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + // + // "Lorem ipsum dolor sit| amet" + // ^ position 21 + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Now that we've started dragging, ensure the magnifier is visible and the + // toolbar is hidden. + _expectOnlyMagnifier(); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + + // Now that the drag is done, ensure the handles and toolbar are visible and + // the magnifier isn't. + _expectHandlesAndToolbar(); + }); + + testWidgetsOnAndroid("selects by character when dragging downstream in reverse", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordDolorSelection); + + // Drag near the end of the downstream word. + // "Lorem ipsum dolor si|t amet" + // ^ position 20 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = 100 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Drag in reverse toward the initial selection. + // + // Drag far enough to trigger a per-character selection, and then + // drag a little more to deselect some characters. + const downstreamDragDistance = -40 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that part of the downstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 19), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when jumping down a line and dragging downstream", (tester) async { + await _pumpAppWithLongText(tester); + + // Long press on the middle of "adi|piscing". + final gesture = await tester.longPressDownInParagraph("1", 42); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), _wordAdipiscingSelection); + + // Drag down one line to select "tempor". + const dragIncrementCount = 10; + const verticalDragDistance = 24 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(0, verticalDragDistance)); + await tester.pump(); + } + + // Ensure the selection begins at the start of "adipiscing" and goes to the + // end of "tempor", which is upstream. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordTemporEnd), + ), + ), + ); + + // Drag downstream to select the next word. + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordIncididuntEnd), + ), + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + }); + }); + }); +} + +// The test suite was originally laid out and calculated with: +// - physical size: 2400x1800 +// - device pixel ratio: 3.0 + +// 01) Lorem ipsum dolor sit amet, [0, 28] +// 02) consectetur adipiscing elit, sed [28, 61] +// 03) do eiusmod tempor incididunt ut [61, 93] +// 04) labore et dolore magna aliqua. +// 05) Ut enim ad minim veniam, quis +// 06) nostrud exercitation ullamco +// 07) laboris nisi ut aliquip ex ea +// 08) commodo consequat. Duis aute +// 09) irure dolor in reprehenderit in +// 10) voluptate velit esse cillum +// 11) dolore eu fugiat nulla pariatur. +// 12) Excepteur sint occaecat +// 13) cupidatat non proident, sunt in +// 14) culpa qui officia deserunt +// 15) mollit anim id est laborum. + +Future _pumpAppWithLongText(WidgetTester tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withAndroidToolbarBuilder((context) => const AndroidTextEditingFloatingToolbar()) + .pump(); +} + +const _wordConsecteturSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 28), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), +); + +const _wordIpsumStart = 6; +// ignore: unused_element +const _wordIpsumEnd = 11; + +const _wordDolorStart = 12; +const _wordDolorEnd = 17; +const _wordDolorSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordDolorEnd), + ), +); + +const _wordAdipiscingStart = 40; +const _wordAdipiscingEnd = 50; +const _wordAdipiscingSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingStart), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: _wordAdipiscingEnd), + ), +); + +// ignore: unused_element +const _wordTemporStart = 72; +const _wordTemporEnd = 78; + +// ignore: unused_element +const _wordIncididuntStart = 79; +const _wordIncididuntEnd = 89; + +void _expectNoControlsAreVisible() { + expect(find.byType(AndroidSelectionHandle), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} + +void _expectOnlyToolbar() { + expect(find.byType(AndroidSelectionHandle), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOneWidget); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} + +void _expectOnlyMagnifier() { + expect(find.byType(AndroidSelectionHandle), findsNothing); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect(find.byType(AndroidMagnifyingGlass), findsOneWidget); +} + +void _expectHandlesAndToolbar() { + expect(find.byType(AndroidSelectionHandle), findsNWidgets(2)); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOneWidget); + expect(find.byType(AndroidMagnifyingGlass), findsNothing); +} diff --git a/super_editor/test/super_reader/mobile/super_reader_ios_overlay_controls_test.dart b/super_editor/test/super_reader/mobile/super_reader_ios_overlay_controls_test.dart new file mode 100644 index 0000000000..292cf5a49b --- /dev/null +++ b/super_editor/test/super_reader/mobile/super_reader_ios_overlay_controls_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/src/test/super_reader_test/super_reader_inspector.dart'; + +import '../../test_runners.dart'; +import '../../../lib/src/test/super_reader_test/reader_test_tools.dart'; + +void main() { + group("SuperReader > iOS > overlay controls >", () { + group("on device and web > shows ", () { + testWidgetsOnIosDeviceAndWeb("upstream and downstream handles", (tester) async { + await _pumpApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect(SuperReaderInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure expanded handles are visible, but caret isn't. + expect(SuperReaderInspector.findMobileCaret(), findsNothing); + expect(SuperReaderInspector.findMobileUpstreamDragHandle(), findsOneWidget); + expect(SuperReaderInspector.findMobileDownstreamDragHandle(), findsOneWidget); + }); + }); + + group("on device >", () { + group("shows", () { + testWidgetsOnIos("the magnifier", (tester) async { + await _pumpApp(tester); + + // Long press, and hold, so that the magnifier appears. + await tester.longPressDownInParagraph("1", 1); + + // Ensure the magnifier is wanted AND visible. + expect(SuperReaderInspector.wantsMobileMagnifierToBeVisible(), isTrue); + expect(SuperReaderInspector.isMobileMagnifierVisible(), isTrue); + }); + + testWidgetsOnIos("the floating toolbar", (tester) async { + await _pumpApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect(SuperReaderInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the toolbar is desired AND displayed. + expect(SuperReaderInspector.wantsMobileToolbarToBeVisible(), isTrue); + expect(SuperReaderInspector.isMobileToolbarVisible(), isTrue); + }); + }); + }); + + group("on web >", () { + group("defers to browser to show", () { + testWidgetsOnWebIos("the magnifier", (tester) async { + await _pumpApp(tester); + + // Long press, and hold, so that the magnifier appears. + await tester.longPressDownInParagraph("1", 1); + + // Ensure the magnifier is desired, but not displayed. + expect(SuperReaderInspector.wantsMobileMagnifierToBeVisible(), isTrue); + expect(SuperReaderInspector.isMobileMagnifierVisible(), isFalse); + }); + + testWidgetsOnWebIos("the floating toolbar", (tester) async { + await _pumpApp(tester); + + // Create an expanded selection. + await tester.doubleTapInParagraph("1", 1); + + // Ensure we have an expanded selection. + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect(SuperReaderInspector.findDocumentSelection()!.isCollapsed, isFalse); + + // Ensure that the toolbar is desired, but not displayed. + expect(SuperReaderInspector.wantsMobileToolbarToBeVisible(), isTrue); + expect(SuperReaderInspector.isMobileToolbarVisible(), isFalse); + }); + }); + }); + }); +} + +Future _pumpApp(WidgetTester tester) async { + await tester + .createDocument() + // Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor... + .withSingleParagraph() + .pump(); +} diff --git a/super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart b/super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart new file mode 100644 index 0000000000..379d44a90d --- /dev/null +++ b/super_editor/test/super_reader/mobile/super_reader_ios_selection_test.dart @@ -0,0 +1,408 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/selection_handles.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_reader_test.dart'; + +import '../../test_tools.dart'; + +void main() { + group("SuperReader mobile selection >", () { + group("iOS >", () { + group("long press >", () { + testWidgetsOnIos("selects word under finger", (tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => + IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint)) + .pump(); + + // Ensure that no overlay controls are visible. + expect(find.byType(IOSSelectionHandle), findsNothing); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsNothing); + + // Long press on the middle of "conse|ctetur" + await tester.longPressInParagraph("1", 33); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 28), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 39), + ), + ), + ); + + // Ensure the drag handles and toolbar are visible, but the magnifier isn't. + expect(find.byType(IOSSelectionHandle), findsNWidgets(2)); + expect(find.byType(IOSTextEditingFloatingToolbar), findsOneWidget); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsNothing); + }); + + testWidgetsOnIos("over handle does nothing", (tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => + IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint)) + .pump(); + + // Long press on the middle of "do|lor". + await tester.longPressInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + const wordSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ); + + expect(SuperReaderInspector.findDocumentSelection(), isNotNull); + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Long-press near the upstream handle, but just before the selected word. + await tester.longPressInParagraph("1", 11); + await tester.pumpAndSettle(); + + // Ensure that the selection didn't change. + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Long-press near the downstream handle, but just after the selected word. + await tester.longPressInParagraph("1", 18); + await tester.pumpAndSettle(); + + // Ensure that the selection didn't change. + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + }); + + testWidgetsOnIos("selects by word when dragging upstream and then back downstream", (tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => + IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint)) + .pump(); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + const wordSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ); + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Ensure the drag handles and magnifier are visible, but the toolbar isn't. + expect(find.byType(IOSSelectionHandle), findsNWidgets(2)); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsOneWidget); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + + // Drag upstream to the end of the previous word. + // "Lorem ipsu|m dolor sit amet" + // ^ position 10 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const upstreamDragDistance = -130 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and upstream word are both selected. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 6), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + + // Drag back towards the original long-press offset. + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const downstreamDragDistance = 100 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that only the original word is selected. + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Release the gesture so the test system doesn't complain. + gesture.up(); + }); + + testWidgetsOnIos("selects by word when dragging downstream and then back upstream", (tester) async { + await tester + .createDocument() + // "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod...", + .withSingleParagraph() + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => + IOSTextEditingFloatingToolbar(key: mobileToolbarKey, focalPoint: focalPoint)) + .pump(); + + // Long press on the middle of "do|lor". + final gesture = await tester.longPressDownInParagraph("1", 14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + const wordSelection = DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ); + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Ensure the drag handles and magnifier are visible, but the toolbar isn't. + expect(find.byType(IOSSelectionHandle), findsNWidgets(2)); + expect(find.byType(IOSRoundedRectangleMagnifyingGlass), findsOneWidget); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + + // Drag downstream to the beginning of the next word. + // "Lorem ipsum dolor s|it amet" + // ^ position 19 + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const dragIncrementCount = 10; + const downstreamDragDistance = 80 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(downstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure the original word and downstream word are both selected. + expect( + SuperReaderInspector.findDocumentSelection(), + const DocumentSelection( + base: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 12), + ), + extent: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 21), + ), + ), + ); + + // Drag back towards the original long-press offset. + // + // We do this with manual distances because the attempt to look up character + // offsets was producing unpredictable results. + const upstreamDragDistance = -40 / dragIncrementCount; + for (int i = 0; i < dragIncrementCount; i += 1) { + await gesture.moveBy(const Offset(upstreamDragDistance, 0)); + await tester.pump(); + } + + // Ensure that only the original word is selected. + expect(SuperReaderInspector.findDocumentSelection(), wordSelection); + + // Release the gesture so the test system doesn't complain. + gesture.up(); + }); + }); + + group("horizontal drag", () { + testWidgetsOnIos("does not cause reader to scroll", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withLongTextContent() + .withScrollController(scrollController) + .pump(); + + // Start dragging horizontally. + final gesture = await tester.startGesture( + tester.getCenter(find.byType(SuperReader)), + ); + + // Drag horizontally. + for (int i = 1; i < 10; i += 1) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + + // Ensure that dragging doesn't cause the reader to scroll. + expect(scrollController.offset, 0); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); + + group("vertical drag", () { + testWidgetsOnIos("scrolls the reader after a horizontal drag", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withLongTextContent() + .withScrollController(scrollController) + .pump(); + + // Start dragging horizontally. + final gesture = await tester.startGesture( + tester.getCenter(find.byType(SuperReader)), + ); + + // Drag horizontally. + for (int i = 1; i < 10; i += 1) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + + // Drag vertically. + for (int i = 1; i < 10; i += 1) { + await gesture.moveBy(const Offset(0, -10)); + await tester.pump(); + } + + // Ensure that the reader scrolled up. + expect(scrollController.offset, greaterThan(0.0)); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); + }); + + group('within ancestor scrollable', () { + testWidgetsOnIos("expands selection when dragging horizontally", (tester) async { + final testContext = await tester + .createDocument() + .fromMarkdown( + ''' +SuperEditor containing a +paragraph that spans +multiple lines.''', + ) + .insideCustomScrollView() + .pump(); + + final paragraphNode = testContext.document.first as ParagraphNode; + + // Double tap to select "SuperEditor". + await SuperReaderRobot(tester).doubleTapInParagraph(paragraphNode.id, 0); + + // Drag from "SuperEdito|r" a distance long enough to go through the entire first line. + await SuperReaderRobot(tester).dragSelectDocumentFromPositionByOffset( + from: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 10), + ), + delta: const Offset(300, 0), + ); + + // Ensure the first line is selected. + expect( + SuperReaderInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection( + base: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 24), + ), + ), + ), + ); + }); + + testWidgetsOnIos("expands selection when dragging vertically", (tester) async { + final testContext = await tester + .createDocument() + .fromMarkdown( + ''' +SuperEditor containing a +paragraph that spans +multiple lines.''', + ) + .insideCustomScrollView() + .pump(); + + final paragraphNode = testContext.document.first as ParagraphNode; + + // Double tap to select "SuperEditor". + await SuperReaderRobot(tester).doubleTapInParagraph(paragraphNode.id, 0); + + // Drag from "SuperEdito|r" a distance long enough to go to the last line. + await SuperReaderRobot(tester).dragSelectDocumentFromPositionByOffset( + from: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 10), + ), + delta: const Offset(0, 40), + ); + + // Ensure the selection starts at the beginning and end at "multiple l|ines". + expect( + SuperReaderInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection( + base: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: paragraphNode.id, + nodePosition: const TextNodePosition(offset: 57), + ), + ), + ), + ); + }); + }); + }); +} diff --git a/super_editor/test/super_reader/reader_test_tools_test.dart b/super_editor/test/super_reader/reader_test_tools_test.dart index 60050810a2..ba3644e54b 100644 --- a/super_editor/test/super_reader/reader_test_tools_test.dart +++ b/super_editor/test/super_reader/reader_test_tools_test.dart @@ -1,9 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_reader_test.dart'; -import '../test_tools.dart'; -import 'reader_test_tools.dart'; - void main() { group("SuperReader test tools", () { group("configures document from markdown", () { diff --git a/super_editor/test/super_reader/super_reader_gestures_test.dart b/super_editor/test/super_reader/super_reader_gestures_test.dart new file mode 100644 index 0000000000..fcc13e7bdd --- /dev/null +++ b/super_editor/test/super_reader/super_reader_gestures_test.dart @@ -0,0 +1,132 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/super_reader/super_reader.dart'; +import 'package:super_editor/super_reader_test.dart'; + +import '../test_tools.dart'; + +void main() { + group('SuperReader gestures', () { + testWidgetsOnDesktop('scrolls the content when dragging the scrollbar (downstream)', (tester) async { + final scrollController = ScrollController(); + await tester // + .createDocument() + .withSingleParagraph() + .withEditorSize(const Size(300, 300)) + .withScrollController(scrollController) + .pump(); + + // Ensure the editor didn't start scrolled. + expect(scrollController.position.pixels, 0.0); + + // Double tap to select "Lorem". + await tester.doubleTapInParagraph('1', 0); + expect( + SuperReaderInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + extent: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + )), + ); + + // Find the approximate position of the scrollbar thumb. + final startingDragLocation = tester.getTopRight(find.byType(SuperReader)) + const Offset(-10, 10); + + final testPointer = TestPointer(1, PointerDeviceKind.mouse); + + // Hover to make the thumb visible with a duration long enough to run the fade animation. + await tester.sendEventToBinding(testPointer.hover(startingDragLocation, timeStamp: const Duration(seconds: 1))); + await tester.pumpAndSettle(); + + // Tap and hold the thumb. + await tester.sendEventToBinding(testPointer.down(startingDragLocation)); + await tester.pump(kTapMinTime); + + // Move the thumb down. + await tester.sendEventToBinding(testPointer.move(startingDragLocation + const Offset(0, 300))); + await tester.pump(); + + // Release the pointer. + await tester.sendEventToBinding(testPointer.up()); + await tester.pump(); + + // Ensure the content scrolled down. + expect(scrollController.position.pixels, greaterThan(0)); + + // Ensure the selection didn't change. + expect( + SuperReaderInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + extent: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + )), + ); + }); + + testWidgetsOnDesktop('scrolls the content when dragging the scrollbar (upstream)', (tester) async { + final scrollController = ScrollController(); + await tester // + .createDocument() + .withSingleParagraph() + .withEditorSize(const Size(300, 300)) + .withScrollController(scrollController) + .pump(); + + // Double tap to select "Lorem". + await tester.doubleTapInParagraph('1', 0); + expect( + SuperReaderInspector.findDocumentSelection(), + selectionEquivalentTo(const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + extent: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + )), + ); + + // Jump to the end of the document. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); + + // Find the approximate position of the scrollbar thumb. + final startingDragLocation = tester.getBottomRight(find.byType(SuperReader)) - const Offset(10, 10); + + final testPointer = TestPointer(1, PointerDeviceKind.mouse); + + // Hover to make the thumb visible with a duration long enough to run the fade animation. + await tester.sendEventToBinding(testPointer.hover(startingDragLocation, timeStamp: const Duration(seconds: 1))); + await tester.pumpAndSettle(); + + // Tap and hold the thumb. + await tester.sendEventToBinding(testPointer.down(startingDragLocation)); + await tester.pump(kTapMinTime); + + // Move the thumb up. + await tester.sendEventToBinding(testPointer.move(startingDragLocation - const Offset(0, 300))); + await tester.pump(); + + // Release the pointer. + await tester.sendEventToBinding(testPointer.up()); + await tester.pump(); + + // Ensure the content scrolled up. + expect(scrollController.position.pixels, 0); + + // Ensure the selection didn't change. + expect( + SuperReaderInspector.findDocumentSelection(), + selectionEquivalentTo( + const DocumentSelection( + base: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 0)), + extent: DocumentPosition(nodeId: '1', nodePosition: TextNodePosition(offset: 5)), + ), + ), + ); + }); + }); +} diff --git a/super_editor/test/super_reader/super_reader_keyboard_test.dart b/super_editor/test/super_reader/super_reader_keyboard_test.dart index 5b9a2ad131..8c6fc54d42 100644 --- a/super_editor/test/super_reader/super_reader_keyboard_test.dart +++ b/super_editor/test/super_reader/super_reader_keyboard_test.dart @@ -1,18 +1,79 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_reader_test.dart'; -import '../test_tools.dart'; -import 'reader_test_tools.dart'; +import '../test_runners.dart'; void main() { - group('SuperReader keyboard', () { - group('moves selection', () { + group('SuperReader keyboard >', () { + testWidgetsOnDesktop("copies text regardless of key order", (tester) async { + final testContext = await tester // + .createDocument() + .fromMarkdown("This is some testing text.") // Length is 26 + .autoFocus(true) + .pump(); + + // Select "This". + final nodeId = testContext.documentContext.document.first.id; + await tester.doubleTapInParagraph(nodeId, 1); + + // Press the "copy" shortcut in the standard key order. + tester.simulateClipboard(); + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdC(); + } else { + await tester.pressCtlC(); + } + + // Ensure that "This" was copied. + expect(tester.getSimulatedClipboardContent(), "This"); + + // Select "testing". + // + // When I wrote this test, double tapping to select another word wasn't + // working. Maybe there's some overlay from the earlier word selection. + // To get rid of whatever it is, we collapse the selection with the arrow + // key and then double tap after that to select a different word. + await tester.pressRightArrow(); + await tester.doubleTapInParagraph(nodeId, 16); + + // Press the "copy" shortcut, but release "CMD" before "C", which + // sometimes happens by accident with human users. + final keyEventPlatform = switch (defaultTargetPlatform) { + TargetPlatform.macOS => "macos", + TargetPlatform.windows => "windows", + TargetPlatform.linux => "linux", + TargetPlatform.fuchsia => "linux", + TargetPlatform.android => throw UnimplementedError(), + TargetPlatform.iOS => throw UnimplementedError(), + }; + + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.sendKeyDownEvent(LogicalKeyboardKey.meta, platform: keyEventPlatform); + } else { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft, platform: keyEventPlatform); + } + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyC, platform: keyEventPlatform); + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.sendKeyUpEvent(LogicalKeyboardKey.meta, platform: keyEventPlatform); + } else { + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft, platform: keyEventPlatform); + } + await tester.sendKeyUpEvent(LogicalKeyboardKey.keyC, platform: keyEventPlatform); + await tester.pumpAndSettle(); + + // Ensure that "testing" was copied. + expect(tester.getSimulatedClipboardContent(), "testing"); + }); + + group('moves selection >', () { testAllInputsOnDesktop("left by one character and expands when SHIFT + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -24,7 +85,7 @@ void main() { testAllInputsOnDesktop("right by one character and expands when SHIFT + RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -34,9 +95,9 @@ void main() { expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 13)); }); - testAllInputsOnMac("to beginning of word and expands when SHIFT + ALT + LEFT_ARROW is pressed", ( + testAllInputsOnApple("to beginning of word and expands when SHIFT + ALT + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -58,9 +119,9 @@ void main() { ); }); - testAllInputsOnMac("to end of word and expands when SHIFT + ALT + RIGHT_ARROW is pressed", ( + testAllInputsOnApple("to end of word and expands when SHIFT + ALT + RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -70,9 +131,9 @@ void main() { expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 20)); }); - testAllInputsOnMac("to beginning of line and expands when SHIFT + CMD + LEFT_ARROW is pressed", ( + testAllInputsOnApple("to beginning of line and expands when SHIFT + CMD + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -82,9 +143,9 @@ void main() { expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 0)); }); - testAllInputsOnMac("to end of line and expands when SHIFT + CMD + RIGHT_ARROW is pressed", ( + testAllInputsOnApple("to end of line and expands when SHIFT + CMD + RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -99,7 +160,7 @@ void main() { testAllInputsOnWindowsAndLinux("to beginning of word and expands when SHIFT + CTL + LEFT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -123,7 +184,7 @@ void main() { testAllInputsOnWindowsAndLinux("to end of word and expands when SHIFT + CTL + RIGHT_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -135,7 +196,7 @@ void main() { testAllInputsOnDesktop("up one line and expands when SHIFT + UP_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLine(tester, offset: 44, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 41, to: 47)); @@ -147,7 +208,7 @@ void main() { testAllInputsOnDesktop("down one line and expands when SHIFT + DOWN_ARROW is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLine(tester, offset: 12, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 12, to: 17)); @@ -159,7 +220,7 @@ void main() { testAllInputsOnDesktop("to beginning of line and expands when SHIFT + UP_ARROW is pressed at top of document", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLine(tester, offset: 12, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 12, to: 17)); @@ -171,7 +232,7 @@ void main() { testAllInputsOnDesktop("end of line and expands when SHIFT + DOWN_ARROW is pressed at end of document", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpDoubleLine(tester, offset: 41, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 41, to: 47)); @@ -182,9 +243,9 @@ void main() { }); }); - testAllInputsOnMac("and removes selection when it collapses without holding the SHIFT key", ( + testAllInputsOnApple("and removes selection when it collapses without holding the SHIFT key", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -205,7 +266,7 @@ void main() { testAllInputsOnWindowsAndLinux("and removes selection when it collapses without holding the SHIFT key", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -213,9 +274,9 @@ void main() { // Hold shift and move the caret to the beginning of the selected word, which // collapses the selection. Release the shift key pressed, which should check // the selection, see that it's collapsed, and then remove it. - await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.pressKeyDown(LogicalKeyboardKey.shift); await tester.pressCtlLeftArrow(); - await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.releaseKeyUp(LogicalKeyboardKey.shift); // Ensure that the selection is gone. expect( @@ -224,9 +285,9 @@ void main() { ); }); - testAllInputsOnMac("and retains the selection when collapsed and the SHIFT key is pressed", ( + testAllInputsOnApple("and retains the selection when collapsed and the SHIFT key is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -246,7 +307,7 @@ void main() { testAllInputsOnWindowsAndLinux("and retains the selection when collapsed and the SHIFT key is pressed", ( tester, { - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final nodeId = await _pumpSingleLineAndSelectAWord(tester, offset: 10, inputSource: inputSource); expect(SuperReaderInspector.findDocumentSelection(), _selectionInParagraph(nodeId, from: 8, to: 12)); @@ -269,7 +330,7 @@ void main() { Future _pumpSingleLineAndSelectAWord( WidgetTester tester, { required int offset, - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final testContext = await tester // .createDocument() @@ -277,7 +338,7 @@ Future _pumpSingleLineAndSelectAWord( .autoFocus(true) .pump(); - final nodeId = testContext.documentContext.document.nodes.first.id; + final nodeId = testContext.documentContext.document.first.id; await tester.doubleTapInParagraph(nodeId, offset); @@ -287,7 +348,7 @@ Future _pumpSingleLineAndSelectAWord( Future _pumpDoubleLine( WidgetTester tester, { required int offset, - required DocumentInputSource inputSource, + required TextInputSource inputSource, }) async { final testContext = await tester // .createDocument() @@ -299,7 +360,7 @@ Future _pumpDoubleLine( .autoFocus(true) .pump(); - final nodeId = testContext.documentContext.document.nodes.first.id; + final nodeId = testContext.documentContext.document.first.id; await tester.doubleTapInParagraph(nodeId, offset); diff --git a/super_editor/test/super_reader/super_reader_phone_rotation_test.dart b/super_editor/test/super_reader/super_reader_phone_rotation_test.dart new file mode 100644 index 0000000000..4f8ef0cde0 --- /dev/null +++ b/super_editor/test/super_reader/super_reader_phone_rotation_test.dart @@ -0,0 +1,79 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor_test.dart'; + +void main() { + group('SuperReader > phone rotation >', () { + const screenSizePortrait = Size(400.0, 1000.0); + const screenSizeLandscape = Size(1000.0, 400); + + testWidgetsOnMobile('does not crash the app when there is no selection', (tester) async { + // Start the test in portrait mode. + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = screenSizePortrait; + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + // Simulate a phone rotation. + tester.view.physicalSize = screenSizeLandscape; + await tester.pumpAndSettle(); + + // Reaching this point means the reader didn't crash. + }); + + testWidgetsOnMobile('does not crash the app when the selection is collapsed', (tester) async { + // Start the test in portrait mode. + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = screenSizePortrait; + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + // Place the caret at the beginning of the document. + await tester.placeCaretInParagraph('1', 0); + + // Simulate a phone rotation. + tester.view.physicalSize = screenSizeLandscape; + await tester.pumpAndSettle(); + + // Reaching this point means the reader didn't crash. + }); + + testWidgetsOnMobile('does not crash the app when the selection is expanded', (tester) async { + // Start the test in portrait mode. + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = screenSizePortrait; + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + // Double tap to select the first word. + await tester.doubleTapInParagraph('1', 0); + + // Simulate a phone rotation. + tester.view.physicalSize = screenSizeLandscape; + await tester.pumpAndSettle(); + + // Reaching this point means the reader didn't crash. + }); + }); +} diff --git a/super_editor/test/super_reader/super_reader_route_test.dart b/super_editor/test/super_reader/super_reader_route_test.dart new file mode 100644 index 0000000000..be26a693d1 --- /dev/null +++ b/super_editor/test/super_reader/super_reader_route_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; + +import '../../lib/src/test/super_reader_test/reader_test_tools.dart'; + +void main() { + group('SuperReader > routes >', () { + testWidgetsOnAllPlatforms('can be used with a route with a delegated transition on top', (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .withCustomWidgetTreeBuilder( + (superReader) => MaterialApp( + home: Scaffold( + body: Column( + children: [ + Expanded( + child: superReader, + ), + Builder(builder: (context) { + return ElevatedButton( + child: const Text('delegatedTransition'), + onPressed: () { + Navigator.of(context).push(_TestRoute()); + }, + ); + }), + ], + ), + ), + ), + ) + .pump(); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + // Reaching this point means that the reader did not crash when the route with + // a delegated transition was pushed on top of it. + // See https://github.com/Flutter-Bounty-Hunters/super_editor/issues/2794 for details. + }); + }); +} + +/// A [ModalRoute] that uses a delegated transition. +class _TestRoute extends ModalRoute { + _TestRoute(); + + @override + DelegatedTransitionBuilder? get delegatedTransition => + (context, animation, secondaryAnimation, allowSnapshotting, child) { + return FadeTransition( + opacity: animation, + child: child, + ); + }; + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + bool get barrierDismissible => true; + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + return const Center( + child: Text('Hello'), + ); + } + + @override + bool get maintainState => true; + + @override + bool get opaque => false; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); +} diff --git a/super_editor/test/super_reader/super_reader_scrolling_test.dart b/super_editor/test/super_reader/super_reader_scrolling_test.dart index 15a6c99255..dcea996ebb 100644 --- a/super_editor/test/super_reader/super_reader_scrolling_test.dart +++ b/super_editor/test/super_reader/super_reader_scrolling_test.dart @@ -1,11 +1,12 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_reader_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_reader_test.dart'; +import 'package:super_editor/super_test.dart'; import '../test_tools.dart'; -import 'reader_test_tools.dart'; void main() { group("SuperReader scrolling", () { @@ -19,7 +20,7 @@ void main() { .pump(); final document = SuperReaderInspector.findDocument()!; - final firstParagraph = document.nodes.first as ParagraphNode; + final firstParagraph = document.first as ParagraphNode; final dragGesture = await tester.startDocumentDragFromPosition( from: DocumentPosition( @@ -54,10 +55,11 @@ void main() { .pump(); final document = SuperReaderInspector.findDocument()!; - final lastParagraph = document.nodes.last as ParagraphNode; + final lastParagraph = document.last as ParagraphNode; // Jump to the end of the document scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); final dragGesture = await tester.startDocumentDragFromPosition( from: DocumentPosition( @@ -84,7 +86,7 @@ void main() { testWidgetsOnDesktop("auto-scrolls down", (tester) async { const windowSize = Size(800, 600); - tester.binding.window.physicalSizeTestValue = windowSize; + tester.view.physicalSize = windowSize; await tester // .createDocument() // @@ -93,8 +95,8 @@ void main() { .pump(); final document = SuperReaderInspector.findDocument()!; - final firstParagraph = document.nodes.first as ParagraphNode; - final lastParagraph = document.nodes.last as ParagraphNode; + final firstParagraph = document.first as ParagraphNode; + final lastParagraph = document.last as ParagraphNode; final dragGesture = await tester.startDocumentDragFromPosition( from: DocumentPosition( @@ -129,7 +131,7 @@ void main() { testWidgetsOnDesktop("auto-scrolls up", (tester) async { const windowSize = Size(800, 600); - tester.binding.window.physicalSizeTestValue = windowSize; + tester.view.physicalSize = windowSize; final testDocContext = await tester // .createDocument() // @@ -138,17 +140,24 @@ void main() { .pump(); final document = SuperReaderInspector.findDocument()!; - final firstParagraph = document.nodes.first as ParagraphNode; - final lastParagraph = document.nodes.last as ParagraphNode; + final firstParagraph = document.first as ParagraphNode; + final lastParagraph = document.last as ParagraphNode; // Place the caret at the end of the document, which causes the editor to // scroll to the bottom. - testDocContext.documentContext.selection.value = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: lastParagraph.id, - nodePosition: lastParagraph.endPosition, + testDocContext.documentContext.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: lastParagraph.id, + nodePosition: lastParagraph.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, ), - ); + ]); + testDocContext.focusNode.requestFocus(); await tester.pumpAndSettle(); @@ -173,7 +182,7 @@ void main() { DocumentSelection( base: DocumentPosition( nodeId: lastParagraph.id, - nodePosition: lastParagraph.endPosition.copyWith(affinity: TextAffinity.upstream), + nodePosition: lastParagraph.endPosition, ), extent: DocumentPosition( nodeId: firstParagraph.id, @@ -185,7 +194,7 @@ void main() { testWidgetsOnDesktop("auto-scrolls to caret position", (tester) async { const windowSize = Size(800, 600); - tester.binding.window.physicalSizeTestValue = windowSize; + tester.view.physicalSize = windowSize; final docContext = await tester // .createDocument() // @@ -193,16 +202,23 @@ void main() { .forDesktop() // .pump(); final document = SuperReaderInspector.findDocument()!; - final lastParagraph = document.nodes.last as ParagraphNode; + final lastParagraph = document.last as ParagraphNode; // Place the caret at the end of the document, which should cause the // editor to scroll to the bottom. - docContext.documentContext.selection.value = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: lastParagraph.id, - nodePosition: lastParagraph.endPosition, + docContext.documentContext.editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: lastParagraph.id, + nodePosition: lastParagraph.endPosition, + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, ), - ); + ]); + docContext.focusNode.requestFocus(); await tester.pumpAndSettle(); @@ -218,5 +234,477 @@ void main() { isTrue, ); }); + + testWidgetsOnAndroid("doesn't overscroll when dragging down", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .pump(); + + // Ensure the reader didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount of pixels from the top of the reader. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperReader)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 200.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, 0); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnAndroid("doesn't overscroll when dragging up", (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withSingleParagraph() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + + // Drag an arbitrary amount of pixels from the bottom of the reader. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperReader)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -200.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnIos('overscrolls when dragging down', (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withLongTextContent() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount of pixels a few pixels below the top of the reader. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperReader)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 80.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, lessThan(0.0)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the top. + expect(scrollController.offset, 0.0); + }); + + testWidgetsOnIos('overscrolls when dragging up', (tester) async { + final scrollController = ScrollController(); + + await tester // + .createDocument() + .withLongTextContent() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pumpAndSettle(); + + // Drag an arbitrary amount of pixels from the bottom of the reader. + // The gesture starts with an arbitrary margin from the bottom. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(SuperReader)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -200.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, greaterThan(scrollController.position.maxScrollExtent)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the end. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + }); + + testWidgetsOnArbitraryDesktop("does not stop momentum on mouse move", (tester) async { + final scrollController = ScrollController(); + + // Pump a reader with a small size to make it scrollable. + await tester // + .createDocument() // + .withCustomContent(longTextDoc()) // + .withScrollController(scrollController) // + .withEditorSize(const Size(300, 300)) + .pump(); + + // Fling scroll with the trackpad to generate momentum. + await tester.trackpadFling( + find.byType(SuperReader), + const Offset(0.0, -300), + 300.0, + ); + + final scrollOffsetInMiddleOfMomentum = scrollController.offset; + + // Move the mouse around. + final gesture = await tester.createGesture(); + await gesture.moveTo(tester.getTopLeft(find.byType(SuperReader))); + + // Let any momentum run. + await tester.pumpAndSettle(); + + // Ensure that the momentum didn't stop due to mouse movement. + expect(scrollOffsetInMiddleOfMomentum, lessThan(scrollController.offset)); + }); + + group("when all content fits in the viewport", () { + testWidgetsOnDesktop( + "trackpad doesn't scroll content", + (tester) async { + tester.view.physicalSize = const Size(800, 600); + + final isScrollUp = _scrollDirectionVariant.currentValue == _ScrollDirection.up; + + await tester // + .createDocument() + .withCustomContent( + paragraphThenHrThenParagraphDoc() + ..insertNodeAt( + 0, + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Document #1'), + metadata: { + 'blockType': header1Attribution, + }, + ), + ), + ) + .pump(); + + final scrollState = tester.state(find.byType(Scrollable)); + + // Perform a fling on the reader to attemp scrolling. + await tester.trackpadFling( + find.byType(SuperReader), + Offset(0.0, isScrollUp ? 100 : -100), + 300, + ); + + await tester.pump(); + + // Ensure SuperReader is not scrolling. + expect(scrollState.position.activity?.isScrolling, false); + }, + variant: _scrollDirectionVariant, + ); + + testWidgetsOnDesktop( + "mouse scroll wheel doesn't scroll content", + (tester) async { + tester.view.physicalSize = const Size(800, 600); + + final isScrollUp = _scrollDirectionVariant.currentValue == _ScrollDirection.up; + + await tester // + .createDocument() + .withCustomContent( + paragraphThenHrThenParagraphDoc() + ..insertNodeAt( + 0, + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText('Document #1'), + metadata: { + 'blockType': header1Attribution, + }, + ), + ), + ) + .pump(); + + final scrollState = tester.state(find.byType(Scrollable)); + + final Offset scrollEventLocation = tester.getCenter(find.byType(SuperReader)); + final TestPointer testPointer = TestPointer(1, PointerDeviceKind.mouse); + + // Send initial pointer event to set the location for subsequent pointer scroll events. + await tester.sendEventToBinding(testPointer.hover(scrollEventLocation)); + + // Send pointer scroll event to start scrolling. + await tester.sendEventToBinding( + testPointer.scroll( + Offset( + 0.0, + isScrollUp ? 100 : -100.0, + ), + ), + ); + + await tester.pump(); + + // Ensure SuperReader is not scrolling. + expect(scrollState.position.activity!.isScrolling, false); + }, + variant: _scrollDirectionVariant, + ); + }); + + group("with ancestor scrollable", () { + testWidgetsOnMobile('scrolling and holding the pointer doesn\'t change selection', (tester) async { + final scrollController = ScrollController(); + + // Pump a reader inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() // + .withLongTextContent() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + final scrollableRect = tester.getRect(find.byType(CustomScrollView)); + + const dragFrameCount = 10; + final dragAmountPerFrame = scrollableRect.height / dragFrameCount; + + // Drag from the bottom all the way up to the top of the scrollable. + final dragGesture = await tester.startGesture(scrollableRect.bottomCenter - const Offset(0, 1)); + for (int i = 0; i < dragFrameCount; i += 1) { + await dragGesture.moveBy(Offset(0, -dragAmountPerFrame)); + await tester.pump(); + } + + // The reader supports long press to select. + // Wait long enough to make sure this gesture wasn't confused with a long press. + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + + // Ensure we scrolled and didn't change the selection. + expect(scrollController.offset, greaterThan(0)); + expect(SuperReaderInspector.findDocumentSelection(), isNull); + + await dragGesture.up(); + await dragGesture.removePointer(); + }); + + testWidgetsOnMobile('scrolling and releasing the pointer doesn\'t change selection after gesture ended', + (tester) async { + final scrollController = ScrollController(); + + // Pump a reader inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() // + .withLongTextContent() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + final scrollableRect = tester.getRect(find.byType(CustomScrollView)); + + const dragFrameCount = 10; + final dragAmountPerFrame = scrollableRect.height / dragFrameCount; + + // Drag from the bottom all the way up to the top of the scrollable. + final dragGesture = await tester.startGesture(scrollableRect.bottomCenter - const Offset(0, 1)); + for (int i = 0; i < dragFrameCount; i += 1) { + await dragGesture.moveBy(Offset(0, -dragAmountPerFrame)); + await tester.pump(); + } + + // Stop the scrolling gesture. + await dragGesture.up(); + await dragGesture.removePointer(); + await tester.pump(); + + // The reader supports long press to select. + // Wait long enough to make sure this gesture wasn't confused with a long press. + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 1)); + + // Ensure we scrolled and didn't change the selection. + expect(scrollController.offset, greaterThan(0)); + expect(SuperReaderInspector.findDocumentSelection(), isNull); + }); + + testWidgetsOnAndroid("doesn't overscroll when dragging down", (tester) async { + final scrollController = ScrollController(); + + await tester + .createDocument() + .withSingleParagraph() + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount of pixels from the top of the reader. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(Viewport)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 400.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, 0); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnAndroid("doesn't overscroll when dragging up", (tester) async { + final scrollController = ScrollController(); + + // Pump a reader inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() + .withSingleParagraph() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + + // Drag an arbitrary amount of pixels from the bottom of the reader. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -400.0), + ); + + // Ensure we don't scroll. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + + // End the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + }); + + testWidgetsOnIos('overscrolls when dragging down', (tester) async { + final scrollController = ScrollController(); + + await tester + .createDocument() // + .withLongTextContent() + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Ensure the scrollview didn't start scrolled. + expect(scrollController.offset, 0); + + // Drag an arbitrary amount, smaller than the reader size. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).topCenter + const Offset(0, 5), + totalDragOffset: const Offset(0, 80.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, lessThan(0.0)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the top. + expect(scrollController.offset, 0.0); + }); + + testWidgetsOnIos('overscrolls when dragging up', (tester) async { + final scrollController = ScrollController(); + + // Pump a reader inside a CustomScrollView without enough room to display + // the whole content. + await tester + .createDocument() // + .withLongTextContent() + .withEditorSize(const Size(200, 200)) + .insideCustomScrollView() + .withScrollController(scrollController) + .pump(); + + // Jump to the bottom. + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pumpAndSettle(); + + // Drag up an arbitrary amount, smaller than the reader size. + final dragGesture = await tester.dragByFrameCount( + startLocation: tester.getRect(find.byType(CustomScrollView)).bottomCenter - const Offset(0, 5), + totalDragOffset: const Offset(0, -100.0), + ); + + // Ensure we are overscrolling while holding the pointer down. + await tester.pumpAndSettle(); + expect(scrollController.offset, greaterThan(scrollController.position.maxScrollExtent)); + + // Release the pointer to end the gesture. + await dragGesture.up(); + + // Wait for the long-press timer to resolve. + await tester.pumpAndSettle(); + + // Ensure the we scrolled back to the end. + expect(scrollController.offset, scrollController.position.maxScrollExtent); + }); + }); }); } + +final _scrollDirectionVariant = ValueVariant<_ScrollDirection>({ + _ScrollDirection.up, + _ScrollDirection.down, +}); + +enum _ScrollDirection { + up, + down; +} diff --git a/super_editor/test/super_reader/super_reader_selection_test.dart b/super_editor/test/super_reader/super_reader_selection_test.dart index c1c4806c52..23590f47ff 100644 --- a/super_editor/test/super_reader/super_reader_selection_test.dart +++ b/super_editor/test/super_reader/super_reader_selection_test.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_reader_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; - -import '../test_tools.dart'; -import 'reader_test_tools.dart'; +import 'package:super_editor/super_reader_test.dart'; void main() { group("SuperReader selection", () { @@ -25,7 +23,7 @@ void main() { // directions: right-to-left is upstream for a single line, and up-to-down is // downstream for multi-node. This test ensures that the single-line direction is // honored by the document layout, rather than the more common multi-node calculation. - final selection = layout.getDocumentSelectionInRegion(const Offset(200, 35), const Offset(150, 45)); + final selection = layout.getDocumentSelectionInRegion(const Offset(1100, 35), const Offset(1050, 45)); expect(selection, isNotNull); // Ensure that the document selection is upstream. @@ -51,7 +49,7 @@ void main() { // directions: left-to-right is downstream for a single line, and down-to-up is // upstream for multi-node. This test ensures that the single-line direction is // honored by the document layout, rather than the more common multi-node calculation. - final selection = layout.getDocumentSelectionInRegion(const Offset(150, 45), const Offset(200, 35)); + final selection = layout.getDocumentSelectionInRegion(const Offset(1050, 45), const Offset(1100, 35)); expect(selection, isNotNull); // Ensure that the document selection is downstream. @@ -65,7 +63,7 @@ void main() { .createDocument() // .fromMarkdown("This is paragraph one.\nThis is paragraph two.") // .pump(); - final nodeId = testContext.documentContext.document.nodes.first.id; + final nodeId = testContext.documentContext.document.first.id; /// Triple tap on the first line in the paragraph node. await tester.tripleTapInParagraph(nodeId, 10); @@ -96,7 +94,7 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final firstParagraphId = testContext.documentContext.document.nodes.first.id; + final firstParagraphId = testContext.documentContext.document.first.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -123,7 +121,7 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final secondParagraphId = testContext.documentContext.document.nodes.last.id; + final secondParagraphId = testContext.documentContext.document.last.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -156,7 +154,7 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final secondParagraphId = testContext.documentContext.document.nodes.last.id; + final secondParagraphId = testContext.documentContext.document.last.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -189,7 +187,7 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final firstParagraphId = testContext.documentContext.document.nodes.first.id; + final firstParagraphId = testContext.documentContext.document.first.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -216,8 +214,8 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final firstParagraphId = testContext.documentContext.document.nodes.first.id; - final secondParagraphId = testContext.documentContext.document.nodes.last.id; + final firstParagraphId = testContext.documentContext.document.first.id; + final secondParagraphId = testContext.documentContext.document.last.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -250,8 +248,8 @@ void main() { (tester) async { final testContext = await _pumpUnselectableComponentTestApp(tester); - final firstParagraphId = testContext.documentContext.document.nodes.first.id; - final secondParagraphId = testContext.documentContext.document.nodes.last.id; + final firstParagraphId = testContext.documentContext.document.first.id; + final secondParagraphId = testContext.documentContext.document.last.id; // TODO: replace the following direct layout access with a simulated user // drag, once we've merged some new dragging tools in #645. @@ -300,7 +298,7 @@ spans multiple lines.''', .pump(); final document = SuperReaderInspector.findDocument()!; - final paragraphNode = document.nodes.first as ParagraphNode; + final paragraphNode = document.first as ParagraphNode; await tester.dragSelectDocumentFromPositionByOffset( from: DocumentPosition( @@ -346,7 +344,7 @@ spans multiple lines.''', .pump(); final document = SuperReaderInspector.findDocument()!; - final paragraphNode = document.nodes.first as ParagraphNode; + final paragraphNode = document.first as ParagraphNode; await tester.dragSelectDocumentFromPositionByOffset( from: DocumentPosition( @@ -394,8 +392,8 @@ spans multiple lines.''', .pump(); final document = SuperReaderInspector.findDocument()!; - final titleNode = document.nodes.first as ParagraphNode; - final paragraphNode = document.nodes[1] as ParagraphNode; + final titleNode = document.first as ParagraphNode; + final paragraphNode = document.getNodeAt(1)! as ParagraphNode; await tester.dragSelectDocumentFromPositionByOffset( from: DocumentPosition( @@ -443,8 +441,8 @@ spans multiple lines.''', .pump(); final document = SuperReaderInspector.findDocument()!; - final titleNode = document.nodes.first as ParagraphNode; - final paragraphNode = document.nodes[1] as ParagraphNode; + final titleNode = document.first as ParagraphNode; + final paragraphNode = document.getNodeAt(1)! as ParagraphNode; await tester.dragSelectDocumentFromPositionByOffset( from: DocumentPosition( diff --git a/super_editor/test/super_reader/super_reader_stylesheet_test.dart b/super_editor/test/super_reader/super_reader_stylesheet_test.dart index f8365c915c..6f3270b7e0 100644 --- a/super_editor/test/super_reader/super_reader_stylesheet_test.dart +++ b/super_editor/test/super_reader/super_reader_stylesheet_test.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import '../test_tools.dart'; -import 'test_documents.dart'; +import '../../lib/src/test/flutter_extensions/test_documents.dart'; void main() { group("SuperReader stylesheets", () { @@ -37,7 +37,7 @@ void main() { } Finder _findTextWithAlignment(TextAlign textAlign) => - find.byWidgetPredicate((widget) => (widget is SuperTextWithSelection) && widget.textAlign == textAlign); + find.byWidgetPredicate((widget) => (widget is SuperText) && widget.textAlign == textAlign); Future _pumpReader( WidgetTester tester, { @@ -47,7 +47,10 @@ Future _pumpReader( MaterialApp( home: Scaffold( body: SuperReader( - document: singleParagraphDoc(), + editor: createDefaultDocumentEditor( + document: singleParagraphDoc(), + composer: MutableDocumentComposer(), + ), stylesheet: stylesheet, ), ), diff --git a/super_editor/test/super_reader/super_reader_tapregion_test.dart b/super_editor/test/super_reader/super_reader_tapregion_test.dart new file mode 100644 index 0000000000..2386eec8e4 --- /dev/null +++ b/super_editor/test/super_reader/super_reader_tapregion_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/test/super_reader_test/super_reader_inspector.dart'; +import 'package:super_editor/src/test/super_reader_test/super_reader_robot.dart'; + +import '../test_tools.dart'; +import '../../lib/src/test/super_reader_test/reader_test_tools.dart'; + +void main() { + group('SuperReader inside a TapRegion', () { + testWidgetsOnMobile("does not report a tap outside when the user touches overlay controls", (tester) async { + const tapRegionId = 'super_editor_group_id'; + final focusNode = FocusNode(); + + final context = await tester // + .createDocument() + .fromMarkdown('Single line document.') + .withFocusNode(focusNode) + .withTapRegionGroupId(tapRegionId) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + body: TapRegion( + groupId: tapRegionId, + onTapOutside: (e) { + // Fail on tap outside so that we're sure that the test + // pass when using TapRegion's for focus, because apps should be able + // to do that. + fail('Tapped outside of SuperReader'); + }, + child: superEditor, + ), + ), + ), + ) + .pump(); + + final nodeId = context.document.first.id; + + // Double tap to show the expanded handle. + await SuperReaderRobot(tester).doubleTapInParagraph(nodeId, 0); + + // Drag the downstream handle all the way to the end of the content. + final gesture = await SuperReaderRobot(tester).pressDownOnDownstreamMobileHandle(); + await gesture.moveBy(const Offset(500, 0)); + await tester.pump(); + + // Ensure the selection expanded to the end of the document. + expect( + SuperReaderInspector.findDocumentSelection(), + selectionEquivalentTo( + DocumentSelection( + base: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + extent: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 21), + ), + ), + ), + ); + + // Pump with enough time to expire the tap recognizer timer. + await tester.pump(kTapTimeout); + }); + }); +} diff --git a/super_editor/test/super_reader/superreader_attributions_test.dart b/super_editor/test/super_reader/superreader_attributions_test.dart new file mode 100644 index 0000000000..0ded01a4ce --- /dev/null +++ b/super_editor/test/super_reader/superreader_attributions_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/test/super_reader_test/super_reader_inspector.dart'; + +import '../super_editor/test_documents.dart'; +import '../../lib/src/test/super_reader_test/reader_test_tools.dart'; + +void main() { + group("SuperReader", () { + group("applies color attributions", () { + testWidgetsOnAllPlatforms("to full text", (tester) async { + await tester // + .createDocument() + .withCustomContent( + singleParagraphFullColor(), + ) + .pump(); + + // Ensure the text is colored orange. + final text = SuperReaderInspector.findTextInParagraph("1"); + final richText = SuperReaderInspector.findRichTextInParagraph("1"); + expect( + richText.getSpanForPosition(const TextPosition(offset: 1))!.style!.color, + Colors.orange, + ); + expect( + richText.getSpanForPosition(TextPosition(offset: text.length - 1))!.style!.color, + Colors.orange, + ); + }); + + testWidgetsOnAllPlatforms("to partial text", (tester) async { + await tester // + .createDocument() + .withCustomContent( + singleParagraphWithPartialColor(), + ) + .pump(); + + // Ensure the first span is colored black. + expect( + SuperReaderInspector.findRichTextInParagraph("1") + .getSpanForPosition(const TextPosition(offset: 0))! + .style! + .color, + Colors.black, + ); + + // Ensure the second span is colored orange. + expect( + SuperReaderInspector.findRichTextInParagraph("1") + .getSpanForPosition(const TextPosition(offset: 5))! + .style! + .color, + Colors.orange, + ); + }); + }); + }); +} diff --git a/super_editor/test/super_reader/test_documents.dart b/super_editor/test/super_reader/test_documents.dart deleted file mode 100644 index 1436ee1bf4..0000000000 --- a/super_editor/test/super_reader/test_documents.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:super_editor/super_editor.dart'; - -MutableDocument paragraphThenHrThenParagraphDoc() => MutableDocument( - nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "This is the first node in a document.")), - HorizontalRuleNode(id: "2"), - ParagraphNode(id: "3", text: AttributedText(text: "This is the third node in a document.")), - ], - ); - -MutableDocument paragraphThenHrDoc() => MutableDocument( - nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "Paragraph 1")), - HorizontalRuleNode(id: "2"), - ], - ); - -MutableDocument hrThenParagraphDoc() => MutableDocument( - nodes: [ - HorizontalRuleNode(id: "1"), - ParagraphNode(id: "2", text: AttributedText(text: "Paragraph 1")), - ], - ); - -MutableDocument singleParagraphEmptyDoc() => MutableDocument( - nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "")), - ], - ); - -MutableDocument twoParagraphEmptyDoc() => MutableDocument( - nodes: [ - ParagraphNode(id: "1", text: AttributedText(text: "")), - ParagraphNode(id: "2", text: AttributedText(text: "")), - ], - ); - -MutableDocument singleParagraphDoc() => MutableDocument( - nodes: [ - ParagraphNode( - id: "1", - text: AttributedText( - // String length is 445 - text: - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - ), - ), - ], - ); - -MutableDocument singleBlockDoc() => MutableDocument( - nodes: [ - HorizontalRuleNode(id: "1"), - ], - ); - -MutableDocument longTextDoc() => MutableDocument( - nodes: [ - ParagraphNode( - id: "1", - text: AttributedText( - text: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', - ), - ), - ParagraphNode( - id: "2", - text: AttributedText( - text: - 'Cras vitae sodales nisi. Vivamus dignissim vel purus vel aliquet. Sed viverra diam vel nisi rhoncus pharetra. Donec gravida ut ligula euismod pharetra. Etiam sed urna scelerisque, efficitur mauris vel, semper arcu. Nullam sed vehicula sapien. Donec id tellus volutpat, eleifend nulla eget, rutrum mauris.'), - ), - ParagraphNode( - id: "3", - text: AttributedText( - text: - 'Nam hendrerit vitae elit ut placerat. Maecenas nec congue neque. Fusce eget tortor pulvinar, cursus neque vitae, sagittis lectus. Duis mollis libero eu scelerisque ullamcorper. Pellentesque eleifend arcu nec augue molestie, at iaculis dui rutrum. Etiam lobortis magna at magna pellentesque ornare. Sed accumsan, libero vel porta molestie, tortor lorem eleifend ante, at egestas leo felis sed nunc. Quisque mi neque, molestie vel dolor a, eleifend tempor odio.', - ), - ), - ParagraphNode( - id: "4", - text: AttributedText( - text: - 'Etiam id lacus interdum, efficitur ex convallis, accumsan ipsum. Integer faucibus mollis mauris, a suscipit ante mollis vitae. Fusce justo metus, congue non lectus ac, luctus rhoncus tellus. Phasellus vitae fermentum orci, sit amet sodales orci. Fusce at ante iaculis nunc aliquet pharetra. Nam placerat, nisl in gravida lacinia, nisl nibh feugiat nunc, in sagittis nisl sapien nec arcu. Nunc gravida faucibus massa, sit amet accumsan dolor feugiat in. Mauris ut elementum leo.', - ), - ), - ], - ); diff --git a/super_editor/test/super_textfield/android/super_textfield_android_scrolling_test.dart b/super_editor/test/super_textfield/android/super_textfield_android_scrolling_test.dart new file mode 100644 index 0000000000..84be58cf99 --- /dev/null +++ b/super_editor/test/super_textfield/android/super_textfield_android_scrolling_test.dart @@ -0,0 +1,338 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/super_text_field.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group("SuperTextField > scrolling >", () { + testWidgetsOnAndroid('auto-scrolls to caret position upon widget initialization', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + controller.selection = TextSelection.collapsed(offset: controller.text.length); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines how far we need to drag the caret + // to the right to enter the auto-scroll region. + maxWidth: 200, + ); + + // Ensure that the text field auto-scrolled to the end, where the caret should be placed. + expect(SuperTextFieldInspector.isScrolledToEnd(), isTrue); + }); + + testWidgetsOnAndroid('single-line auto-scrolls to the right by caret', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines how far we need to drag the caret + // to the right to enter the auto-scroll region. + maxWidth: 200, + ); + + await tester.placeCaretInSuperTextField(0); + expect(controller.selection.extentOffset, 0); + + // Drag caret from left side of text field to right side of text field, into the + // right auto-scroll region. + final gesture = await tester.dragCaretByDistanceInSuperTextField(const Offset(220, 0)); + + // Pump a few more frames and ensure that every frame moves the caret further. + int previousCaretPosition = controller.selection.extentOffset; + for (int i = 0; i < 10; i += 1) { + await tester.pump(const Duration(milliseconds: 50)); + final newCaretPosition = controller.selection.extentOffset; + expect(newCaretPosition, greaterThan(previousCaretPosition), + reason: "Caret position didn't move on drag frame $i"); + previousCaretPosition = newCaretPosition; + } + + // Log the scroll offset to make sure that the scroll offset doesn't jump back + // to the left when we move out of the auto-scroll region. This is a glitch that + // we saw in #1673. + final scrollOffsetAfterAutoScroll = SuperTextFieldInspector.findScrollOffset(); + + // Drag back to the left to leave the auto-scroll region. + await gesture.moveBy(const Offset(-50, 0)); + await tester.pump(); + + // Pump a few frames to ensure that the selection isn't jumping around + // in a glitchy manner. + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + + // Ensure that when we moved slightly back to the left, to move out of the auto-scroll + // region, the scroll offset didn't jump somewhere else. + expect(SuperTextFieldInspector.findScrollOffset(), scrollOffsetAfterAutoScroll); + + // Release the gesture. + await gesture.up(); + + // Ensure that the scroll offset didn't change after we released. + expect(SuperTextFieldInspector.findScrollOffset(), scrollOffsetAfterAutoScroll); + }); + + testWidgetsOnAndroid('single-line auto-scrolls to the right by handle', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines how far we need to drag the caret + // to the right to enter the auto-scroll region. + maxWidth: 200, + ); + + await tester.placeCaretInSuperTextField(0); + expect(controller.selection.extentOffset, 0); + + // Drag handle from left side of text field to right side of text field, into the + // right auto-scroll region. + final gesture = await tester.dragAndroidCollapsedHandleByDistanceInSuperTextField(const Offset(190, 0)); + + // Pump a few more frames and ensure that every frame moves the caret further. + int previousCaretPosition = controller.selection.extentOffset; + for (int i = 0; i < 10; i += 1) { + await tester.pump(const Duration(milliseconds: 50)); + final newCaretPosition = controller.selection.extentOffset; + expect(newCaretPosition, greaterThan(previousCaretPosition), + reason: "Caret position didn't move on drag frame $i"); + previousCaretPosition = newCaretPosition; + } + + // Log the scroll offset to make sure that the scroll offset doesn't jump back + // to the left when we move out of the auto-scroll region. This is a glitch that + // we saw in #1673. + final scrollOffsetAfterAutoScroll = SuperTextFieldInspector.findScrollOffset(); + + // Drag back to the left to leave the auto-scroll region. + await gesture.moveBy(const Offset(-50, 0)); + await tester.pump(); + + // Pump a few frames to ensure that the selection isn't jumping around + // in a glitchy manner. + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + + // Ensure that when we moved slightly back to the left, to move out of the auto-scroll + // region, the scroll offset didn't jump somewhere else. + expect(SuperTextFieldInspector.findScrollOffset(), scrollOffsetAfterAutoScroll); + + // Release the gesture. + await gesture.up(); + + // Ensure that the scroll offset didn't change after we released. + expect(SuperTextFieldInspector.findScrollOffset(), scrollOffsetAfterAutoScroll); + + // When working on #1673 I found that specifically when dragging to the right using + // the handle (not caret), the text field jumps all the way to the end of the text. + // Make sure we're not at the end of the text. + expect(SuperTextFieldInspector.isScrolledToEnd(), isFalse); + }); + + testWidgetsOnAndroid('single-line auto-scrolls to the left', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + controller.selection = TextSelection.collapsed(offset: controller.text.length); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines how far we need to drag the caret + // to the right to enter the auto-scroll region. + maxWidth: 200, + // Add padding to leave room to see the caret at the very end of the + // text. + padding: const EdgeInsets.symmetric(horizontal: 2), + autofocus: true, + ); + + // Ensure we're starting scrolled to the end. + expect(SuperTextFieldInspector.isScrolledToEnd(), isTrue); + + // Drag caret from right side of text field to left side of text field, into the + // left auto-scroll region. + final gesture = await tester.dragCaretByDistanceInSuperTextField(const Offset(-220, 0)); + + // Pump a few more frames and ensure that every frame moves the caret further. + int previousCaretPosition = controller.selection.extentOffset; + for (int i = 0; i < 10; i += 1) { + await tester.pump(const Duration(milliseconds: 50)); + final newCaretPosition = controller.selection.extentOffset; + expect(newCaretPosition, lessThan(previousCaretPosition), + reason: "Caret position didn't move on drag frame $i"); + previousCaretPosition = newCaretPosition; + } + + // Log the scroll offset to make sure that the scroll offset doesn't jump back + // to the left when we move out of the auto-scroll region. This is a glitch that + // we saw in #1673. + final scrollOffsetAfterAutoScroll = SuperTextFieldInspector.findScrollOffset(); + + // Drag back to the right to leave the auto-scroll region. + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // Pump a few frames to ensure that we're the selection isn't jumping around + // in a glitchy manner. + await tester.pump(); + await tester.pump(); + await tester.pump(); + + // Ensure that when we moved slightly back to the right, to move out of the auto-scroll + // region, the scroll offset didn't jump somewhere else. + expect(SuperTextFieldInspector.findScrollOffset(), scrollOffsetAfterAutoScroll); + + // Release the gesture. + await gesture.up(); + }); + + testWidgetsOnAndroid('single-line drag does nothing without a selection', (tester) async { + // Test explanation: I experimented with single-line text fields in a few iOS apps + // and I found that dragging in an area away from the caret doesn't have any effect. + // It doesn't scroll the text field, it doesn't move the caret, nothing. + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines whether the text fits, or + // if scrolling is available. + maxWidth: 200, + ); + + // Ensure there's no selection and no focus. + expect(SuperTextFieldInspector.findSelection()!.isValid, isFalse); + expect(SuperTextFieldInspector.hasFocus(), isFalse); + + // Drag from right to left. + await tester.drag(find.byType(SuperTextField), const Offset(-100, 0)); + + // Ensure the scroll offset didn't change and there's still no selection or focus. + expect(SuperTextFieldInspector.findScrollOffset()!, 0); + expect(SuperTextFieldInspector.findSelection()!.isValid, isFalse); + expect(SuperTextFieldInspector.hasFocus(), isFalse); + + // Pump with enough time to expire the tap recognizer timer. + await tester.pump(kTapTimeout); + }); + + testWidgetsOnAndroid('single-line drag does nothing with collapsed selection', (tester) async { + // Test explanation: I experimented with single-line text fields in a few iOS apps + // and I found that dragging in an area away from the caret doesn't have any effect. + // It doesn't scroll the text field, it doesn't move the caret, nothing. + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines whether the text fits, or + // if scrolling is available. + maxWidth: 200, + ); + + // Place a caret in the field. + await tester.placeCaretInSuperTextField(0); + + // Ensure there's a selection with focus. + expect(SuperTextFieldInspector.findSelection()!.isValid, isTrue); + expect(SuperTextFieldInspector.hasFocus(), isTrue); + + // Drag from right to left, far away from the caret. + final selectionBeforeDrag = SuperTextFieldInspector.findSelection(); + await tester.drag(find.byType(SuperTextField), const Offset(100, 0)); + + // Ensure the scroll offset and the selection didn't change. + expect(SuperTextFieldInspector.findScrollOffset()!, 0); + expect(SuperTextFieldInspector.findSelection(), selectionBeforeDrag); + + // Pump with enough time to expire the tap recognizer timer. + await tester.pump(kTapTimeout); + }); + }); +} + +Future _pumpTestApp( + WidgetTester tester, { + required AttributedTextEditingController textController, + required int minLines, + required int maxLines, + double? maxWidth, + double? maxHeight, + EdgeInsets? padding, + bool autofocus = false, +}) async { + final focusNode = FocusNode(); + if (autofocus) { + focusNode.requestFocus(); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth ?? double.infinity, + maxHeight: maxHeight ?? double.infinity, + ), + child: SuperTextField( + focusNode: focusNode, + textController: textController, + lineHeight: 20, + textStyleBuilder: (_) => const TextStyle(fontSize: 20), + minLines: minLines, + maxLines: maxLines, + padding: padding, + ), + ), + ), + ), + ), + ); + + // The first frame might have a zero viewport height. Pump a second frame to account for the final viewport size. + await tester.pump(); +} diff --git a/super_editor/test/super_textfield/android/super_textfield_android_selection_test.dart b/super_editor/test/super_textfield/android/super_textfield_android_selection_test.dart new file mode 100644 index 0000000000..9a197cb70a --- /dev/null +++ b/super_editor/test/super_textfield/android/super_textfield_android_selection_test.dart @@ -0,0 +1,450 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/src/infrastructure/platforms/android/toolbar.dart'; +import 'package:super_editor/super_text_field.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group("SuperTextField Android selection >", () { + testWidgetsOnAndroid("long-pressing in empty space shows the toolbar", (tester) async { + await _pumpTestApp(tester); + + // Ensure there's no selection to begin with, and no toolbar is displayed. + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: -1)); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Place the caret at the end of the text by tapping in empty space at the center + // of the text field. + await tester.tap(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 3)); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Long-press in empty space at the center of the text field. + await tester.longPress(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + // Ensure that the text field toolbar is visible. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOneWidget); + + // Tap again to hide the toolbar. + await tester.tap(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + // Ensure that the text field toolbar disappeared. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + }); + + testWidgetsOnAndroid("long-pressing in empty space when there is NO selection does NOT show the toolbar", + (tester) async { + await _pumpTestApp(tester); + + // Ensure there's no selection to begin with, and no toolbar is displayed. + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: -1)); + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Long-press in empty space at the center of the text field. + await tester.longPress(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + // Ensure that no toolbar is displayed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + }); + + testWidgetsOnAndroid("tapping at collapsed handle shows/hides the toolbar", (tester) async { + await _pumpTestApp(tester); + + // Ensure no toolbar is displayed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Place caret at the end of the textfield. + await tester.placeCaretInSuperTextField(3); + + // Ensure no toolbar is displayed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Tap on the drag handle to show the toolbar. + await tester.tapOnAndroidCollapsedHandle(); + await tester.pump(); + + // Ensure that the text field toolbar is visible. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOneWidget); + + // Tap on the drag handle to hide the toolbar. + await tester.tapOnAndroidCollapsedHandle(); + await tester.pump(); + + // Ensure the toolbar disappeared. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + }); + + testWidgetsOnAndroid("tapping at existing collapsed selection shows/hides the toolbar", (tester) async { + await _pumpTestApp(tester); + + // Ensure no toolbar is displayed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Place caret at "ab|c". + await tester.placeCaretInSuperTextField(2); + + // Ensure no toolbar is displayed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Tap again on the same position to show the toolbar. + await tester.placeCaretInSuperTextField(2); + + // Ensure that the toolbar is visible and the selection didn't change. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOneWidget); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 2), + ); + + // Tap again on the same position to hide the toolbar. + await tester.placeCaretInSuperTextField(2); + + // Ensure the toolbar disappeared and the selection didn't change. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 2), + ); + }); + + testWidgetsOnAndroid("tapping at existing expanded selection places the caret", (tester) async { + await _pumpTestApp(tester); + + // Ensure no toolbar is displayed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Double tap to select "abc". + await tester.doubleTapAtSuperTextField(2); + + // Ensure the toolbar is displayed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOneWidget); + + // Tap at "ab|c" to place the caret. Pump to avoid a pan gesture. + await tester.pump(kTapTimeout); + await tester.placeCaretInSuperTextField(2); + + // Ensure that the toolbar disappeared and the selection changed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 2), + ); + }); + + testWidgetsOnAndroid("hides toolbar when the user taps to move the caret", (tester) async { + await _pumpTestApp(tester); + + // Ensure no toolbar is displayed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Place caret at the beginning of the textfield. + await tester.placeCaretInSuperTextField(0); + + // Ensure no toolbar is displayed. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + + // Tap on the drag handle to show the toolbar. + await tester.tapOnAndroidCollapsedHandle(); + await tester.pump(); + + // Ensure that the text field toolbar is visible. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsOneWidget); + + // Place caret at the end of the textfield. + await tester.placeCaretInSuperTextField(3); + + // Ensure the toolbar disappeared. + expect(find.byType(AndroidTextEditingFloatingToolbar), findsNothing); + }); + + group("drag handle selection > ", () { + testWidgetsOnAndroid("selects by word when dragging downstream", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("Lorem ipsum dolor sit amet consectetur"), + ); + + // Pump a tree with a text field wide enough that we know it won't be scrollable. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 1000, + child: SuperTextField( + textController: controller, + ), + ), + ), + ), + ); + + // Double tap to select the word "dolor". + await tester.doubleTapAtSuperTextField(14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 12, extentOffset: 17), + ); + + // Drag the downstream handle to the beginning of the downstream word. + // "Lorem ipsum [dolor sit a]met" + // ^ position 23 + final textLayout = SuperTextFieldInspector.findProseTextLayout(); + final downstreamPositionBox = textLayout.getCharacterBox(const TextPosition(offset: 17)); + final desiredPositionBox = textLayout.getCharacterBox(const TextPosition(offset: 23)); + final gesture = await tester.dragDownstreamMobileHandleByDistanceInSuperTextField( + Offset(desiredPositionBox!.right - downstreamPositionBox!.right, 0.0), + ); + + // Ensure the upstream handle remained where it began and the downstream handle + // jumped to the end of the partially selected word. + // + // "Lorem ipsum [dolor sit amet]" + // ^ position 26 + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection( + baseOffset: 12, + extentOffset: 26, + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by character when dragging downstream in reverse", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("Lorem ipsum dolor sit amet consectetur"), + ); + + // Pump a tree with a text field wide enough that we know it won't be scrollable. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 1000, + child: SuperTextField( + textController: controller, + ), + ), + ), + ), + ); + + // Double tap to select the word "consectetur". + await tester.doubleTapAtSuperTextField(34); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 27, extentOffset: 38), + ); + + // Drag the downstream handle towards the beginning of the selected word. + // "Lorem ipsum dolor sit amet [con]sectetur" + // ^ position 30 + final textLayout = SuperTextFieldInspector.findProseTextLayout(); + final downstreamPositionBox = textLayout.getCharacterBox(const TextPosition(offset: 38)); + final desiredPositionBox = textLayout.getCharacterBox(const TextPosition(offset: 30)); + final gesture = await tester.dragDownstreamMobileHandleByDistanceInSuperTextField( + Offset(desiredPositionBox!.left - downstreamPositionBox!.right, 0.0), + ); + + // Ensure that part of the downstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsum dolor sit amet [con]sectetur" + // ^ position 27 + // ^ position 30 + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection( + baseOffset: 27, + extentOffset: 30, + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by word when dragging upstream", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("Lorem ipsum dolor sit amet consectetur"), + ); + + // Pump a tree with a text field wide enough that we know it won't be scrollable. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 1000, + child: SuperTextField( + textController: controller, + ), + ), + ), + ), + ); + + // Double tap to select the word "dolor". + await tester.doubleTapAtSuperTextField(14); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + // Ensure the word was selected. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 12, extentOffset: 17), + ); + + // Drag the upstream handle to the end of the upstream word. + // "Lorem ipsu[m dolor] sit amet" + // ^ position 10 + final textLayout = SuperTextFieldInspector.findProseTextLayout(); + final upstreamPositionBox = textLayout.getCharacterBox(const TextPosition(offset: 12)); + final desiredPositionBox = textLayout.getCharacterBox(const TextPosition(offset: 10)); + final gesture = await tester.dragUpstreamMobileHandleByDistanceInSuperTextField( + Offset(desiredPositionBox!.left - upstreamPositionBox!.left, 0.0), + ); + + // Ensure the downstream handle remained where it began and the upstream handle + // jumped to the beginning of the partially selected word. + // + // "Lorem [ipsum dolor] sit amet" + // ^ position 6 + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection( + baseOffset: 6, + extentOffset: 17, + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + + testWidgetsOnAndroid("selects by character when dragging upstream in reverse", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("Lorem ipsum dolor sit amet consectetur"), + ); + + // Pump a tree with a text field wide enough that we know it won't be scrollable. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 1000, + child: SuperTextField( + textController: controller, + ), + ), + ), + ), + ); + + // Double tap to select the word "consectetur". + await tester.doubleTapAtSuperTextField(34); + await tester.pumpAndSettle(); + + // Ensure the word was selected. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 27, extentOffset: 38), + ); + + // Drag the upstream handle towards the end of the selected word. + // "Lorem ipsum dolor sit amet consect[etur]" + // ^ position 34 + final textLayout = SuperTextFieldInspector.findProseTextLayout(); + final upstreamPositionBox = textLayout.getCharacterBox(const TextPosition(offset: 27)); + final desiredPositionBox = textLayout.getCharacterBox(const TextPosition(offset: 34)); + final gesture = await tester.dragUpstreamMobileHandleByDistanceInSuperTextField( + Offset(desiredPositionBox!.left - upstreamPositionBox!.left, 0.0), + ); + + // Ensure that part of the downstream word is selected because we're now + // in per-character selection mode. + // + // "Lorem ipsum dolor sit amet consect[etur]" + // ^ position 34 + // ^ position 38 + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection( + baseOffset: 34, + extentOffset: 38, + ), + ); + + // Release the gesture so the test system doesn't complain. + await gesture.up(); + await tester.pump(); + }); + }); + }); +} + +Future _pumpTestApp( + WidgetTester tester, { + AttributedTextEditingController? controller, + EdgeInsets? padding, + TextAlign? textAlign, +}) async { + final textFieldFocusNode = FocusNode(); + const tapRegionGroupdId = "test_super_text_field"; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TapRegion( + groupId: tapRegionGroupdId, + onTapOutside: (_) { + // Unfocus on tap outside so that we're sure that all gesture tests + // pass when using TapRegion's for focus, because apps should be able + // to do that. + textFieldFocusNode.unfocus(); + }, + child: SizedBox.expand( + child: Align( + alignment: Alignment.center, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + ), + child: SuperTextField( + focusNode: textFieldFocusNode, + tapRegionGroupId: tapRegionGroupdId, + padding: padding, + textAlign: textAlign ?? TextAlign.left, + textController: controller ?? + AttributedTextEditingController( + text: AttributedText('abc'), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/super_editor/test/super_textfield/attributed_text_editing_controller_test.dart b/super_editor/test/super_textfield/attributed_text_editing_controller_test.dart index afd3181903..c97094e197 100644 --- a/super_editor/test/super_textfield/attributed_text_editing_controller_test.dart +++ b/super_editor/test/super_textfield/attributed_text_editing_controller_test.dart @@ -5,7 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/default_editor/attributions.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; void main() { @@ -14,7 +14,7 @@ void main() { test("does nothing at beginning of text when collapsed", () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'one two three', + 'one two three', ), )..selection = const TextSelection.collapsed(offset: 0); @@ -33,7 +33,7 @@ void main() { test("does nothing at beginning of text when expanded", () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'one two three', + 'one two three', ), )..selection = const TextSelection(extentOffset: 0, baseOffset: 3); @@ -52,7 +52,7 @@ void main() { test("does nothing at end of text when collapsed", () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'one two three', + 'one two three', ), )..selection = const TextSelection.collapsed(offset: 13); // at the end of the text. @@ -71,7 +71,7 @@ void main() { test("does nothing at end of text when expanded", () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'one two three', + 'one two three', ), )..selection = const TextSelection(extentOffset: 13, baseOffset: 8); @@ -90,7 +90,7 @@ void main() { test("jumps word upstream", () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'one two three', + 'one two three', ), )..selection = const TextSelection.collapsed(offset: 7); @@ -109,7 +109,7 @@ void main() { test("jumps word downstream", () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'one two three', + 'one two three', ), )..selection = const TextSelection.collapsed(offset: 4); @@ -144,7 +144,7 @@ void main() { ..insertAtCaret(text: 'l') ..insertAtCaret(text: 'd'); - expect(controller.text.text, equals('Hello World')); + expect(controller.text.toPlainText(), equals('Hello World')); ExpectedSpans([ '______bbbbb', ]).expectSpans(controller.text.spans); @@ -153,8 +153,8 @@ void main() { test('types new text in the middle of styled text', () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'before [] after', - spans: AttributedSpans( + 'before [] after', + AttributedSpans( attributions: [ const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), @@ -165,7 +165,7 @@ void main() { ..selection = const TextSelection.collapsed(offset: 8) ..insertAtCaret(text: 'b'); - expect(controller.text.text, equals('before [b] after')); + expect(controller.text.toPlainText(), equals('before [b] after')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 9))); ExpectedSpans([ '_______bbb______', @@ -175,8 +175,8 @@ void main() { test('types batch of new text in the middle of styled text', () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'before [] after', - spans: AttributedSpans( + 'before [] after', + AttributedSpans( attributions: [ const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), @@ -187,7 +187,7 @@ void main() { ..selection = const TextSelection.collapsed(offset: 8) ..insertAtCaret(text: 'hello'); - expect(controller.text.text, equals('before [hello] after')); + expect(controller.text.toPlainText(), equals('before [hello] after')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 13))); ExpectedSpans([ '_______bbbbbbb______', @@ -197,8 +197,8 @@ void main() { test('types unstyled text in the middle of styled text', () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'before [] after', - spans: AttributedSpans( + 'before [] after', + AttributedSpans( attributions: [ const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), @@ -210,7 +210,7 @@ void main() { ..clearComposingAttributions() ..insertAtCaret(text: 'b'); - expect(controller.text.text, equals('before [b] after')); + expect(controller.text.toPlainText(), equals('before [b] after')); ExpectedSpans([ '_______b_b______', ]).expectSpans(controller.text.spans); @@ -219,8 +219,8 @@ void main() { test('clears composing attributions by deleting all styled text', () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'before [] after', - spans: AttributedSpans( + 'before [] after', + AttributedSpans( attributions: [ const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), @@ -240,50 +240,50 @@ void main() { test('tries to delete previous character at beginning of text', () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'some text', + 'some text', ), ) ..selection = const TextSelection.collapsed(offset: 0) ..deletePreviousCharacter(); - expect(controller.text.text, equals('some text')); + expect(controller.text.toPlainText(), equals('some text')); }); test('deletes first character in text', () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'some text', + 'some text', ), ) ..selection = const TextSelection.collapsed(offset: 1) ..deletePreviousCharacter(); - expect(controller.text.text, equals('ome text')); + expect(controller.text.toPlainText(), equals('ome text')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 0))); }); test('tries to delete next character at end of text', () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'some text', + 'some text', ), ) ..selection = const TextSelection.collapsed(offset: 9) ..deleteNextCharacter(); - expect(controller.text.text, equals('some text')); + expect(controller.text.toPlainText(), equals('some text')); }); test('deletes last character in text', () { final controller = AttributedTextEditingController( text: AttributedText( - text: 'some text', + 'some text', ), ) ..selection = const TextSelection.collapsed(offset: 8) ..deleteNextCharacter(); - expect(controller.text.text, equals('some tex')); + expect(controller.text.toPlainText(), equals('some tex')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 8))); }); }); @@ -294,7 +294,7 @@ void main() { selection: const TextSelection.collapsed(offset: 0), )..insertAtCaret(text: 'newtext'); - expect(controller.text.text, equals('newtext')); + expect(controller.text.toPlainText(), equals('newtext')); }); test('into empty text with caret', () { @@ -303,42 +303,42 @@ void main() { ); controller.insertAtCaret(text: 'newtext'); - expect(controller.text.text, equals('newtext')); + expect(controller.text.toPlainText(), equals('newtext')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 7))); }); test('into start of existing text', () { final controller = AttributedTextEditingController( - text: AttributedText(text: ':existing text'), + text: AttributedText(':existing text'), selection: const TextSelection.collapsed(offset: 0), ); controller.insertAtCaret(text: 'newtext'); - expect(controller.text.text, equals('newtext:existing text')); + expect(controller.text.toPlainText(), equals('newtext:existing text')); }); test('into start of existing text and pushes caret back', () { final controller = AttributedTextEditingController( - text: AttributedText(text: ':existing text'), + text: AttributedText(':existing text'), selection: const TextSelection.collapsed(offset: 0), ); controller.insertAtCaret(text: 'newtext'); - expect(controller.text.text, equals('newtext:existing text')); + expect(controller.text.toPlainText(), equals('newtext:existing text')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 7))); }); test('into start of existing text and pushes selection back', () { final controller = AttributedTextEditingController( - text: AttributedText(text: ':existing text'), + text: AttributedText(':existing text'), selection: const TextSelection( baseOffset: 1, extentOffset: 9, ), ); - controller.insert(newText: AttributedText(text: 'newtext'), insertIndex: 0); + controller.insert(newText: AttributedText('newtext'), insertIndex: 0); - expect(controller.text.text, equals('newtext:existing text')); + expect(controller.text.toPlainText(), equals('newtext:existing text')); expect( controller.selection, equals( @@ -352,15 +352,15 @@ void main() { test('into start of existing text and expands existing selection', () { final controller = AttributedTextEditingController( - text: AttributedText(text: ':existing text'), + text: AttributedText(':existing text'), selection: const TextSelection( baseOffset: 0, extentOffset: 9, ), ); - controller.insert(newText: AttributedText(text: 'newtext'), insertIndex: 0); + controller.insert(newText: AttributedText('newtext'), insertIndex: 0); - expect(controller.text.text, equals('newtext:existing text')); + expect(controller.text.toPlainText(), equals('newtext:existing text')); expect( controller.selection, equals( @@ -374,33 +374,33 @@ void main() { test('into end of existing text', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'existing text:'), + text: AttributedText('existing text:'), selection: const TextSelection.collapsed(offset: 14), ); controller.insertAtCaret(text: 'newtext'); - expect(controller.text.text, equals('existing text:newtext')); + expect(controller.text.toPlainText(), equals('existing text:newtext')); }); test('into end of existing text with caret before inserted text', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'existing text:'), + text: AttributedText('existing text:'), selection: const TextSelection.collapsed(offset: 14), ); controller.insertAtCaret(text: 'newtext'); - expect(controller.text.text, equals('existing text:newtext')); + expect(controller.text.toPlainText(), equals('existing text:newtext')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 21))); }); test('into end of existing text with selection before inserted text', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'existing text:'), + text: AttributedText('existing text:'), selection: const TextSelection(baseOffset: 0, extentOffset: 8), ); - controller.insert(newText: AttributedText(text: 'newtext'), insertIndex: 14); + controller.insert(newText: AttributedText('newtext'), insertIndex: 14); - expect(controller.text.text, equals('existing text:newtext')); + expect(controller.text.toPlainText(), equals('existing text:newtext')); expect( controller.selection, equals( @@ -415,29 +415,29 @@ void main() { test('into middle of text with caret at insertion', () { final controller = AttributedTextEditingController( text: AttributedText( - text: '[]:existing text', + '[]:existing text', ), selection: const TextSelection.collapsed(offset: 1), ); controller.insertAtCaret(text: 'newtext'); - expect(controller.text.text, equals('[newtext]:existing text')); + expect(controller.text.toPlainText(), equals('[newtext]:existing text')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 8))); }); test('into middle of text with selection around insertion', () { final controller = AttributedTextEditingController( text: AttributedText( - text: '[]:existing text', + '[]:existing text', ), selection: const TextSelection( baseOffset: 0, extentOffset: 2, ), ); - controller.insert(newText: AttributedText(text: 'newtext'), insertIndex: 1); + controller.insert(newText: AttributedText('newtext'), insertIndex: 1); - expect(controller.text.text, equals('[newtext]:existing text')); + expect(controller.text.toPlainText(), equals('[newtext]:existing text')); expect( controller.selection, equals( @@ -452,16 +452,16 @@ void main() { test('into middle of text with selection after insertion', () { final controller = AttributedTextEditingController( text: AttributedText( - text: '[]:existing text', + '[]:existing text', ), selection: const TextSelection( baseOffset: 3, extentOffset: 11, ), ); - controller.insert(newText: AttributedText(text: 'newtext'), insertIndex: 1); + controller.insert(newText: AttributedText('newtext'), insertIndex: 1); - expect(controller.text.text, equals('[newtext]:existing text')); + expect(controller.text.toPlainText(), equals('[newtext]:existing text')); expect( controller.selection, equals( @@ -476,8 +476,8 @@ void main() { test('before styled text - the style is not extended', () { final controller = AttributedTextEditingController( text: AttributedText( - text: '[]:unstyled text', - spans: AttributedSpans( + '[]:unstyled text', + AttributedSpans( attributions: [ const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), const SpanMarker(attribution: boldAttribution, offset: 2, markerType: SpanMarkerType.end), @@ -488,7 +488,7 @@ void main() { ); controller.insertAtCaret(text: 'newtext'); - expect(controller.text.text, equals('newtext[]:unstyled text')); + expect(controller.text.toPlainText(), equals('newtext[]:unstyled text')); ExpectedSpans([ '_______bb______________', ]).expectSpans(controller.text.spans); @@ -497,8 +497,8 @@ void main() { test('into middle of styled text - the style is extended', () { final controller = AttributedTextEditingController( text: AttributedText( - text: '[]:unstyled text', - spans: AttributedSpans( + '[]:unstyled text', + AttributedSpans( attributions: [ const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), const SpanMarker(attribution: boldAttribution, offset: 2, markerType: SpanMarkerType.end), @@ -509,7 +509,7 @@ void main() { ); controller.insertAtCaret(text: 'newtext'); - expect(controller.text.text, equals('[newtext]:unstyled text')); + expect(controller.text.toPlainText(), equals('[newtext]:unstyled text')); ExpectedSpans([ 'bbbbbbbbb______________', ]).expectSpans(controller.text.spans); @@ -518,8 +518,8 @@ void main() { test('after styled text - the style is extended', () { final controller = AttributedTextEditingController( text: AttributedText( - text: '[]:unstyled text', - spans: AttributedSpans( + '[]:unstyled text', + AttributedSpans( attributions: [ const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.end), @@ -530,7 +530,7 @@ void main() { ); controller.insertAtCaret(text: 'newtext'); - expect(controller.text.text, equals('[newtext]:unstyled text')); + expect(controller.text.toPlainText(), equals('[newtext]:unstyled text')); ExpectedSpans([ 'bbbbbbbb_______________', ]).expectSpans(controller.text.spans); @@ -540,25 +540,25 @@ void main() { group('replace', () { test('empty text with new text at beginning', () { final controller = AttributedTextEditingController( - text: AttributedText(text: ':existing text'), + text: AttributedText(':existing text'), selection: const TextSelection.collapsed(offset: 0), ); - controller.replace(newText: AttributedText(text: 'newtext'), from: 0, to: 0); + controller.replace(newText: AttributedText('newtext'), from: 0, to: 0); - expect(controller.text.text, equals('newtext:existing text')); + expect(controller.text.toPlainText(), equals('newtext:existing text')); }); test('empty text with new text at beginning with selection', () { final controller = AttributedTextEditingController( - text: AttributedText(text: ':existing text'), + text: AttributedText(':existing text'), selection: const TextSelection( baseOffset: 0, extentOffset: 1, ), ); - controller.replace(newText: AttributedText(text: 'newtext'), from: 0, to: 0); + controller.replace(newText: AttributedText('newtext'), from: 0, to: 0); - expect(controller.text.text, equals('newtext:existing text')); + expect(controller.text.toPlainText(), equals('newtext:existing text')); expect( controller.selection, const TextSelection( @@ -570,52 +570,52 @@ void main() { test('text with empty text at beginning', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'deleteme:existing text'), + text: AttributedText('deleteme:existing text'), selection: const TextSelection.collapsed(offset: 0), ); - controller.replace(newText: AttributedText(text: ''), from: 0, to: 8); + controller.replace(newText: AttributedText(''), from: 0, to: 8); - expect(controller.text.text, equals(':existing text')); + expect(controller.text.toPlainText(), equals(':existing text')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 0))); }); test('text at beginning', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'replaceme:existing text'), + text: AttributedText('replaceme:existing text'), selection: const TextSelection(baseOffset: 0, extentOffset: 9), ); - controller.replace(newText: AttributedText(text: 'newtext'), from: 0, to: 9); + controller.replace(newText: AttributedText('newtext'), from: 0, to: 9); - expect(controller.text.text, equals('newtext:existing text')); + expect(controller.text.toPlainText(), equals('newtext:existing text')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 7))); }); test('text at end', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'existing text:replaceme'), + text: AttributedText('existing text:replaceme'), selection: const TextSelection(baseOffset: 14, extentOffset: 23), ); - controller.replace(newText: AttributedText(text: 'newtext'), from: 14, to: 23); + controller.replace(newText: AttributedText('newtext'), from: 14, to: 23); - expect(controller.text.text, equals('existing text:newtext')); + expect(controller.text.toPlainText(), equals('existing text:newtext')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 21))); }); test('text in the middle', () { final controller = AttributedTextEditingController( - text: AttributedText(text: '[replaceme]'), + text: AttributedText('[replaceme]'), selection: const TextSelection(baseOffset: 1, extentOffset: 10), ); - controller.replace(newText: AttributedText(text: 'newtext'), from: 1, to: 10); + controller.replace(newText: AttributedText('newtext'), from: 1, to: 10); - expect(controller.text.text, equals('[newtext]')); + expect(controller.text.toPlainText(), equals('[newtext]')); }); test('in middle of styled text with new styled text', () { final controller = AttributedTextEditingController( text: AttributedText( - text: '[replaceme]', - spans: AttributedSpans( + '[replaceme]', + AttributedSpans( attributions: [ const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), const SpanMarker(attribution: boldAttribution, offset: 10, markerType: SpanMarkerType.end), @@ -624,8 +624,8 @@ void main() { ), ); final newText = AttributedText( - text: 'newtext', - spans: AttributedSpans( + 'newtext', + AttributedSpans( attributions: [ const SpanMarker(attribution: italicsAttribution, offset: 0, markerType: SpanMarkerType.start), const SpanMarker(attribution: italicsAttribution, offset: 6, markerType: SpanMarkerType.end), @@ -634,7 +634,7 @@ void main() { ); controller.replace(newText: newText, from: 1, to: 10); - expect(controller.text.text, equals('[newtext]')); + expect(controller.text.toPlainText(), equals('[newtext]')); ExpectedSpans([ 'biiiiiiib', @@ -645,27 +645,27 @@ void main() { group('delete', () { test('from beginning', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'deleteme:existing text'), + text: AttributedText('deleteme:existing text'), ); controller.delete(from: 0, to: 8); - expect(controller.text.text, equals(':existing text')); + expect(controller.text.toPlainText(), equals(':existing text')); }); test('from beginning with caret', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'deleteme:existing text'), + text: AttributedText('deleteme:existing text'), selection: const TextSelection.collapsed(offset: 8), ); controller.delete(from: 0, to: 8); - expect(controller.text.text, equals(':existing text')); + expect(controller.text.toPlainText(), equals(':existing text')); expect(controller.selection, equals(const TextSelection.collapsed(offset: 0))); }); test('from beginning with selection', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'deleteme:existing text'), + text: AttributedText('deleteme:existing text'), selection: const TextSelection( baseOffset: 4, extentOffset: 17, @@ -673,7 +673,7 @@ void main() { ); controller.delete(from: 0, to: 8); - expect(controller.text.text, equals(':existing text')); + expect(controller.text.toPlainText(), equals(':existing text')); expect( controller.selection, equals( @@ -687,22 +687,22 @@ void main() { test('from end', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'existing text:deleteme'), + text: AttributedText('existing text:deleteme'), ); controller.delete(from: 14, to: 22); - expect(controller.text.text, equals('existing text:')); + expect(controller.text.toPlainText(), equals('existing text:')); }); test('from end with caret', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'existing text:deleteme'), + text: AttributedText('existing text:deleteme'), // Caret part of the way into the text that will be deleted. selection: const TextSelection.collapsed(offset: 18), ); controller.delete(from: 14, to: 22); - expect(controller.text.text, equals('existing text:')); + expect(controller.text.toPlainText(), equals('existing text:')); expect( controller.selection, equals( @@ -713,14 +713,14 @@ void main() { test('from end with selection', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'existing text:deleteme'), + text: AttributedText('existing text:deleteme'), // Selection that starts near the end of remaining text and // extends part way into text that's deleted. selection: const TextSelection(baseOffset: 11, extentOffset: 18), ); controller.delete(from: 14, to: 22); - expect(controller.text.text, equals('existing text:')); + expect(controller.text.toPlainText(), equals('existing text:')); expect( controller.selection, equals( @@ -734,16 +734,16 @@ void main() { test('from middle', () { final controller = AttributedTextEditingController( - text: AttributedText(text: '[deleteme]'), + text: AttributedText('[deleteme]'), ); controller.delete(from: 1, to: 9); - expect(controller.text.text, equals('[]')); + expect(controller.text.toPlainText(), equals('[]')); }); test('from middle with crosscutting selection at beginning', () { final controller = AttributedTextEditingController( - text: AttributedText(text: '[deleteme]'), + text: AttributedText('[deleteme]'), selection: const TextSelection( baseOffset: 0, extentOffset: 5, @@ -751,7 +751,7 @@ void main() { ); controller.delete(from: 1, to: 9); - expect(controller.text.text, equals('[]')); + expect(controller.text.toPlainText(), equals('[]')); expect( controller.selection, equals( @@ -765,7 +765,7 @@ void main() { test('from middle with partial selection in middle', () { final controller = AttributedTextEditingController( - text: AttributedText(text: '[deleteme]'), + text: AttributedText('[deleteme]'), selection: const TextSelection( baseOffset: 3, extentOffset: 6, @@ -773,7 +773,7 @@ void main() { ); controller.delete(from: 1, to: 9); - expect(controller.text.text, equals('[]')); + expect(controller.text.toPlainText(), equals('[]')); expect( controller.selection, equals(const TextSelection.collapsed(offset: 1)), @@ -782,7 +782,7 @@ void main() { test('from middle with crosscutting selection at end', () { final controller = AttributedTextEditingController( - text: AttributedText(text: '[deleteme]'), + text: AttributedText('[deleteme]'), selection: const TextSelection( baseOffset: 5, extentOffset: 10, @@ -790,7 +790,7 @@ void main() { ); controller.delete(from: 1, to: 9); - expect(controller.text.text, equals('[]')); + expect(controller.text.toPlainText(), equals('[]')); expect( controller.selection, equals( @@ -803,9 +803,88 @@ void main() { }); }); + group("clearing text and selection", () { + test("can remove the text, selection, and composing region at the same time", () { + int listenerNotifyCount = 0; + final controller = AttributedTextEditingController( + text: AttributedText('my text'), + selection: const TextSelection.collapsed(offset: 7), + composingRegion: const TextRange(start: 3, end: 7), + ) + ..composingAttributions = { + boldAttribution, + } + ..addListener(() { + listenerNotifyCount += 1; + }); + + controller.clearTextAndSelection(); + + expect(controller.text.toPlainText(), isEmpty); + expect( + controller.selection, + const TextSelection.collapsed(offset: -1), + ); + expect(controller.composingAttributions, isEmpty); + expect(controller.composingRegion, TextRange.empty); + expect(listenerNotifyCount, 1); + + // Below here we want to validate that the old deprecated method + // .clear() does exactly the same thing as its replacement method + // .clearTextAndSelection(). + // + // As soon as the deprecated method is removed, the below code will + // throw a compile error, at which time it will be safe to remove it. + controller + ..text = AttributedText('my text') + ..selection = const TextSelection.collapsed(offset: 7) + ..composingRegion = const TextRange(start: 3, end: 7) + ..composingAttributions = {boldAttribution}; + listenerNotifyCount = 0; + + // ignore: deprecated_member_use_from_same_package + controller.clear(); + + expect(controller.text.toPlainText(), isEmpty); + expect( + controller.selection, + const TextSelection.collapsed(offset: -1), + ); + expect(controller.composingAttributions, isEmpty); + expect(controller.composingRegion, TextRange.empty); + expect(listenerNotifyCount, 1); + }); + + test("can remove the text and composing region, and place the caret at the start, at the same time", () { + int listenerNotifyCount = 0; + final controller = AttributedTextEditingController( + text: AttributedText('my text'), + selection: const TextSelection.collapsed(offset: 7), + composingRegion: const TextRange(start: 3, end: 7), + ) + ..composingAttributions = { + boldAttribution, + } + ..addListener(() { + listenerNotifyCount += 1; + }); + + controller.clearText(); + + expect(controller.text.toPlainText(), isEmpty); + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + expect(controller.composingAttributions, isEmpty); + expect(controller.composingRegion, TextRange.empty); + expect(listenerNotifyCount, 1); + }); + }); + test('set text', () { - final text1 = AttributedText(text: 'text1'); - final text2 = AttributedText(text: 'text2'); + final text1 = AttributedText('text1'); + final text2 = AttributedText('text2'); final controller = AttributedTextEditingController(text: text1); expect(controller.text, equals(text1)); @@ -822,7 +901,7 @@ void main() { group('removal', () { test('should remove the given attributions', () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'my text'), + text: AttributedText('my text'), ); controller.addComposingAttributions( {boldAttribution, italicsAttribution, underlineAttribution}, @@ -837,7 +916,7 @@ void main() { test("does nothing when it doesn't have the given composing attributions", () { final controller = AttributedTextEditingController( - text: AttributedText(text: 'my text'), + text: AttributedText('my text'), ); controller.addComposingAttributions( {boldAttribution, italicsAttribution}, @@ -870,7 +949,11 @@ class _NoOpTextLayout implements ProseTextLayout { } @override - List getBoxesForSelection(TextSelection selection) { + List getBoxesForSelection( + TextSelection selection, { + BoxHeightStyle boxHeightStyle = BoxHeightStyle.tight, + BoxWidthStyle boxWidthStyle = BoxWidthStyle.tight, + }) { throw UnimplementedError(); } diff --git a/super_editor/test/super_textfield/goldens/super_textfield_alignments_multiline_android.png b/super_editor/test/super_textfield/goldens/super_textfield_alignments_multiline_android.png deleted file mode 100644 index cc82aafea1..0000000000 Binary files a/super_editor/test/super_textfield/goldens/super_textfield_alignments_multiline_android.png and /dev/null differ diff --git a/super_editor/test/super_textfield/goldens/super_textfield_alignments_multiline_ios.png b/super_editor/test/super_textfield/goldens/super_textfield_alignments_multiline_ios.png deleted file mode 100644 index cc82aafea1..0000000000 Binary files a/super_editor/test/super_textfield/goldens/super_textfield_alignments_multiline_ios.png and /dev/null differ diff --git a/super_editor/test/super_textfield/ime_attributed_text_editing_controller_test.dart b/super_editor/test/super_textfield/ime_attributed_text_editing_controller_test.dart index 2764b8f85c..78e2029e17 100644 --- a/super_editor/test/super_textfield/ime_attributed_text_editing_controller_test.dart +++ b/super_editor/test/super_textfield/ime_attributed_text_editing_controller_test.dart @@ -1,344 +1,456 @@ +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +import '../../lib/src/test/flutter_extensions/test_tools_user_input.dart'; void main() { group('ImeAttributedTextEditingController', () { - group('platform', () { - test('types hello **world** into empty field', () { - final controller = ImeAttributedTextEditingController( + test('types hello **world** into empty field', () { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + selection: const TextSelection.collapsed(offset: 0), + ), + ) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: '', + textInserted: 'H', + insertionOffset: 0, + selection: TextSelection.collapsed(offset: 1), + composing: TextRange.empty, + ) + ]) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'H', + textInserted: 'e', + insertionOffset: 1, + selection: TextSelection.collapsed(offset: 2), + composing: TextRange.empty, + ) + ]) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'He', + textInserted: 'l', + insertionOffset: 2, + selection: TextSelection.collapsed(offset: 3), + composing: TextRange.empty, + ) + ]) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'Hel', + textInserted: 'l', + insertionOffset: 3, + selection: TextSelection.collapsed(offset: 4), + composing: TextRange.empty, + ) + ]) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'Hell', + textInserted: 'o', + insertionOffset: 4, + selection: TextSelection.collapsed(offset: 5), + composing: TextRange.empty, + ) + ]) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'Hello', + textInserted: ' ', + insertionOffset: 5, + selection: TextSelection.collapsed(offset: 6), + composing: TextRange.empty, + ) + ]) + ..addComposingAttributions({boldAttribution}) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'Hello ', + textInserted: 'W', + insertionOffset: 6, + selection: TextSelection.collapsed(offset: 7), + composing: TextRange.empty, + ) + ]) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'Hello W', + textInserted: 'o', + insertionOffset: 7, + selection: TextSelection.collapsed(offset: 8), + composing: TextRange.empty, + ) + ]) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'Hello Wo', + textInserted: 'r', + insertionOffset: 8, + selection: TextSelection.collapsed(offset: 9), + composing: TextRange.empty, + ) + ]) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'Hello Wor', + textInserted: 'l', + insertionOffset: 9, + selection: TextSelection.collapsed(offset: 10), + composing: TextRange.empty, + ) + ]) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'Hello Worl', + textInserted: 'd', + insertionOffset: 10, + selection: TextSelection.collapsed(offset: 11), + composing: TextRange.empty, + ) + ]); + + expect(controller.text.toPlainText(), equals('Hello World')); + ExpectedSpans([ + '______bbbbb', + ]).expectSpans(controller.text.spans); + }); + + testWidgetsOnAllPlatforms('doesn\'t send existing IME value back to IME', (tester) async { + // We test this condition because, on at least some platforms, whenever + // we send a value to the IME, the IME sends it right back to us. Therefore, + // if we keep reporting unchanged IME values, we'll get stuck in an infinite + // loop of IME updates. + + int listenerNotificationCount = 0; + late ImeConnectionWithUpdateCount imeConnection; + final controller = ImeAttributedTextEditingController( controller: AttributedTextEditingController( - selection: const TextSelection.collapsed(offset: 0), + text: AttributedText( + 'Some text', + ), ), - ) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: '', - textInserted: 'H', - insertionOffset: 0, - selection: TextSelection.collapsed(offset: 1), - composing: TextRange.empty, - ) - ]) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'H', - textInserted: 'e', - insertionOffset: 1, - selection: TextSelection.collapsed(offset: 2), - composing: TextRange.empty, - ) - ]) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'He', - textInserted: 'l', - insertionOffset: 2, - selection: TextSelection.collapsed(offset: 3), - composing: TextRange.empty, - ) - ]) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'Hel', - textInserted: 'l', - insertionOffset: 3, - selection: TextSelection.collapsed(offset: 4), - composing: TextRange.empty, - ) - ]) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'Hell', - textInserted: 'o', - insertionOffset: 4, - selection: TextSelection.collapsed(offset: 5), - composing: TextRange.empty, - ) - ]) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'Hello', - textInserted: ' ', - insertionOffset: 5, - selection: TextSelection.collapsed(offset: 6), - composing: TextRange.empty, - ) - ]) - ..addComposingAttributions({boldAttribution}) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'Hello ', - textInserted: 'W', - insertionOffset: 6, - selection: TextSelection.collapsed(offset: 7), - composing: TextRange.empty, - ) - ]) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'Hello W', - textInserted: 'o', - insertionOffset: 7, - selection: TextSelection.collapsed(offset: 8), - composing: TextRange.empty, - ) - ]) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'Hello Wo', - textInserted: 'r', - insertionOffset: 8, - selection: TextSelection.collapsed(offset: 9), - composing: TextRange.empty, - ) - ]) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'Hello Wor', - textInserted: 'l', - insertionOffset: 9, - selection: TextSelection.collapsed(offset: 10), - composing: TextRange.empty, - ) - ]) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'Hello Worl', - textInserted: 'd', - insertionOffset: 10, - selection: TextSelection.collapsed(offset: 11), - composing: TextRange.empty, - ) - ]); - - expect(controller.text.text, equals('Hello World')); - ExpectedSpans([ - '______bbbbb', - ]).expectSpans(controller.text.spans); - }); - - test('types new text in the middle of styled text', () { - final controller = ImeAttributedTextEditingController( - controller: AttributedTextEditingController( + // Decorate the TextInputConnection to track the number of IME updates. + inputConnectionFactory: (client, config) { + final realConnection = TextInput.attach(client, config); + imeConnection = ImeConnectionWithUpdateCount(realConnection); + return imeConnection; + }) + ..addListener(() { + // Track the number of times the controller notifies listeners + // of changes, because we don't want to receive notifications for + // deltas that don't change anything. + listenerNotificationCount += 1; + }); + + // Display a SuperTextField. + await _pumpSuperTextField(tester, controller, inputSource: TextInputSource.ime); + + // Place the caret in the text field to introduce a selection. + await tester.placeCaretInSuperTextField(4); + + // Ensure the controller was updated with the selection. + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + expect(controller.composingRegion, TextRange.empty); + expect(listenerNotificationCount, 1); + expect(imeConnection.contentUpdateCount, 1); + + // Send a delta that shouldn't change the text field's content. + controller.updateEditingValueWithDeltas([ + const TextEditingDeltaNonTextUpdate( + oldText: "Some text", + selection: TextSelection.collapsed(offset: 4), + composing: TextRange.empty, + ), + ]); + await tester.pumpAndSettle(); + + // Ensure listeners aren't notified, because no change occurred. + expect(listenerNotificationCount, 1); + + // Ensure that we didn't send another update to the IME. This is the most + // critical condition in this test. + expect(imeConnection.contentUpdateCount, 1); + }); + + test('types new text in the middle of styled text', () { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + text: AttributedText( + 'before [] after', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), + ], + ), + ), + )) + ..selection = const TextSelection.collapsed(offset: 8) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'before [] after', + textInserted: 'b', + insertionOffset: 8, + selection: TextSelection.collapsed(offset: 9), + composing: TextRange.empty, + ) + ]); + + expect(controller.text.toPlainText(), equals('before [b] after')); + expect(controller.selection, equals(const TextSelection.collapsed(offset: 9))); + ExpectedSpans([ + '_______bbb______', + ]).expectSpans(controller.text.spans); + }); + + test('types batch of new text in the middle of styled text', () { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( text: AttributedText( - text: 'before [] after', - spans: AttributedSpans( + 'before [] after', + AttributedSpans( attributions: [ const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), ], ), ), - )) - ..selection = const TextSelection.collapsed(offset: 8) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'before [] after', - textInserted: 'b', - insertionOffset: 8, - selection: TextSelection.collapsed(offset: 9), - composing: TextRange.empty, - ) - ]); - - expect(controller.text.text, equals('before [b] after')); - expect(controller.selection, equals(const TextSelection.collapsed(offset: 9))); - ExpectedSpans([ - '_______bbb______', - ]).expectSpans(controller.text.spans); - }); - - test('types batch of new text in the middle of styled text', () { - final controller = ImeAttributedTextEditingController( - controller: AttributedTextEditingController( - text: AttributedText( - text: 'before [] after', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - ], - ), + ), + ) + ..selection = const TextSelection.collapsed(offset: 8) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'before [] after', + textInserted: 'hello', + insertionOffset: 8, + selection: TextSelection.collapsed(offset: 13), + composing: TextRange.empty, + ) + ]); + + expect(controller.text.toPlainText(), equals('before [hello] after')); + expect(controller.selection, equals(const TextSelection.collapsed(offset: 13))); + ExpectedSpans([ + '_______bbbbbbb______', + ]).expectSpans(controller.text.spans); + }); + + test('types unstyled text in the middle of styled text', () { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + text: AttributedText( + 'before [] after', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), + ], ), ), - ) - ..selection = const TextSelection.collapsed(offset: 8) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'before [] after', - textInserted: 'hello', - insertionOffset: 8, - selection: TextSelection.collapsed(offset: 13), - composing: TextRange.empty, - ) - ]); - - expect(controller.text.text, equals('before [hello] after')); - expect(controller.selection, equals(const TextSelection.collapsed(offset: 13))); - ExpectedSpans([ - '_______bbbbbbb______', - ]).expectSpans(controller.text.spans); - }); - - test('types unstyled text in the middle of styled text', () { - final controller = ImeAttributedTextEditingController( - controller: AttributedTextEditingController( - text: AttributedText( - text: 'before [] after', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - ], - ), + ), + ) + ..selection = const TextSelection.collapsed(offset: 8) + ..clearComposingAttributions() + ..updateEditingValueWithDeltas([ + const TextEditingDeltaInsertion( + oldText: 'before [] after', + textInserted: 'b', + insertionOffset: 8, + selection: TextSelection.collapsed(offset: 9), + composing: TextRange.empty, + ) + ]); + + expect(controller.text.toPlainText(), equals('before [b] after')); + ExpectedSpans([ + '_______b_b______', + ]).expectSpans(controller.text.spans); + }); + + test('clears composing attributions by deleting individual styled characters', () { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + text: AttributedText( + 'before [] after', + AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), + ], ), ), + ), + )..selection = const TextSelection.collapsed(offset: 9); + expect(controller.composingAttributions, equals({boldAttribution})); + + controller.updateEditingValueWithDeltas([ + const TextEditingDeltaDeletion( + oldText: 'before [] after', + deletedRange: TextRange(start: 8, end: 9), + selection: TextSelection.collapsed(offset: 8), + composing: TextRange.empty, ) - ..selection = const TextSelection.collapsed(offset: 8) - ..clearComposingAttributions() - ..updateEditingValueWithDeltas([ - const TextEditingDeltaInsertion( - oldText: 'before [] after', - textInserted: 'b', - insertionOffset: 8, - selection: TextSelection.collapsed(offset: 9), - composing: TextRange.empty, - ) - ]); - - expect(controller.text.text, equals('before [b] after')); - ExpectedSpans([ - '_______b_b______', - ]).expectSpans(controller.text.spans); - }); - - test('clears composing attributions by deleting individual styled characters', () { - final controller = ImeAttributedTextEditingController( - controller: AttributedTextEditingController( - text: AttributedText( - text: 'before [] after', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - ], - ), - ), + ]); + expect(controller.composingAttributions, equals({boldAttribution})); + + controller.updateEditingValueWithDeltas([ + const TextEditingDeltaDeletion( + oldText: 'before [ after', + deletedRange: TextRange(start: 7, end: 8), + selection: TextSelection.collapsed(offset: 7), + composing: TextRange.empty, + ) + ]); + expect(controller.composingAttributions.isEmpty, isTrue); + }); + + test('replaces selected text with new character', () { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + text: AttributedText( + '[replaceme]', + ), + ), + ) + ..selection = const TextSelection(baseOffset: 1, extentOffset: 10) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaReplacement( + oldText: '[replaceme]', + replacementText: 'b', + replacedRange: TextRange(start: 1, end: 10), + selection: TextSelection.collapsed(offset: 2), + composing: TextRange.empty, ), - )..selection = const TextSelection.collapsed(offset: 9); - expect(controller.composingAttributions, equals({boldAttribution})); + ]); + + expect(controller.text.toPlainText(), equals('[b]')); + expect(controller.selection, equals(const TextSelection.collapsed(offset: 2))); + }); + + test('replaces selected text with batch of new text', () { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + text: AttributedText( + '[replaceme]', + ), + ), + ) + ..selection = const TextSelection(baseOffset: 1, extentOffset: 10) + ..updateEditingValueWithDeltas([ + const TextEditingDeltaReplacement( + oldText: '[replaceme]', + replacementText: 'new', + replacedRange: TextRange(start: 1, end: 10), + selection: TextSelection.collapsed(offset: 4), + composing: TextRange.empty, + ), + ]); - controller.updateEditingValueWithDeltas([ + expect(controller.text.toPlainText(), equals('[new]')); + expect(controller.selection, equals(const TextSelection.collapsed(offset: 4))); + }); + + test('deletes first character in text with backspace key', () { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + text: AttributedText( + 'some text', + ), + ), + ) + ..selection = const TextSelection.collapsed(offset: 1) + ..updateEditingValueWithDeltas([ const TextEditingDeltaDeletion( - oldText: 'before [] after', - deletedRange: TextRange(start: 8, end: 9), - selection: TextSelection.collapsed(offset: 8), + oldText: 'some text', + deletedRange: TextRange(start: 0, end: 1), + selection: TextSelection.collapsed(offset: 0), composing: TextRange.empty, ) ]); - expect(controller.composingAttributions, equals({boldAttribution})); - controller.updateEditingValueWithDeltas([ + expect(controller.text.toPlainText(), equals('ome text')); + expect(controller.selection, equals(const TextSelection.collapsed(offset: 0))); + }); + + test('deletes last character in text with delete key', () { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + text: AttributedText( + 'some text', + ), + ), + ) + ..selection = const TextSelection.collapsed(offset: 8) + ..updateEditingValueWithDeltas([ const TextEditingDeltaDeletion( - oldText: 'before [ after', - deletedRange: TextRange(start: 7, end: 8), - selection: TextSelection.collapsed(offset: 7), + oldText: 'some text', + deletedRange: TextRange(start: 8, end: 9), + selection: TextSelection.collapsed(offset: 8), composing: TextRange.empty, ) ]); - expect(controller.composingAttributions.isEmpty, isTrue); - }); - test('replaces selected text with new character', () { - final controller = ImeAttributedTextEditingController( - controller: AttributedTextEditingController( - text: AttributedText( - text: '[replaceme]', - ), - ), - ) - ..selection = const TextSelection(baseOffset: 1, extentOffset: 10) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaReplacement( - oldText: '[replaceme]', - replacementText: 'b', - replacedRange: TextRange(start: 1, end: 10), - selection: TextSelection.collapsed(offset: 2), - composing: TextRange.empty, - ), - ]); + expect(controller.text.toPlainText(), equals('some tex')); + expect(controller.selection, equals(const TextSelection.collapsed(offset: 8))); + }); + }); - expect(controller.text.text, equals('[b]')); - expect(controller.selection, equals(const TextSelection.collapsed(offset: 2))); - }); + testWidgets('isn\'t notified by inner controller after disposal', (tester) async { + final innerController = AttributedTextEditingController( + text: AttributedText( + 'some text', + ), + ); - test('replaces selected text with batch of new text', () { - final controller = ImeAttributedTextEditingController( - controller: AttributedTextEditingController( - text: AttributedText( - text: '[replaceme]', - ), - ), - ) - ..selection = const TextSelection(baseOffset: 1, extentOffset: 10) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaReplacement( - oldText: '[replaceme]', - replacementText: 'new', - replacedRange: TextRange(start: 1, end: 10), - selection: TextSelection.collapsed(offset: 4), - composing: TextRange.empty, - ), - ]); + // Create an IME controller wrapping the inner controller. + // + // The IME controller is notified whenever the inner controller changes. + ImeAttributedTextEditingController imeController = ImeAttributedTextEditingController( + controller: innerController, + disposeClientController: false, + ); - expect(controller.text.text, equals('[new]')); - expect(controller.selection, equals(const TextSelection.collapsed(offset: 4))); - }); + // Dispose the IME controller. + // + // After this point, the IME controller crashes if it's notified. + imeController.dispose(); - test('deletes first character in text with backspace key', () { - final controller = ImeAttributedTextEditingController( - controller: AttributedTextEditingController( - text: AttributedText( - text: 'some text', - ), - ), - ) - ..selection = const TextSelection.collapsed(offset: 1) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaDeletion( - oldText: 'some text', - deletedRange: TextRange(start: 0, end: 1), - selection: TextSelection.collapsed(offset: 0), - composing: TextRange.empty, - ) - ]); - - expect(controller.text.text, equals('ome text')); - expect(controller.selection, equals(const TextSelection.collapsed(offset: 0))); - }); - - test('deletes last character in text with delete key', () { - final controller = ImeAttributedTextEditingController( - controller: AttributedTextEditingController( - text: AttributedText( - text: 'some text', - ), - ), - ) - ..selection = const TextSelection.collapsed(offset: 8) - ..updateEditingValueWithDeltas([ - const TextEditingDeltaDeletion( - oldText: 'some text', - deletedRange: TextRange(start: 8, end: 9), - selection: TextSelection.collapsed(offset: 8), - composing: TextRange.empty, - ) - ]); - - expect(controller.text.text, equals('some tex')); - expect(controller.selection, equals(const TextSelection.collapsed(offset: 8))); - }); - }); + // Attach the inner controller into a new IME controller. + imeController = ImeAttributedTextEditingController( + controller: innerController, + disposeClientController: false, + ); + + // Change the text of the inner controller to notify the listeners. + innerController.text = AttributedText('Another text'); + + // Reaching this point means that disposing the old controller didn't cause a crash. }); } + +Future _pumpSuperTextField( + WidgetTester tester, + AttributedTextEditingController controller, { + TextInputSource? inputSource, +}) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: SuperTextField( + textController: controller, + inputSource: inputSource, + ), + ), + ), + ), + )); +} diff --git a/super_editor/test/super_textfield/ios/super_textfield_ios_scrolling_test.dart b/super_editor/test/super_textfield/ios/super_textfield_ios_scrolling_test.dart new file mode 100644 index 0000000000..b9f883c456 --- /dev/null +++ b/super_editor/test/super_textfield/ios/super_textfield_ios_scrolling_test.dart @@ -0,0 +1,273 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; +import 'package:super_editor/super_text_field.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group("SuperTextField > scrolling >", () { + testWidgetsOnIos('auto-scrolls to caret position upon widget initialization', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + controller.selection = TextSelection.collapsed(offset: controller.text.length); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines how far we need to drag the caret + // to the right to enter the auto-scroll region. + maxWidth: 200, + ); + + // Ensure that the text field auto-scrolled to the end, where the caret should be placed. + expect(SuperTextFieldInspector.isScrolledToEnd(), isTrue); + }); + + testWidgetsOnIos('single-line auto-scrolls to the right', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines how far we need to drag the caret + // to the right to enter the auto-scroll region. + maxWidth: 200, + ); + + await tester.placeCaretInSuperTextField(0); + expect(controller.selection.extentOffset, 0); + + // Drag caret from left side of text field to right side of text field, into the + // right auto-scroll region. + final gesture = await tester.dragCaretByDistanceInSuperTextField(const Offset(220, 0)); + + // Pump a few more frames and ensure that every frame moves the caret further. + int previousCaretPosition = controller.selection.extentOffset; + for (int i = 0; i < 10; i += 1) { + await tester.pump(const Duration(milliseconds: 50)); + final newCaretPosition = controller.selection.extentOffset; + expect(newCaretPosition, greaterThan(previousCaretPosition), + reason: "Caret position didn't move on drag frame $i"); + previousCaretPosition = newCaretPosition; + } + + // Log the scroll offset to make sure that the scroll offset doesn't jump back + // to the left when we move out of the auto-scroll region. This is a glitch that + // we saw in #1673. + final scrollOffsetAfterAutoScroll = SuperTextFieldInspector.findScrollOffset(); + + // Drag back to the left to leave the auto-scroll region. + await gesture.moveBy(const Offset(-50, 0)); + await tester.pump(); + + // Pump a few frames to ensure that the selection isn't jumping around + // in a glitchy manner. + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + + // Ensure that when we moved slightly back to the left, to move out of the auto-scroll + // region, the scroll offset didn't jump somewhere else. + expect(SuperTextFieldInspector.findScrollOffset(), scrollOffsetAfterAutoScroll); + + // Release the gesture. + await gesture.up(); + + // Ensure that the scroll offset didn't change after we released. + expect(SuperTextFieldInspector.findScrollOffset(), scrollOffsetAfterAutoScroll); + }); + + testWidgetsOnIos('single-line auto-scrolls to the left', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + controller.selection = TextSelection.collapsed(offset: controller.text.length); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines how far we need to drag the caret + // to the right to enter the auto-scroll region. + maxWidth: 200, + // Add padding to leave room to see the caret at the very end of the + // text. + padding: const EdgeInsets.symmetric(horizontal: 2), + autofocus: true, + ); + + // Ensure we're starting scrolled to the end. + expect(SuperTextFieldInspector.isScrolledToEnd(), isTrue); + + // Drag caret from right side of text field to left side of text field, into the + // left auto-scroll region. + final gesture = await tester.dragCaretByDistanceInSuperTextField(const Offset(-220, 0)); + + // Pump a few more frames and ensure that every frame moves the caret further. + int previousCaretPosition = controller.selection.extentOffset; + for (int i = 0; i < 10; i += 1) { + await tester.pump(const Duration(milliseconds: 50)); + final newCaretPosition = controller.selection.extentOffset; + expect(newCaretPosition, lessThan(previousCaretPosition), + reason: "Caret position didn't move on drag frame $i"); + previousCaretPosition = newCaretPosition; + } + + // Log the scroll offset to make sure that the scroll offset doesn't jump back + // to the left when we move out of the auto-scroll region. This is a glitch that + // we saw in #1673. + final scrollOffsetAfterAutoScroll = SuperTextFieldInspector.findScrollOffset(); + + // Drag back to the right to leave the auto-scroll region. + await gesture.moveBy(const Offset(50, 0)); + await tester.pump(); + + // Pump a few frames to ensure that we're the selection isn't jumping around + // in a glitchy manner. + await tester.pump(); + await tester.pump(); + await tester.pump(); + + // Ensure that when we moved slightly back to the right, to move out of the auto-scroll + // region, the scroll offset didn't jump somewhere else. + expect(SuperTextFieldInspector.findScrollOffset(), scrollOffsetAfterAutoScroll); + + // Release the gesture. + await gesture.up(); + }); + + testWidgetsOnIos('single-line drag does nothing without a selection', (tester) async { + // Test explanation: I experimented with single-line text fields in a few iOS apps + // and I found that dragging in an area away from the caret doesn't have any effect. + // It doesn't scroll the text field, it doesn't move the caret, nothing. + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines whether the text fits, or + // if scrolling is available. + maxWidth: 200, + ); + + // Ensure there's no selection and no focus. + expect(SuperTextFieldInspector.findSelection()!.isValid, isFalse); + expect(SuperTextFieldInspector.hasFocus(), isFalse); + + // Drag from right to left. + await tester.drag(find.byType(SuperTextField), const Offset(-100, 0)); + + // Ensure the scroll offset didn't change and there's still no selection or focus. + expect(SuperTextFieldInspector.findScrollOffset()!, 0); + expect(SuperTextFieldInspector.findSelection()!.isValid, isFalse); + expect(SuperTextFieldInspector.hasFocus(), isFalse); + + // Pump with enough time to expire the tap recognizer timer. + await tester.pump(kTapTimeout); + }); + + testWidgetsOnIos('single-line drag does nothing with collapsed selection', (tester) async { + // Test explanation: I experimented with single-line text fields in a few iOS apps + // and I found that dragging in an area away from the caret doesn't have any effect. + // It doesn't scroll the text field, it doesn't move the caret, nothing. + final controller = AttributedTextEditingController( + text: AttributedText("This is long text that extends beyond the right side of the text field."), + ); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + // This width is important because it determines whether the text fits, or + // if scrolling is available. + maxWidth: 200, + ); + + // Place a caret in the field. + await tester.placeCaretInSuperTextField(0); + + // Ensure there's a selection with focus + expect(SuperTextFieldInspector.findSelection()!.isValid, isTrue); + expect(SuperTextFieldInspector.hasFocus(), isTrue); + + // Drag from left to right, far away from the caret. + final selectionBeforeDrag = SuperTextFieldInspector.findSelection(); + await tester.drag(find.byType(SuperTextField), const Offset(100, 0)); + + // Ensure the scroll offset and the selection didn't change. + expect(SuperTextFieldInspector.findScrollOffset()!, 0); + expect(SuperTextFieldInspector.findSelection(), selectionBeforeDrag); + + // Pump with enough time to expire the tap recognizer timer. + await tester.pump(kTapTimeout); + }); + }); +} + +Future _pumpTestApp( + WidgetTester tester, { + required AttributedTextEditingController textController, + required int minLines, + required int maxLines, + double? maxWidth, + double? maxHeight, + EdgeInsets? padding, + bool autofocus = false, +}) async { + final focusNode = FocusNode(); + if (autofocus) { + focusNode.requestFocus(); + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth ?? double.infinity, + maxHeight: maxHeight ?? double.infinity, + ), + child: SuperTextField( + focusNode: focusNode, + textController: textController, + lineHeight: 20, + textStyleBuilder: (_) => const TextStyle(fontSize: 20, color: Colors.black), + minLines: minLines, + maxLines: maxLines, + padding: padding, + ), + ), + ), + ), + ), + ); + + // The first frame might have a zero viewport height. Pump a second frame to account for the final viewport size. + await tester.pump(); +} diff --git a/super_editor/test/super_textfield/ios/super_textfield_ios_selection_test.dart b/super_editor/test/super_textfield/ios/super_textfield_ios_selection_test.dart new file mode 100644 index 0000000000..d40525e472 --- /dev/null +++ b/super_editor/test/super_textfield/ios/super_textfield_ios_selection_test.dart @@ -0,0 +1,196 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/toolbar.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_text_field.dart'; +import 'package:super_editor/super_text_field_test.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +void main() { + group("SuperTextField mobile selection > iOS", () { + group("on tap >", () { + testWidgetsOnIos("when beyond first character > places caret at end of word", (tester) async { + // TODO: Add this test - for an example, see the Super Editor version: super_editor_ios_selection_test.dart + // This test isn't implemented because when I got to it we didn't have any WidgetTester + // extensions to tap to place the caret. Create those extensions and then implement this. + // Issue: https://github.com/superlistapp/super_editor/issues/2098 + }, skip: true); + + testWidgetsOnIos("when near first character > places caret at start of word", (tester) async { + // TODO: Add this test - for an example, see the Super Editor version: super_editor_ios_selection_test.dart + // This test isn't implemented because when I got to it we didn't have any WidgetTester + // extensions to tap to place the caret. Create those extensions and then implement this. + // Issue: https://github.com/superlistapp/super_editor/issues/2098 + }, skip: true); + }); + + testWidgetsOnIos("tapping on caret toggles the toolbar", (tester) async { + await _pumpScaffold(tester); + + // Ensure there's no selection to begin with, and no toolbar is displayed. + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: -1)); + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + + // Place the caret at the end of the text by tapping in empty space at the center + // of the text field. + await tester.tap(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 3)); + + // Tap again in the empty space by tapping in the center of the text field. + await tester.tap(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + // Ensure that the text field toolbar is visible. + expect(find.byType(IOSTextEditingFloatingToolbar), findsOneWidget); + + // Tap a third time in the empty space by tapping in the center of the text field. + await tester.tap(find.byType(SuperTextField)); + await tester.pumpAndSettle(kDoubleTapTimeout); + + // Ensure that the text field toolbar disappeared. + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + }); + + testWidgetsOnIos("keeps current selection when tapping on caret", (tester) async { + IOSTextFieldTouchInteractor.useIosSelectionHeuristics = true; + addTearDown(() => IOSTextFieldTouchInteractor.useIosSelectionHeuristics = false); + + await _pumpScaffold( + tester, + controller: AttributedTextEditingController( + text: AttributedText('Lorem ipsum dolor'), + ), + ); + + // Ensure there's no selection to begin with. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + + // Tap at "ipsum|" to place the caret. + await tester.placeCaretInSuperTextField(11); + await tester.pump(kDoubleTapTimeout); + + // Ensure the selection was placed at the end of the word. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 11), + ); + + // Press and drag the caret to "ips|um", because dragging is the only way + // we can place the caret at the middle of a word when caret snapping is enabled. + final dragGesture = await tester.dragCaretByDistanceInSuperTextField(const Offset(-32, 0)); + await dragGesture.up(); + + // Ensure the selection moved to "ips|um". + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 9), + ); + + // Ensure that the text field toolbar is not visible. + expect(find.byType(IOSTextEditingFloatingToolbar), findsNothing); + + // Tap at the caret to show the toolbar. + await tester.placeCaretInSuperTextField(9); + + // Ensure the selection was kept at "ips|um". + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 9), + ); + + // Ensure that the text field toolbar is visible. + expect(find.byType(IOSTextEditingFloatingToolbar), findsOneWidget); + }); + + testWidgetsOnIos('displays selection highlight when controller is not provided', (tester) async { + // Pump a tree with a SuperIOSTextField without providing it a controller to make sure + // SuperIOSTextField does not rely on the provided controller to show the selection highlight. + // + // See https://github.com/superlistapp/super_editor/issues/2346 for details. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 300), + child: const SuperIOSTextField( + padding: EdgeInsets.all(12), + caretStyle: CaretStyle(color: Colors.red), + selectionColor: defaultSelectionColor, + handlesColor: Colors.red, + textStyleBuilder: defaultTextFieldStyleBuilder, + ), + ), + ), + ), + ); + + // Place the caret at the beginning of the text. + await tester.placeCaretInSuperTextField(0, find.byType(SuperIOSTextField)); + + // Type some text. + await tester.typeImeText('This is some text'); + + // Double tap to select the word "some". + await tester.doubleTapAtSuperTextField(10, find.byType(SuperIOSTextField)); + + // Ensure the selection highlight is displayed. + expect(find.byType(TextLayoutSelectionHighlight), findsOneWidget); + }); + }); +} + +Future _pumpScaffold( + WidgetTester tester, { + AttributedTextEditingController? controller, + EdgeInsets? padding, + TextAlign? textAlign, +}) async { + final textFieldFocusNode = FocusNode(); + const tapRegionGroupId = "test_super_text_field"; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TapRegion( + groupId: tapRegionGroupId, + onTapOutside: (_) { + // Unfocus on tap outside so that we're sure that all gesture tests + // pass when using TapRegion's for focus, because apps should be able + // to do that. + textFieldFocusNode.unfocus(); + }, + child: SizedBox.expand( + child: Align( + alignment: Alignment.center, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + ), + child: SuperTextField( + focusNode: textFieldFocusNode, + tapRegionGroupId: tapRegionGroupId, + padding: padding, + textAlign: textAlign ?? TextAlign.left, + textController: controller ?? + AttributedTextEditingController( + text: AttributedText('abc'), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/super_editor/test/super_textfield/mac/super_textfield_mac_selectors_test.dart b/super_editor/test/super_textfield/mac/super_textfield_mac_selectors_test.dart new file mode 100644 index 0000000000..975ca1389a --- /dev/null +++ b/super_editor/test/super_textfield/mac/super_textfield_mac_selectors_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group("SuperTextField > Mac > Selectors > ", () { + testWidgetsOnMac('allows apps to handle selectors in their own way', (tester) async { + bool customHandlerCalled = false; + + final controller = AttributedTextEditingController( + text: AttributedText('Selectors test'), + ); + + await tester.pumpWidget( + _buildScaffold( + child: SuperTextField( + textController: controller, + inputSource: TextInputSource.ime, + selectorHandlers: { + MacOsSelectors.moveRight: ({ + required SuperTextFieldContext textFieldContext, + }) { + customHandlerCalled = true; + } + }, + ), + ), + ); + + // Place the caret at the beginning of the text field. + await tester.placeCaretInSuperTextField(0); + + // Press right arrow key to trigger the MacOsSelectors.moveRight selector. + await tester.pressRightArrow(); + + // Ensure the custom handler was called. + expect(customHandlerCalled, isTrue); + + // Ensure that the textfield didn't execute the default handler for the MacOsSelectors.moveRight selector. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 0), + ); + }); + }); + + testWidgetsOnMac('prevents surrounding widgets from consuming control keys that trigger OS selectors', + (tester) async { + // Explanation: Mac OS selectors are only generated for a given key event, if that key event + // isn't handled by anything within Flutter code. Some key events are almost always tied to + // Shortcuts higher up in the tree, e.g., ESC to generate a DismissIntent. Therefore, SuperTextField + // needs to explicitly tell Flutter to stop propagating any key event that's expected to generate a + // selector on the OS side. + bool receivedOsSelector = false; + + await tester.pumpWidget( + _buildScaffold( + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + }, + child: Actions( + actions: { + DismissIntent: CallbackAction(onInvoke: (DismissIntent intent) { + fail("Received a DismissIntent from Shortcuts but that shortcut should never have been activated."); + }), + }, + child: SuperTextField( + inputSource: TextInputSource.ime, + selectorHandlers: { + MacOsSelectors.cancelOperation: ({ + required SuperTextFieldContext textFieldContext, + }) { + receivedOsSelector = true; + } + }, + ), + ), + ), + ), + ); + + // Give focus to the text field so that it handles key presses. + await tester.placeCaretInSuperTextField(0); + + // Press ESC, which we expect to make it all the way to the OS. + await tester.pressEscape(); + + // Ensure that the key event skipped the Flutter tree Shortcuts and Actions and + // made it back to us as an OS selector. + expect(receivedOsSelector, isTrue); + }); +} + +Widget _buildScaffold({ + required Widget child, +}) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + child: child, + ), + ), + ); +} diff --git a/super_editor/test/super_textfield/super_desktop_texfield_mouse_interaction_test.dart b/super_editor/test/super_textfield/super_desktop_texfield_mouse_interaction_test.dart index a16ed7283b..fa2ae01f49 100644 --- a/super_editor/test/super_textfield/super_desktop_texfield_mouse_interaction_test.dart +++ b/super_editor/test/super_textfield/super_desktop_texfield_mouse_interaction_test.dart @@ -2,11 +2,10 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import '../test_tools.dart'; - void main() { group('SuperDesktopTextField', () { testWidgetsOnDesktop('has text cursor style while hovering over text', (tester) async { @@ -15,12 +14,12 @@ void main() { // Start a gesture outside SuperDesktopTextField bounds final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); + addTearDown(gesture.removePointer); await tester.pump(); // Ensure the cursor type is 'basic' when not hovering SuperDesktopTextField expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); - + // Hover over the text inside SuperDesktopTextField // TODO: add the ability to SuperTextFieldInspector to lookup an offset for a content position await gesture.moveTo(tester.getTopLeft(find.byType(SuperText))); @@ -42,14 +41,14 @@ void main() { // Start a gesture outside SuperDesktopTextField bounds final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); + addTearDown(gesture.removePointer); await tester.pump(); // Ensure the cursor type is 'basic' when not hovering SuperDesktopTextField expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); - - // Hover over the empty space within SuperDesktopTextField - await gesture.moveTo(tester.getBottomRight(find.byType(SuperDesktopTextField)) - const Offset(10, 10)); + + // Hover over the empty space within SuperDesktopTextField + await gesture.moveTo(tester.getBottomRight(find.byType(SuperDesktopTextField)) - const Offset(10, 10)); await tester.pump(); // Ensure the cursor type is 'text' when hovering the empty space @@ -68,12 +67,12 @@ void main() { // Start a gesture outside SuperDesktopTextField bounds final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(location: Offset.zero); - addTearDown(gesture.removePointer); + addTearDown(gesture.removePointer); await tester.pump(); // Ensure the cursor type is 'basic' when not hovering SuperDesktopTextField expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); - + // Hover over the padding within SuperDesktopTextField await gesture.moveTo(tester.getTopLeft(find.byType(SuperDesktopTextField)) + const Offset(10, 10)); await tester.pump(); @@ -87,29 +86,26 @@ void main() { // Ensure the cursor type is 'basic' again expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); }); - }); + }); } /// Creates a test app with the given [padding] applied to [SuperDesktopTextField] -Future _pumpGestureTestApp(WidgetTester tester, { - double padding = 0.0 -}) async { +Future _pumpGestureTestApp(WidgetTester tester, {double padding = 0.0}) async { await tester.pumpWidget( - MaterialApp( + MaterialApp( home: Scaffold( body: Container( width: 300, padding: const EdgeInsets.all(20.0), - child: SuperDesktopTextField( + child: SuperDesktopTextField( padding: EdgeInsets.all(padding), textController: AttributedTextEditingController( - text: AttributedText(text: "abc"), + text: AttributedText("abc"), ), textStyleBuilder: (_) => const TextStyle(fontSize: 16), - ), - ), + ), + ), ), ), ); } - diff --git a/super_editor/test/super_textfield/super_desktop_textfield_keyboard_test.dart b/super_editor/test/super_textfield/super_desktop_textfield_keyboard_test.dart index a1bc64be97..128cc01744 100644 --- a/super_editor/test/super_textfield/super_desktop_textfield_keyboard_test.dart +++ b/super_editor/test/super_textfield/super_desktop_textfield_keyboard_test.dart @@ -2,11 +2,9 @@ import 'package:flutter/material.dart' hide SelectableText; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; - -import '../test_tools.dart'; -import 'super_textfield_inspector.dart'; -import 'super_textfield_robot.dart'; +import 'package:super_editor/super_text_field_test.dart'; void main() { group('SuperDesktopTextField', () { @@ -18,7 +16,7 @@ void main() { await tester.typeKeyboardText("f"); - expect(SuperTextFieldInspector.findText().text, "f"); + expect(SuperTextFieldInspector.findText().toPlainText(), "f"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); }); @@ -26,14 +24,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: '--><--'), + text: AttributedText('--><--'), ), ); await tester.placeCaretInSuperTextField(3); await tester.typeKeyboardText("f"); - expect(SuperTextFieldInspector.findText().text, "-->f<--"); + expect(SuperTextFieldInspector.findText().toPlainText(), "-->f<--"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4)); }); @@ -41,14 +39,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: '-->'), + text: AttributedText('-->'), ), ); await tester.placeCaretInSuperTextField(3); await tester.typeKeyboardText("f"); - expect(SuperTextFieldInspector.findText().text, "-->f"); + expect(SuperTextFieldInspector.findText().toPlainText(), "-->f"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4)); }); @@ -56,14 +54,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: '-->REPLACE<--'), + text: AttributedText('-->REPLACE<--'), ), ); await tester.selectSuperTextFieldText(2, 10); await tester.typeKeyboardText("f"); - expect(SuperTextFieldInspector.findText().text, "-->f<--"); + expect(SuperTextFieldInspector.findText().toPlainText(), "-->f<--"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4)); }); }); @@ -73,14 +71,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'this is some text'), + text: AttributedText('this is some text'), ), ); await tester.placeCaretInSuperTextField(8); await tester.pressEnter(); - expect(SuperTextFieldInspector.findText().text, "this is \nsome text"); + expect(SuperTextFieldInspector.findText().toPlainText(), "this is \nsome text"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 9)); }); @@ -88,14 +86,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'this is some text'), + text: AttributedText('this is some text'), ), ); await tester.placeCaretInSuperTextField(0); await tester.pressEnter(); - expect(SuperTextFieldInspector.findText().text, "\nthis is some text"); + expect(SuperTextFieldInspector.findText().toPlainText(), "\nthis is some text"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); }); @@ -103,14 +101,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'this is some text'), + text: AttributedText('this is some text'), ), ); await tester.placeCaretInSuperTextField(17); await tester.pressEnter(); - expect(SuperTextFieldInspector.findText().text, "this is some text\n"); + expect(SuperTextFieldInspector.findText().toPlainText(), "this is some text\n"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 18)); }); }); @@ -120,7 +118,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(0); @@ -151,7 +149,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(2); @@ -171,7 +169,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), )); await tester.placeCaretInSuperTextField(18); @@ -225,7 +223,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(2); @@ -244,7 +242,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); // TODO: we begin 1 character ahead of where we should because @@ -260,7 +258,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); // TODO: we begin 1 character behind where we should because @@ -278,7 +276,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); await tester.placeCaretInSuperTextField(16); @@ -292,7 +290,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); await tester.placeCaretInSuperTextField(2); @@ -310,7 +308,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(18, null, TextAffinity.upstream); @@ -331,7 +329,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); await tester.placeCaretInSuperTextField(2); @@ -345,7 +343,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); // TODO: we begin 1 character ahead of where we should because @@ -361,7 +359,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); // TODO: we begin 1 character after of where we should because @@ -379,7 +377,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(5); @@ -393,7 +391,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(18); @@ -407,7 +405,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(18); @@ -421,7 +419,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(23); @@ -437,7 +435,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(50); @@ -452,7 +450,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(0); @@ -466,7 +464,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(0); @@ -480,7 +478,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(5); @@ -496,14 +494,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: ""), + text: AttributedText(), ), ); await tester.placeCaretInSuperTextField(0); await tester.pressBackspace(); - expect(SuperTextFieldInspector.findText().text, ""); + expect(SuperTextFieldInspector.findText().toPlainText(), ""); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); }); @@ -511,14 +509,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(2); await tester.pressBackspace(); - expect(SuperTextFieldInspector.findText().text, "tis is some text"); + expect(SuperTextFieldInspector.findText().toPlainText(), "tis is some text"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); }); @@ -526,7 +524,7 @@ void main() { // TODO: We create the controller outside the pump so that we can // explicitly set its selection because of bug #549. final controller = AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ); await _pumpSuperTextField( tester, @@ -540,21 +538,22 @@ void main() { await tester.pressBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); - expect(SuperTextFieldInspector.findText().text, "is long enough to be multiline in the available space"); + expect(SuperTextFieldInspector.findText().toPlainText(), + "is long enough to be multiline in the available space"); }); testWidgetsOnDesktop('DELETE does nothing when text is empty', (tester) async { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: ""), + text: AttributedText(), ), ); await tester.placeCaretInSuperTextField(0); await tester.pressDelete(); - expect(SuperTextFieldInspector.findText().text, ""); + expect(SuperTextFieldInspector.findText().toPlainText(), ""); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); }); @@ -562,14 +561,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(17); await tester.pressDelete(); - expect(SuperTextFieldInspector.findText().text, "this is some text"); + expect(SuperTextFieldInspector.findText().toPlainText(), "this is some text"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 17)); }); @@ -577,14 +576,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(2); await tester.pressDelete(); - expect(SuperTextFieldInspector.findText().text, "ths is some text"); + expect(SuperTextFieldInspector.findText().toPlainText(), "ths is some text"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 2)); }); @@ -592,7 +591,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); // TODO: the starting offset is one index to the left because @@ -601,7 +600,7 @@ void main() { await tester.pressDelete(); - expect(SuperTextFieldInspector.findText().text, "this is text"); + expect(SuperTextFieldInspector.findText().toPlainText(), "this is text"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 8)); }); }); @@ -614,7 +613,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); @@ -633,7 +632,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); @@ -652,7 +651,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); @@ -671,7 +670,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); @@ -692,14 +691,14 @@ void main() { await _pumpSuperTextField( tester, - AttributedTextEditingController(text: AttributedText(text: "Pasted content: ")), + AttributedTextEditingController(text: AttributedText("Pasted content: ")), ); await tester.placeCaretInSuperTextField(16); await tester.pressCmdV(); // Ensure that the clipboard text was pasted into the SuperTextField - expect(SuperTextFieldInspector.findText().text, 'Pasted content: this is clipboard text'); + expect(SuperTextFieldInspector.findText().toPlainText(), 'Pasted content: this is clipboard text'); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 38)); }); @@ -708,14 +707,14 @@ void main() { await _pumpSuperTextField( tester, - AttributedTextEditingController(text: AttributedText(text: "Pasted content: ")), + AttributedTextEditingController(text: AttributedText("Pasted content: ")), ); await tester.placeCaretInSuperTextField(16); await tester.pressCtlV(); // Ensure that the clipboard text was NOT pasted into the SuperTextField. - expect(SuperTextFieldInspector.findText().text, 'Pasted content: '); + expect(SuperTextFieldInspector.findText().toPlainText(), 'Pasted content: '); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 16)); }); @@ -724,14 +723,14 @@ void main() { await _pumpSuperTextField( tester, - AttributedTextEditingController(text: AttributedText(text: "Pasted content: ")), + AttributedTextEditingController(text: AttributedText("Pasted content: ")), ); await tester.placeCaretInSuperTextField(16); await tester.sendKeyEvent(LogicalKeyboardKey.keyV); // Ensure that the clipboard text was NOT pasted into the SuperTextField. - expect(SuperTextFieldInspector.findText().text, 'Pasted content: v'); + expect(SuperTextFieldInspector.findText().toPlainText(), 'Pasted content: v'); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 17)); }); @@ -740,14 +739,14 @@ void main() { await _pumpSuperTextField( tester, - AttributedTextEditingController(text: AttributedText(text: "Pasted content: ")), + AttributedTextEditingController(text: AttributedText("Pasted content: ")), ); await tester.placeCaretInSuperTextField(16); await tester.sendKeyEvent(LogicalKeyboardKey.metaLeft); // Ensure that the clipboard text was NOT pasted into the SuperTextField. - expect(SuperTextFieldInspector.findText().text, 'Pasted content: '); + expect(SuperTextFieldInspector.findText().toPlainText(), 'Pasted content: '); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 16)); }); }); @@ -757,7 +756,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(0); @@ -778,7 +777,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(0); @@ -796,7 +795,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(0); @@ -814,7 +813,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(0); @@ -834,7 +833,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(12); @@ -850,7 +849,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(12); @@ -866,7 +865,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(12); @@ -882,7 +881,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(12); @@ -901,7 +900,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'this is some text'), + text: AttributedText('this is some text'), ), ); await tester.placeCaretInSuperTextField(5); @@ -915,7 +914,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'this is some text'), + text: AttributedText('this is some text'), ), ); await tester.placeCaretInSuperTextField(0); @@ -931,7 +930,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); await tester.placeCaretInSuperTextField(6); @@ -945,7 +944,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); await tester.placeCaretInSuperTextField(16); @@ -961,7 +960,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); await tester.placeCaretInSuperTextField(6); @@ -975,7 +974,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); await tester.placeCaretInSuperTextField(6); @@ -989,7 +988,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'super text field'), + text: AttributedText('super text field'), ), ); await tester.placeCaretInSuperTextField(6); @@ -1003,7 +1002,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'this is some text'), + text: AttributedText('this is some text'), ), ); await tester.placeCaretInSuperTextField(5); @@ -1017,7 +1016,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'this is some text'), + text: AttributedText('this is some text'), ), ); await tester.placeCaretInSuperTextField(17); @@ -1033,7 +1032,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(4); @@ -1041,14 +1040,14 @@ void main() { await tester.pressAltBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); - expect(SuperTextFieldInspector.findText().text, " is some text"); + expect(SuperTextFieldInspector.findText().toPlainText(), " is some text"); }); testWidgetsOnMac('ALT + BACKSPACE deletes until beginning of word', (tester) async { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(2); @@ -1056,14 +1055,14 @@ void main() { await tester.pressAltBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); - expect(SuperTextFieldInspector.findText().text, "is is some text"); + expect(SuperTextFieldInspector.findText().toPlainText(), "is is some text"); }); testWidgetsOnMac('ALT + BACKSPACE deletes previous word with caret after whitespace', (tester) async { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(8); @@ -1071,14 +1070,14 @@ void main() { await tester.pressAltBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 5)); - expect(SuperTextFieldInspector.findText().text, "this some text"); + expect(SuperTextFieldInspector.findText().toPlainText(), "this some text"); }); testWidgetsOnMac('ALT + BACKSPACE deletes expanded selection', (tester) async { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.selectSuperTextFieldText(0, 10); @@ -1088,14 +1087,15 @@ void main() { // TODO: When #549 is fixed, I expect this offset to change to 0, and the first // character of the expected text to be deleted. expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); - expect(SuperTextFieldInspector.findText().text, "tis long enough to be multiline in the available space"); + expect(SuperTextFieldInspector.findText().toPlainText(), + "tis long enough to be multiline in the available space"); }); testWidgetsOnMac('CMD + BACKSPACE deletes partial line before caret (flowed multiline)', (tester) async { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(28); @@ -1103,7 +1103,8 @@ void main() { await tester.pressCmdBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 18)); - expect(SuperTextFieldInspector.findText().text, "this text is long be multiline in the available space"); + expect(SuperTextFieldInspector.findText().toPlainText(), + "this text is long be multiline in the available space"); }); // TODO: When #549 is fixed, un-skip this test. The problem is that we need @@ -1113,7 +1114,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(31, null, TextAffinity.upstream); @@ -1121,21 +1122,22 @@ void main() { await tester.pressCmdBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 18)); - expect(SuperTextFieldInspector.findText().text, "this text is long multiline in the available space"); + expect( + SuperTextFieldInspector.findText().toPlainText(), "this text is long multiline in the available space"); }, skip: true); testWidgetsOnMac('CMD + BACKSPACE deletes partial line before caret (explicit newlines)', (tester) async { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "This is line 1\nThis is line 2\nThis is line 3"), + text: AttributedText("This is line 1\nThis is line 2\nThis is line 3"), ), ); await tester.placeCaretInSuperTextField(23); await tester.pressCmdBackspace(); - expect(SuperTextFieldInspector.findText().text, "This is line 1\nline 2\nThis is line 3"); + expect(SuperTextFieldInspector.findText().toPlainText(), "This is line 1\nline 2\nThis is line 3"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 15)); }); @@ -1143,14 +1145,14 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "This is line 1\nThis is line 2\nThis is line 3"), + text: AttributedText("This is line 1\nThis is line 2\nThis is line 3"), ), ); await tester.placeCaretInSuperTextField(29, null, TextAffinity.upstream); await tester.pressCmdBackspace(); - expect(SuperTextFieldInspector.findText().text, "This is line 1\n\nThis is line 3"); + expect(SuperTextFieldInspector.findText().toPlainText(), "This is line 1\n\nThis is line 3"); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 15)); }); @@ -1158,7 +1160,7 @@ void main() { // TODO: We create the controller outside the pump so that we can // explicitly set its selection because of bug #549. final controller = AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ); await _pumpSuperTextField( tester, @@ -1172,14 +1174,15 @@ void main() { await tester.pressCmdBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); - expect(SuperTextFieldInspector.findText().text, "is long enough to be multiline in the available space"); + expect(SuperTextFieldInspector.findText().toPlainText(), + "is long enough to be multiline in the available space"); }); testWidgetsOnMac('CMD + BACKSPACE does nothing when selection is at start of line', (tester) async { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.placeCaretInSuperTextField(18); @@ -1187,7 +1190,7 @@ void main() { await tester.pressCmdBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 18)); - expect(SuperTextFieldInspector.findText().text, _multilineLayoutText); + expect(SuperTextFieldInspector.findText().toPlainText(), _multilineLayoutText); }); }); @@ -1196,7 +1199,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(5); @@ -1210,7 +1213,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(5); @@ -1224,7 +1227,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(5); @@ -1238,7 +1241,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(5); @@ -1256,7 +1259,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "super text field"), + text: AttributedText("super text field"), ), ); await tester.placeCaretInSuperTextField(10); @@ -1272,7 +1275,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "super text field"), + text: AttributedText("super text field"), ), ); await tester.placeCaretInSuperTextField(10); @@ -1290,7 +1293,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(12); @@ -1308,7 +1311,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(12); @@ -1329,7 +1332,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); @@ -1348,7 +1351,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); @@ -1367,7 +1370,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); @@ -1386,7 +1389,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); @@ -1407,14 +1410,14 @@ void main() { await _pumpSuperTextField( tester, - AttributedTextEditingController(text: AttributedText(text: "Pasted content: ")), + AttributedTextEditingController(text: AttributedText("Pasted content: ")), ); await tester.placeCaretInSuperTextField(16); await tester.pressCtlV(); // Ensure that the clipboard text was pasted into the SuperTextField - expect(SuperTextFieldInspector.findText().text, 'Pasted content: this is clipboard text'); + expect(SuperTextFieldInspector.findText().toPlainText(), 'Pasted content: this is clipboard text'); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 38)); }); @@ -1423,14 +1426,14 @@ void main() { await _pumpSuperTextField( tester, - AttributedTextEditingController(text: AttributedText(text: "Pasted content: ")), + AttributedTextEditingController(text: AttributedText("Pasted content: ")), ); await tester.placeCaretInSuperTextField(16); await tester.pressCmdV(); // Ensure that the clipboard text was NOT pasted into the SuperTextField. - expect(SuperTextFieldInspector.findText().text, 'Pasted content: '); + expect(SuperTextFieldInspector.findText().toPlainText(), 'Pasted content: '); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 16)); }); @@ -1439,14 +1442,14 @@ void main() { await _pumpSuperTextField( tester, - AttributedTextEditingController(text: AttributedText(text: "Pasted content: ")), + AttributedTextEditingController(text: AttributedText("Pasted content: ")), ); await tester.placeCaretInSuperTextField(16); await tester.sendKeyEvent(LogicalKeyboardKey.keyV); // Ensure that the clipboard text was NOT pasted into the SuperTextField. - expect(SuperTextFieldInspector.findText().text, 'Pasted content: v'); + expect(SuperTextFieldInspector.findText().toPlainText(), 'Pasted content: v'); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 17)); }); @@ -1455,14 +1458,14 @@ void main() { await _pumpSuperTextField( tester, - AttributedTextEditingController(text: AttributedText(text: "Pasted content: ")), + AttributedTextEditingController(text: AttributedText("Pasted content: ")), ); await tester.placeCaretInSuperTextField(16); await tester.sendKeyEvent(LogicalKeyboardKey.controlLeft); // Ensure that the clipboard text was NOT pasted into the SuperTextField. - expect(SuperTextFieldInspector.findText().text, 'Pasted content: '); + expect(SuperTextFieldInspector.findText().toPlainText(), 'Pasted content: '); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 16)); }); }); @@ -1472,7 +1475,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(0); @@ -1493,7 +1496,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(0); @@ -1511,7 +1514,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(0); @@ -1529,7 +1532,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: 'This is some text'), + text: AttributedText('This is some text'), ), ); await tester.placeCaretInSuperTextField(0); @@ -1549,7 +1552,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "super text field"), + text: AttributedText("super text field"), ), ); await tester.placeCaretInSuperTextField(10); @@ -1563,7 +1566,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "super text field"), + text: AttributedText("super text field"), ), ); await tester.placeCaretInSuperTextField(10); @@ -1577,7 +1580,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is a text big enough that will cause auto line wrapping"), + text: AttributedText("this is a text big enough that will cause auto line wrapping"), ), ); @@ -1595,7 +1598,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "super text field\nthis is second line"), + text: AttributedText("super text field\nthis is second line"), ), ); @@ -1615,7 +1618,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "super text field"), + text: AttributedText("super text field"), ), ); await tester.placeCaretInSuperTextField(6); @@ -1629,7 +1632,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "super text field"), + text: AttributedText("super text field"), ), ); await tester.placeCaretInSuperTextField(6); @@ -1643,7 +1646,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is a text big enough that will cause auto line wrapping"), + text: AttributedText("this is a text big enough that will cause auto line wrapping"), ), ); @@ -1661,7 +1664,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "super text field\nthis is second line"), + text: AttributedText("super text field\nthis is second line"), ), ); @@ -1681,7 +1684,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(4); @@ -1689,14 +1692,14 @@ void main() { await tester.pressCtlBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); - expect(SuperTextFieldInspector.findText().text, " is some text"); + expect(SuperTextFieldInspector.findText().toPlainText(), " is some text"); }); testWidgetsOnWindowsAndLinux('CTL + BACKSPACE deletes until beginning of word', (tester) async { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(2); @@ -1704,7 +1707,7 @@ void main() { await tester.pressCtlBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); - expect(SuperTextFieldInspector.findText().text, "is is some text"); + expect(SuperTextFieldInspector.findText().toPlainText(), "is is some text"); }); testWidgetsOnWindowsAndLinux('CTL + BACKSPACE deletes previous word with caret after whitespace', @@ -1712,7 +1715,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(8); @@ -1720,14 +1723,14 @@ void main() { await tester.pressCtlBackspace(); expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 5)); - expect(SuperTextFieldInspector.findText().text, "this some text"); + expect(SuperTextFieldInspector.findText().toPlainText(), "this some text"); }); testWidgetsOnWindowsAndLinux('CTL + BACKSPACE deletes expanded selection', (tester) async { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), + text: AttributedText(_multilineLayoutText), ), ); await tester.selectSuperTextFieldText(0, 10); @@ -1737,7 +1740,8 @@ void main() { // TODO: When #549 is fixed, I expect the selection offset to change to 0, and the // first letter of the final text to be deleted. expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); - expect(SuperTextFieldInspector.findText().text, "tis long enough to be multiline in the available space"); + expect(SuperTextFieldInspector.findText().toPlainText(), + "tis long enough to be multiline in the available space"); }); }); @@ -1746,7 +1750,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(5); @@ -1760,7 +1764,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(5); @@ -1774,7 +1778,7 @@ void main() { await _pumpSuperTextField( tester, AttributedTextEditingController( - text: AttributedText(text: "this is some text"), + text: AttributedText("this is some text"), ), ); await tester.placeCaretInSuperTextField(5); @@ -1799,7 +1803,7 @@ const _multilineLayoutText = 'this text is long enough to be multiline in the av Future _pumpEmptySuperTextField(WidgetTester tester) async { await _pumpSuperTextField( tester, - AttributedTextEditingController(text: AttributedText(text: '')), + AttributedTextEditingController(text: AttributedText('')), ); } diff --git a/super_editor/test/super_textfield/super_texfield_scroll_test.dart b/super_editor/test/super_textfield/super_texfield_scroll_test.dart deleted file mode 100644 index 3307d1f7ae..0000000000 --- a/super_editor/test/super_textfield/super_texfield_scroll_test.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:golden_toolkit/golden_toolkit.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_text_layout/super_text_layout.dart'; - -import '../test_tools.dart'; - -void main() { - group('SuperTextField', () { - testWidgetsOnAllPlatforms('single-line jumps scroll position horizontally as the user types', (tester) async { - final controller = AttributedTextEditingController( - text: AttributedText(text: "ABCDEFG"), - ); - - // Pump the widget tree with a SuperTextField with a maxWidth smaller - // than the text width - await _pumpTestApp( - tester, - textController: controller, - minLines: 1, - maxLines: 1, - maxWidth: 50, - ); - - // Move selection to the end of the text - // TODO: change to simulate user input when IME simulation is available - controller.selection = const TextSelection.collapsed(offset: 7); - await tester.pumpAndSettle(); - - // Position at the end of the viewport - final viewportRight = tester.getBottomRight(find.byType(SuperTextField)).dx; - - // Position at the end of the text - final textRight = tester.getBottomRight(find.byType(SuperText)).dx; - - // Ensure the text field scrolled its content horizontally - expect(textRight, lessThanOrEqualTo(viewportRight)); - }); - - testWidgetsOnAllPlatforms('multi-line jumps scroll position vertically as the user types', (tester) async { - final controller = AttributedTextEditingController( - text: AttributedText(text: "A\nB\nC\nD"), - ); - - // Pump the widget tree with a SuperTextField with a maxHeight smaller - // than the text heght - await _pumpTestApp( - tester, - textController: controller, - minLines: 1, - maxLines: 2, - maxHeight: 20, - ); - - // Move selection to the end of the text - // TODO: change to simulate user input when IME simulation is available - controller.selection = const TextSelection.collapsed(offset: 7); - await tester.pumpAndSettle(); - - // Position at the end of the viewport - final viewportBottom = tester.getBottomRight(find.byType(SuperTextField)).dy; - - // Position at the end of the text - final textBottom = tester.getBottomRight(find.byType(SuperText)).dy; - - // Ensure the text field scrolled its content vertically - expect(textBottom, lessThanOrEqualTo(viewportBottom)); - }); - - testWidgetsOnDesktop("doesn't scroll vertically when maxLines is null", (tester) async { - // With the Ahem font the estimated line height is equal to the true line height - // so we need to use a custom font. - await loadAppFonts(); - - // We use some padding because it affects the viewport height calculation. - const verticalPadding = 6.0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: ConstrainedBox( - constraints: const BoxConstraints(minWidth: 300), - child: SuperDesktopTextField( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: verticalPadding), - minLines: 1, - maxLines: null, - textController: AttributedTextEditingController( - text: AttributedText(text: "SuperTextField"), - ), - textStyleBuilder: (_) => const TextStyle( - fontSize: 14, - height: 1, - fontFamily: 'Roboto', - ), - ), - ), - ), - ), - ); - await tester.pump(); - - // In the running app, the estimated line height and actual line height differ. - // This test ensures that we account for that. Ideally, this test would check that the scrollview doesn't scroll. - // However, in test suites, the estimated and actual line heights are always identical. - // Therefore, this test ensures that we add up the appropriate dimensions, - // rather than verify the scrollview's max scroll extent. - - final viewportHeight = tester.getRect(find.byType(SuperTextFieldScrollview)).height; - - final layoutState = (find.byType(SuperDesktopTextField).evaluate().single as StatefulElement).state as SuperDesktopTextFieldState; - final contentHeight = layoutState.textLayout.getLineHeightAtPosition(const TextPosition(offset: 0)); - - // Vertical padding is added to both top and bottom - final totalHeight = contentHeight + (verticalPadding * 2); - - // Ensure the viewport is big enough so the text doesn't scroll vertically - expect(viewportHeight, greaterThanOrEqualTo(totalHeight)); - }); - }); -} - -Future _pumpTestApp( - WidgetTester tester, { - required AttributedTextEditingController textController, - required int minLines, - required int maxLines, - double? maxWidth, - double? maxHeight, -}) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: maxWidth ?? double.infinity, - maxHeight: maxHeight ?? double.infinity, - ), - child: SuperTextField( - textController: textController, - lineHeight: 20, - textStyleBuilder: (_) => const TextStyle(fontSize: 20), - minLines: minLines, - maxLines: maxLines, - ), - ), - ), - ), - ); -} diff --git a/super_editor/test/super_textfield/super_textfield_ancestor_shortcuts_test.dart b/super_editor/test/super_textfield/super_textfield_ancestor_shortcuts_test.dart index eb34a7856a..79cebfa3e1 100644 --- a/super_editor/test/super_textfield/super_textfield_ancestor_shortcuts_test.dart +++ b/super_editor/test/super_textfield/super_textfield_ancestor_shortcuts_test.dart @@ -2,10 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; - -import '../test_tools.dart'; -import 'super_textfield_robot.dart'; +import 'package:super_editor/super_text_field_test.dart'; void main() { group("SuperTextField shortcuts", () { @@ -82,7 +81,7 @@ Future _pumpShortcutsAndSuperTextField( width: 300, child: SuperTextField( textController: AttributedTextEditingController( - text: AttributedText(text: ""), + text: AttributedText(), ), keyboardHandlers: keyboardActions, ), diff --git a/super_editor/test/super_textfield/super_textfield_attributions_test.dart b/super_editor/test/super_textfield/super_textfield_attributions_test.dart new file mode 100644 index 0000000000..5f01a46f4b --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_attributions_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group("SuperTextField", () { + group("applies color attributions", () { + testWidgetsOnAllPlatforms("to full text", (tester) async { + await _pumpTestApp( + tester, + text: AttributedText( + 'abcdefghij', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 0, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 9, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ); + + // Ensure the text is colored orange. + for (int i = 0; i <= 9; i++) { + expect( + SuperTextFieldInspector.findRichText().getSpanForPosition(TextPosition(offset: i))!.style!.color, + Colors.orange, + ); + } + }); + + testWidgetsOnAllPlatforms("to partial text", (tester) async { + await _pumpTestApp( + tester, + text: AttributedText( + 'abcdefghij', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 5, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: ColorAttribution(Colors.orange), + offset: 9, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ); + + // Ensure the first span is colored black. + expect( + SuperTextFieldInspector.findRichText().getSpanForPosition(const TextPosition(offset: 0))!.style!.color, + Colors.black, + ); + + // Ensure the second span is colored orange. + expect( + SuperTextFieldInspector.findRichText().getSpanForPosition(const TextPosition(offset: 5))!.style!.color, + Colors.orange, + ); + }); + }); + }); +} + +/// Pumps a [SuperTextField] with the given attributed [text]. +Future _pumpTestApp( + WidgetTester tester, { + required AttributedText text, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperTextField( + textController: AttributedTextEditingController( + text: text, + ), + ), + ), + ), + ); + + // A SuperTextField configured with maxLines can't render in the first frame. + // Ask another frame, so the text field can be found by the finder. + await tester.pump(); +} diff --git a/super_editor/test/super_textfield/super_textfield_auto_scroll_test.dart b/super_editor/test/super_textfield/super_textfield_auto_scroll_test.dart index 51d5e856d4..ec6499916c 100644 --- a/super_editor/test/super_textfield/super_textfield_auto_scroll_test.dart +++ b/super_editor/test/super_textfield/super_textfield_auto_scroll_test.dart @@ -1,18 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/src/infrastructure/super_textfield/metrics.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:meta/meta.dart'; +import 'package:super_editor/src/super_textfield/metrics.dart'; import 'package:super_editor/super_editor.dart'; -import 'package:super_text_layout/super_text_layout.dart'; - -import '../test_tools.dart'; +import 'package:super_editor/super_text_field_test.dart'; const screenSizeWithoutKeyboard = Size(400, 800); const screenSizeWithKeyboard = Size(400, 300); void main() { - group('SuperTextField on mobile', () { - group('with an ancestor Scrollable', () { - testWidgetsOnMobileWithKeyboard('auto scrolls when focused in single-line', (tester, keyboardToggle) async { + group('SuperTextField', () { + group('on mobile with an ancestor Scrollable', () { + _testWidgetsOnMobileWithKeyboard('auto scrolls when focused in single-line', (tester, keyboardToggle) async { await _pumpTestApp( tester, text: 'Single line SuperTextField', @@ -33,13 +33,14 @@ void main() { expect(selectionOffset.dy.floor(), lessThanOrEqualTo(screenSizeWithKeyboard.height)); // Ensure we scroll only the necessary to reveal the selection, plus a small gap - expect(screenSizeWithKeyboard.height - selectionOffset.dy.floor(), lessThanOrEqualTo(gapBetweenCaretAndKeyboard)); + expect( + screenSizeWithKeyboard.height - selectionOffset.dy.floor(), lessThanOrEqualTo(gapBetweenCaretAndKeyboard)); // Ensure selection doesn't scroll beyond the top expect(selectionOffset.dy.floor(), greaterThanOrEqualTo(0)); }); - testWidgetsOnMobileWithKeyboard('auto scrolls when focused in single-line', (tester, keyboardToggle) async { + _testWidgetsOnMobileWithKeyboard('auto scrolls when focused in single-line', (tester, keyboardToggle) async { await _pumpTestApp( tester, text: 'Single line SuperTextField', @@ -60,13 +61,14 @@ void main() { expect(selectionOffset.dy.floor(), lessThanOrEqualTo(screenSizeWithKeyboard.height)); // Ensure we scroll only the necessary to reveal the selection, plus a small gap - expect(screenSizeWithKeyboard.height - selectionOffset.dy.floor(), lessThanOrEqualTo(gapBetweenCaretAndKeyboard)); + expect( + screenSizeWithKeyboard.height - selectionOffset.dy.floor(), lessThanOrEqualTo(gapBetweenCaretAndKeyboard)); // Ensure selection doesn't scroll beyond the top expect(selectionOffset.dy.floor(), greaterThanOrEqualTo(0)); }); - testWidgetsOnMobileWithKeyboard('auto scrolls when focused in multi-line', (tester, keyboardToggle) async { + _testWidgetsOnMobileWithKeyboard('auto scrolls when focused in multi-line', (tester, keyboardToggle) async { await _pumpTestApp( tester, text: 'This is\na multiline\nSuperTextField', @@ -87,13 +89,14 @@ void main() { expect(selectionOffset.dy.floor(), lessThanOrEqualTo(screenSizeWithKeyboard.height)); // Ensure we scroll only the necessary to reveal the selection, plus a small gap - expect(screenSizeWithKeyboard.height - selectionOffset.dy.floor(), lessThanOrEqualTo(gapBetweenCaretAndKeyboard)); + expect( + screenSizeWithKeyboard.height - selectionOffset.dy.floor(), lessThanOrEqualTo(gapBetweenCaretAndKeyboard)); // Ensure selection doesn't scroll beyond the top expect(selectionOffset.dy.floor(), greaterThanOrEqualTo(0)); }); - testWidgetsOnMobileWithKeyboard('doest not auto scroll when not focused', (tester, keyboardToggle) async { + _testWidgetsOnMobileWithKeyboard('doest not auto scroll when not focused', (tester, keyboardToggle) async { await _pumpTestApp( tester, text: 'Single line SuperTextField', @@ -113,6 +116,37 @@ void main() { expect(finalTopLeft, initialTopLeft); }); }); + + testWidgetsOnAllPlatforms('auto scroll doesn\'t crash when text is empty', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText('Text before'), + ); + + await _pumpScaffold( + tester, + SuperTextField( + textController: controller, + ), + ); + + // Place caret at the end of the text field. + await tester.placeCaretInSuperTextField(11); + + // Clear the text and changes the selection to the beginning of the text. + controller.updateTextAndSelection( + text: AttributedText(), + selection: const TextSelection.collapsed(offset: 0), + ); + await tester.pump(); + + /// When text or selection changes, we auto scroll to ensure that the selecion is visible. + /// To do so, we need to get the bounds of the character at the selection extent. + /// + /// If we attempt to auto scroll when the text is empty, a crash happens because there isn't a + /// character at the selection extent. + /// + /// Reaching this point means we didn't attempt to auto scroll after clearing the text. + }); }); } @@ -132,7 +166,7 @@ Future _pumpTestApp( maxLines: lineCount, lineHeight: 24, textController: AttributedTextEditingController( - text: AttributedText(text: text), + text: AttributedText(text), ), ), ], @@ -140,18 +174,39 @@ Future _pumpTestApp( ), ), ); + + // A SuperTextField configured with maxLines can't render in the first frame. + // Ask another frame, so the text field can be found by the finder. + await tester.pump(); +} + +/// Pumps a scaffold with a centered [child]. +Future _pumpScaffold(WidgetTester tester, Widget child) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: child, + ), + ), + ), + ), + ); } -void testWidgetsOnMobileWithKeyboard( +@isTestGroup +void _testWidgetsOnMobileWithKeyboard( String description, Future Function(WidgetTester tester, _KeyboardToggle keyboardToggle) test, ) { testWidgetsOnMobile(description, (tester) async { - tester.binding.window - ..physicalSizeTestValue = screenSizeWithoutKeyboard + tester.view + ..physicalSize = screenSizeWithoutKeyboard ..platformDispatcher.textScaleFactorTestValue = 1.0 - ..devicePixelRatioTestValue = 1.0; - addTearDown(() => tester.binding.window.clearAllTestValues()); + ..devicePixelRatio = 1.0; + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); final keyboardToggle = _KeyboardToggle( tester: tester, @@ -168,6 +223,7 @@ class _KeyboardToggle { required this.tester, required this.sizeWithoutKeyboard, required this.sizeWithKeyboard, + // ignore: unused_element this.frameCount = 60, }); @@ -187,7 +243,7 @@ class _KeyboardToggle { resizedWidth += widthShrinkPerFrame; resizedHeight += heightShrinkPerFrame; final currentScreenSize = (sizeWithoutKeyboard - Offset(resizedWidth, resizedHeight)) as Size; - tester.binding.window.physicalSizeTestValue = currentScreenSize; + tester.view.physicalSize = currentScreenSize; await tester.pumpAndSettle(); } } diff --git a/super_editor/test/super_textfield/super_textfield_caret_test.dart b/super_editor/test/super_textfield/super_textfield_caret_test.dart index ede7aad9ac..da7ab58c21 100644 --- a/super_editor/test/super_textfield/super_textfield_caret_test.dart +++ b/super_editor/test/super_textfield/super_textfield_caret_test.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_text_field_test.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import 'package:flutter_test_robots/flutter_test_robots.dart'; - -import '../test_tools.dart'; void main() { group("SuperTextField caret", () { @@ -102,7 +102,7 @@ void main() { expect(_isCaretPresent(tester), isFalse); }); - testWidgetsOnAllPlatforms("is displayed with focus and a text selection", (tester) async { + testWidgetsOnAllPlatforms("is displayed with focus and a collapsed text selection", (tester) async { final controller = AttributedTextEditingController( selection: const TextSelection.collapsed(offset: 0), ); @@ -119,6 +119,247 @@ void main() { expect(_isCaretPresent(tester), isTrue); }); + + testWidgetsOnMobile("is NOT displayed with focus and an expanded text selection", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("Hello, world!"), + selection: const TextSelection(baseOffset: 0, extentOffset: 5), + ); + + await tester.pumpWidget( + _buildScaffold( + child: SuperTextField( + focusNode: FocusNode()..requestFocus(), + textController: controller, + ), + ), + ); + await tester.pump(); + + expect(_isCaretPresent(tester), isFalse); + }); + + testWidgetsOnAllPlatforms("uses the given caretStyle", (tester) async { + final controller = AttributedTextEditingController( + selection: const TextSelection.collapsed(offset: 0), + ); + + const caretStyle = CaretStyle( + color: Colors.red, + width: 5, + borderRadius: BorderRadius.all(Radius.circular(2.0)), + ); + + await tester.pumpWidget( + _buildScaffold( + child: SuperTextField( + focusNode: FocusNode()..requestFocus(), + textController: controller, + caretStyle: caretStyle, + ), + ), + ); + await tester.pump(); + + final caret = tester.widget(find.byType(TextLayoutCaret)); + + expect(caret.style.color, caretStyle.color); + expect(caret.style.width, caretStyle.width); + expect(caret.style.borderRadius, caretStyle.borderRadius); + }); + + testWidgetsOnMobile("does not blink while dragging the caret", (tester) async { + addTearDown(() => BlinkController.indeterminateAnimationsEnabled = false); + + final controller = AttributedTextEditingController( + text: AttributedText( + 'SuperTextField with a content that spans multiple lines of text to test scrolling with a scrollbar.', + ), + ); + + await tester.pumpWidget( + _buildScaffold( + child: SuperTextField( + textController: controller, + ), + ), + ); + + await tester.placeCaretInSuperTextField(0); + + // Configure BlinkController to animate, otherwise it won't blink. + BlinkController.indeterminateAnimationsEnabled = true; + + // Drag caret by a small distance so that we trigger a user drag event. + // This drag event is continued down below so that we can check for caret blinking + // during a user drag. + final TestGesture gesture = await tester.dragCaretByDistanceInSuperTextField(const Offset(100, 100)); + addTearDown(() => gesture.removePointer()); + + // Check for the caret visibility across 3-4 frames and ensure it doesn't blink. + // Test in half-flash period intervals as we don't know how much time has passed and + // we might get unlucky and check the visibility when the caret is momentarily + // invisible. + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + }); + + testWidgetsOnMobile("does not blink while dragging expanded handles", (tester) async { + addTearDown(() => BlinkController.indeterminateAnimationsEnabled = false); + + final controller = AttributedTextEditingController( + text: AttributedText( + 'SuperTextField with a content that spans multiple lines of text to test scrolling with a scrollbar.', + ), + ); + + await tester.pumpWidget( + _buildScaffold( + child: SuperTextField( + textController: controller, + ), + ), + ); + + await tester.doubleTapAtSuperTextField(0); + + // Configure BlinkController to animate, otherwise it won't blink. + BlinkController.indeterminateAnimationsEnabled = true; + + // Drag the upstream selection handle by a small distance so that we trigger a + // user drag event. This drag event is continued down below so that we can check + // for caret blinking during a user drag. + final TestGesture upstreamHandleGesture = + await tester.dragUpstreamMobileHandleByDistanceInSuperTextField(const Offset(100, 100)); + addTearDown(() => upstreamHandleGesture.removePointer()); + + // Check for the caret visibility across 3-4 frames and ensure it doesn't blink. + // Test in half-flash period intervals as we don't know how much time has passed and + // we might get unlucky and check the visibility when the caret is momentarily + // invisible. + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + // End the current drag gesture before we start the downstream handle drag. + // We don't want multiple gesture pointers active at the same time. + await upstreamHandleGesture.up(); + await tester.pump(); + + // Drag the downstream selection handle by a small distance so that we trigger a + // user drag event. This drag event is continued down below so that we can check + // for caret blinking during a user drag. + final TestGesture downstreamHandleGesture = + await tester.dragDownstreamMobileHandleByDistanceInSuperTextField(const Offset(100, 100)); + addTearDown(() => downstreamHandleGesture.removePointer()); + + // Check for the caret visibility across 3-4 frames and ensure it doesn't blink. + // Test in half-flash period intervals as we don't know how much time has passed and + // we might get unlucky and check the visibility when the caret is momentarily + // invisible. + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + }); + + testWidgetsOnAndroid("does not blink while dragging collapsed handle", (tester) async { + addTearDown(() => BlinkController.indeterminateAnimationsEnabled = false); + + final controller = AttributedTextEditingController( + text: AttributedText( + 'SuperTextField with a content that spans multiple lines of text to test scrolling with a scrollbar.', + ), + ); + + await tester.pumpWidget( + _buildScaffold( + child: SuperTextField( + textController: controller, + ), + ), + ); + + await tester.placeCaretInSuperTextField(0); + + // Configure BlinkController to animate, otherwise it won't blink. + BlinkController.indeterminateAnimationsEnabled = true; + + // Drag the collapsed handle by a small distance so that we trigger a + // user drag event. This drag event is continued down below so that we + // can check for caret blinking during a user drag. + final TestGesture gesture = + await tester.dragAndroidCollapsedHandleByDistanceInSuperTextField(const Offset(100, 100)); + addTearDown(() => gesture.removePointer()); + + // Check for the caret visibility across 3-4 frames and ensure it doesn't blink. + // Test in half-flash period intervals as we don't know how much time has passed and + // we might get unlucky and check the visibility when the caret is momentarily + // invisible. + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + + await tester.pump(flashPeriod ~/ 2); + + // Ensure caret is visible. + expect(_isCaretVisible(tester), true); + }); }); } diff --git a/super_editor/test/super_textfield/super_textfield_text_layout_test.dart b/super_editor/test/super_textfield/super_textfield_emoji_test.dart similarity index 60% rename from super_editor/test/super_textfield/super_textfield_text_layout_test.dart rename to super_editor/test/super_textfield/super_textfield_emoji_test.dart index ed57d58e4c..d60cc7ca24 100644 --- a/super_editor/test/super_textfield/super_textfield_text_layout_test.dart +++ b/super_editor/test/super_textfield/super_textfield_emoji_test.dart @@ -1,17 +1,16 @@ import 'package:flutter/material.dart' hide SelectableText; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; - -import '../test_tools.dart'; -import 'super_textfield_inspector.dart'; -import 'super_textfield_robot.dart'; +import 'package:super_editor/super_text_field_test.dart'; void main() { group('SuperTextField with keyboard', () { - group('containing only one emoji', (){ - testWidgetsOnAllPlatforms("moves caret upstream around the emoji", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + group('containing only one emoji', () { + testWidgetsOnAllPlatforms("moves caret upstream around the emoji", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: '🐢', ); @@ -19,27 +18,25 @@ void main() { // #549 - update to place caret at the end and remove the call to pressRightArrow when that bug is fixed. // Place caret at the beginning of the text await tester.placeCaretInSuperTextField(0); - // Move caret to the right + // Move caret to the right await tester.pressRightArrow(); // Ensure we are at the end of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 2), - ); + ); // Press left arrow key to move the selection to the beginning of the text - await tester.pressLeftArrow(); + await tester.pressLeftArrow(); // Ensure caret is at the beginning of the text - expect( - SuperTextFieldInspector.findSelection(), - const TextSelection.collapsed(offset: 0) - ); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); }); - testWidgetsOnAllPlatforms("expands selection upstream around the emoji", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + testWidgetsOnAllPlatforms("expands selection upstream around the emoji", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: '🐢', ); @@ -47,100 +44,104 @@ void main() { // #549 - update to place caret at the end and remove the call to pressRightArrow when that bug is fixed. // Place caret at the beginning of the text await tester.placeCaretInSuperTextField(0); - // Move caret to the right - await tester.pressRightArrow(); + // Move caret to the right + await tester.pressRightArrow(); // Ensure we are at the end of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 2), - ); + ); // Press shift + left arrow key to expand the selection to the left - await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); // Ensure that the emoji is selected expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 2, extentOffset: 0, ), - ); + ); }); - - testWidgetsOnAllPlatforms("moves caret downstream around the emoji", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + + testWidgetsOnAllPlatforms("moves caret downstream around the emoji", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: '🐢', ); - // Place caret before the emoji + // Place caret before the emoji await tester.placeCaretInSuperTextField(0); // Ensure we are at the beginning of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0), - ); + ); // Press right arrow key to move the selection to the right - await tester.pressRightArrow(); + await tester.pressRightArrow(); // Ensure caret is at the end of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 2), - ); - }); + ); + }); - testWidgetsOnAllPlatforms("expands selection downstream around the emoji", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + testWidgetsOnAllPlatforms("expands selection downstream around the emoji", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: '🐢', ); // Place caret before the emoji - await tester.placeCaretInSuperTextField(0); + await tester.placeCaretInSuperTextField(0); // Ensure we are at the beginning of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0), - ); + ); // Press shift + right arrow key to expand the selection to the right - await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); // Ensure that the emoji is selected expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 0, extentOffset: 2, ), - ); + ); }); - testWidgetsOnAllPlatforms("selects the emoji on double tap", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + testWidgetsOnAllPlatforms("selects the emoji on double tap", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: '🐢', ); - await tester.doubleTapAtSuperTextField(0); + await tester.doubleTapAtSuperTextField(0); // Ensure that the emoji is selected expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 0, extentOffset: 2, ), - ); + ); }); }); - group('containing only two consecutive emojis', (){ - testWidgetsOnAllPlatforms("moves caret upstream around the emoji", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + group('containing only two consecutive emojis', () { + testWidgetsOnAllPlatforms("moves caret upstream around the emoji", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: '🐢🐢', ); @@ -148,38 +149,36 @@ void main() { // #549 - update to place caret at the end and remove the calls to pressRightArrow when that bug is fixed. // Place caret at the beginning of the text await tester.placeCaretInSuperTextField(0); - // Move caret to the right + // Move caret to the right await tester.pressRightArrow(); - // Move caret to the right + // Move caret to the right await tester.pressRightArrow(); // Ensure we are at the end of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4), - ); + ); // Press left arrow key to move the selection to the left - await tester.pressLeftArrow(); + await tester.pressLeftArrow(); // Ensure caret is between the two emojis expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 2), - ); + ); // Press left arrow key to move the selection to the left - await tester.pressLeftArrow(); + await tester.pressLeftArrow(); // Ensure caret is at the beginning of the text - expect( - SuperTextFieldInspector.findSelection(), - const TextSelection.collapsed(offset: 0) - ); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); }); - testWidgetsOnAllPlatforms("expands selection upstream around the emoji", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + testWidgetsOnAllPlatforms("expands selection upstream around the emoji", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: '🐢🐢', ); @@ -187,95 +186,97 @@ void main() { // #549 - update to place caret at the end and remove the calls to pressRightArrow when that bug is fixed. // Place caret at the beginning of the text await tester.placeCaretInSuperTextField(0); - // Move caret to the right + // Move caret to the right await tester.pressRightArrow(); - // Move caret to the right + // Move caret to the right await tester.pressRightArrow(); // Ensure we are at the end of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4), - ); + ); // Press shift + left arrow key to expand the selection to the left - await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); // Ensure that the last emoji is selected expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 4, extentOffset: 2, ), - ); + ); // Press shift + left arrow key to expand the selection to the left - await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); // Ensure the whole text is selected expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 4, extentOffset: 0, ), - ); - }); - - testWidgetsOnAllPlatforms("moves caret downstream around the emoji", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + ); + }); + + testWidgetsOnAllPlatforms("moves caret downstream around the emoji", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: '🐢🐢', ); - // Place caret before the first emoji + // Place caret before the first emoji await tester.placeCaretInSuperTextField(0); // Ensure we are at the beginning of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0), - ); + ); // Press right arrow key to move the selection to the right - await tester.pressRightArrow(); + await tester.pressRightArrow(); // Ensure caret is between the two emojis expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 2), - ); + ); // Press right arrow key to move the selection to the right - await tester.pressRightArrow(); + await tester.pressRightArrow(); // Ensure caret is at the end of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4), - ); - }); - - testWidgetsOnAllPlatforms("expands selection downstream around the emoji", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + ); + }); + + testWidgetsOnAllPlatforms("expands selection downstream around the emoji", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: '🐢🐢', ); // Place caret before the first emoji - await tester.placeCaretInSuperTextField(0); + await tester.placeCaretInSuperTextField(0); // Ensure we are at the beginning of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0), - ); + ); // Press shift + right arrow key to expand the selection to the right - await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); // Ensure the first emoji is selected - expect( - SuperTextFieldInspector.findSelection(), + expect( + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 0, extentOffset: 2, @@ -283,145 +284,146 @@ void main() { ); // Press shift + right arrow key to expand the selection to the right - await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); // Ensure we selected the whole text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 0, extentOffset: 4, ), - ); - }); - }); + ); + }); + }); - group('containing emojis and non-emojis', (){ - testWidgetsOnAllPlatforms("moves caret upstream around the text", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + group('containing emojis and non-emojis', () { + testWidgetsOnAllPlatforms("moves caret upstream around the text", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: 'a🐢b', ); - // Place caret at |b - await tester.placeCaretInSuperTextField(3); + // Place caret at |b + await tester.placeCaretInSuperTextField(3); // Ensure we are after the emoji expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 3), - ); + ); // Press left arrow key to move the selection to the left - await tester.pressLeftArrow(); + await tester.pressLeftArrow(); // Ensure we are between the emoji and the 'a' expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1), - ); + ); // Press left arrow key to move the selection to the left - await tester.pressLeftArrow(); + await tester.pressLeftArrow(); // Ensure caret is at the beginning of the text - expect( - SuperTextFieldInspector.findSelection(), - const TextSelection.collapsed(offset: 0) - ); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); }); - - testWidgetsOnAllPlatforms("expands selection upstream around the text", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + + testWidgetsOnAllPlatforms("expands selection upstream around the text", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: 'a🐢b', ); // Place caret at |b - await tester.placeCaretInSuperTextField(3); + await tester.placeCaretInSuperTextField(3); // Ensure we are after the emoji expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 3), - ); + ); // Press shift + left arrow key to expand the selection to the left - await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); // Ensure we selected the emoji expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 3, extentOffset: 1, ), - ); + ); // Press shift + left arrow key to expand the selection to the left - await tester.pressShiftLeftArrow(); + await tester.pressShiftLeftArrow(); // Ensure "a🐢" is selected expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 3, extentOffset: 0, ), - ); - }); - - testWidgetsOnAllPlatforms("moves caret downstream around the text", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + ); + }); + + testWidgetsOnAllPlatforms("moves caret downstream around the text", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: 'a🐢b', ); - // Place caret at the beginning of the text + // Place caret at the beginning of the text await tester.placeCaretInSuperTextField(0); // Ensure we are at the beginning of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0), - ); + ); // Press right arrow key to move the selection to the right - await tester.pressRightArrow(); + await tester.pressRightArrow(); // Ensure we are at a| expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1), - ); + ); // Press right arrow key to move the selection to the right - await tester.pressRightArrow(); + await tester.pressRightArrow(); // Ensure caret is after the emoji expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 3), - ); - }); - - testWidgetsOnAllPlatforms("expands selection downstream around the text", (tester) async { - await _pumpSuperTextFieldEmojiTest(tester, + ); + }); + + testWidgetsOnAllPlatforms("expands selection downstream around the text", (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, text: 'a🐢b', ); // Place caret at the beginning of the text - await tester.placeCaretInSuperTextField(0); + await tester.placeCaretInSuperTextField(0); // Ensure we are at the beginning of the text expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0), - ); + ); // Press shift + right arrow key to expand the selection to the right - await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); // Ensure 'a' is selected expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 0, extentOffset: 1, @@ -429,16 +431,33 @@ void main() { ); // Press shift + right arrow key to expand the selection to the right - await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); // Ensure "a🐢" is selected expect( - SuperTextFieldInspector.findSelection(), + SuperTextFieldInspector.findSelection(), const TextSelection( baseOffset: 0, extentOffset: 3, ), - ); + ); + }); + + testWidgetsOnAndroid('deletes emojis with BACKSPACE', (tester) async { + await _pumpSuperTextFieldEmojiTest( + tester, + configuration: SuperTextFieldPlatformConfiguration.android, + text: 'This is a text with an emoji 🐢', + ); + + // Place the caret at the end of the text field. + await tester.placeCaretInSuperTextField(SuperTextFieldInspector.findText().length); + + // Press backspace to delete the previous character. + await tester.pressBackspace(); + + // Ensure the emoji is deleted. + expect(SuperTextFieldInspector.findText().toPlainText(), 'This is a text with an emoji '); }); }); }); @@ -446,16 +465,17 @@ void main() { Future _pumpSuperTextFieldEmojiTest( WidgetTester tester, { - required String text + required String text, + SuperTextFieldPlatformConfiguration configuration = SuperTextFieldPlatformConfiguration.desktop, }) async { final controller = AttributedTextEditingController( - text: AttributedText(text: text), + text: AttributedText(text), ); await tester.pumpWidget( MaterialApp( - home: Scaffold( + home: Scaffold( body: SuperTextField( - configuration: SuperTextFieldPlatformConfiguration.desktop, + configuration: configuration, textController: controller, textStyleBuilder: (_) => const TextStyle(fontSize: 16), ), diff --git a/super_editor/test/super_textfield/super_textfield_estimated_line_height_test.dart b/super_editor/test/super_textfield/super_textfield_estimated_line_height_test.dart index 4d7c7df312..f2f384799b 100644 --- a/super_editor/test/super_textfield/super_textfield_estimated_line_height_test.dart +++ b/super_editor/test/super_textfield/super_textfield_estimated_line_height_test.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:super_editor/super_editor.dart'; -import '../test_tools.dart'; - void main() { group('SuperTextField', () { testWidgetsOnArbitraryDesktop('computes line height for empty field', (tester) async { @@ -21,7 +20,7 @@ void main() { final heightWithHintText = tester.getSize(find.byType(SuperTextField)).height; // Change the text, this should recompute viewport height. - controller.text = AttributedText(text: 'Leave a message'); + controller.text = AttributedText('Leave a message'); await tester.pumpAndSettle(); // When the text field has content the line height should be the true line height. diff --git a/super_editor/test/super_textfield/super_textfield_gesture_scrolling_test.dart b/super_editor/test/super_textfield/super_textfield_gesture_scrolling_test.dart new file mode 100644 index 0000000000..ea8a0bb818 --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_gesture_scrolling_test.dart @@ -0,0 +1,379 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_text_field_test.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +void main() { + group('SuperTextField', () { + testWidgetsOnAllPlatforms('single-line jumps scroll position horizontally as the user types', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("ABCDEFG"), + ); + + // Pump the widget tree with a SuperTextField with a maxWidth smaller + // than the text width + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 1, + maxWidth: 50, + ); + + // Move selection to the end of the text + // TODO: change to simulate user input when IME simulation is available + controller.selection = const TextSelection.collapsed(offset: 7); + await tester.pumpAndSettle(); + + // Position at the end of the viewport + final viewportRight = tester.getBottomRight(find.byType(SuperTextField)).dx; + + // Position at the end of the text + final textRight = tester.getBottomRight(find.byType(SuperText)).dx; + + // Ensure the text field scrolled its content horizontally + expect(textRight, lessThanOrEqualTo(viewportRight)); + }); + + testWidgetsOnAllPlatforms('multi-line jumps scroll position vertically as the user types', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("A\nB\nC\nD"), + ); + + // Pump the widget tree with a SuperTextField with a maxHeight smaller + // than the text heght + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 20, + ); + + // Move selection to the end of the text + // TODO: change to simulate user input when IME simulation is available + controller.selection = const TextSelection.collapsed(offset: 7); + await tester.pumpAndSettle(); + + // Position at the end of the viewport + final viewportBottom = tester.getBottomRight(find.byType(SuperTextField)).dy; + + // Position at the end of the text + final textBottom = tester.getBottomRight(find.byType(SuperText)).dy; + + // Ensure the text field scrolled its content vertically + expect(textBottom, lessThanOrEqualTo(viewportBottom)); + }); + + testWidgetsOnAllPlatforms( + "multi-line jumps scroll position vertically when selection extent moves above or below the visible viewport area", + (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("First line\nSecond Line\nThird Line\nFourth Line"), + ); + + // Pump the widget tree with a SuperTextField which is two lines tall. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 40, + ); + + // Move selection to the end of the text. + // This will scroll the text field to the end. + controller.selection = const TextSelection.collapsed(offset: 45); + await tester.pumpAndSettle(); + + // Ensure the text field has scrolled. + expect( + SuperTextFieldInspector.findScrollOffset(), + greaterThan(0.0), + ); + + // Place the caret at the beginning of the text. + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pumpAndSettle(); + + // Ensure the text field scrolled to the top. + expect( + SuperTextFieldInspector.findScrollOffset(), + 0.0, + ); + }); + + testWidgetsOnAllPlatforms("multi-line doesn't jump scroll position vertically when selection extent is visible", + (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("First line\nSecond Line\nThird Line\nFourth Line"), + ); + + // Pump the widget tree with a SuperTextField which is two lines tall. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 40, + ); + + // Move selection to the end of the text. + // This will scroll the text field to the end. + controller.selection = const TextSelection.collapsed(offset: 45); + await tester.pumpAndSettle(); + + final scrollOffsetBefore = SuperTextFieldInspector.findScrollOffset(); + + // Place the caret at "Third| Line". + // As we have room for two lines, this line is already visible, + // and thus shouldn't cause the text field to scroll. + controller.selection = const TextSelection.collapsed(offset: 28); + await tester.pumpAndSettle(); + + // Ensure the content didn't scroll. + expect( + SuperTextFieldInspector.findScrollOffset(), + scrollOffsetBefore, + ); + }); + + testWidgetsOnDesktop("doesn't scroll vertically when maxLines is null", (tester) async { + // We use some padding because it affects the viewport height calculation. + const verticalPadding = 6.0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 300), + child: SuperDesktopTextField( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: verticalPadding), + minLines: 1, + maxLines: null, + textController: AttributedTextEditingController( + text: AttributedText("SuperTextField"), + ), + textStyleBuilder: (_) => const TextStyle( + fontSize: 14, + height: 1, + fontFamily: 'Roboto', + ), + ), + ), + ), + ), + ); + await tester.pump(); + + // In the running app, the estimated line height and actual line height differ. + // This test ensures that we account for that. Ideally, this test would check that the scrollview doesn't scroll. + // However, in test suites, the estimated and actual line heights are always identical. + // Therefore, this test ensures that we add up the appropriate dimensions, + // rather than verify the scrollview's max scroll extent. + + final viewportHeight = tester.getRect(find.byType(SuperTextFieldScrollview)).height; + + final layoutState = + (find.byType(SuperDesktopTextField).evaluate().single as StatefulElement).state as SuperDesktopTextFieldState; + final contentHeight = layoutState.textLayout.getLineHeightAtPosition(const TextPosition(offset: 0)); + + // Vertical padding is added to both top and bottom + final totalHeight = contentHeight + (verticalPadding * 2); + + // Ensure the viewport is big enough so the text doesn't scroll vertically + expect(viewportHeight, greaterThanOrEqualTo(totalHeight)); + }); + + testWidgetsOnDesktop("stops momentum on tap down with trackpad and doesn't place the caret", (tester) async { + // Generate a long text to have enough scrollable content. + final text = [ + for (int i = 1; i <= 1000; i++) // + 'Line $i', + ]; + + final controller = AttributedTextEditingController( + text: AttributedText(text.join('\n')), + ); + + // Pump the widget tree with a SuperTextField with a maxHeight smaller + // than the text height. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 20, + ); + + // Ensure the textfield initially has no selection. + expect(SuperTextFieldInspector.findSelection(), TextRange.empty); + + // Fling scroll the textfield with the trackpad. + final scrollGesture = await tester.startGesture( + tester.getCenter(find.byType(SuperTextField)), + kind: PointerDeviceKind.trackpad, + ); + await scrollGesture.moveBy(const Offset(0, -1000)); + await scrollGesture.up(); + + // Pump a few frames of momentum. + for (int i = 0; i < 25; i += 1) { + await tester.pump(const Duration(milliseconds: 16)); + } + final scrollOffsetInMiddleOfMomentum = SuperTextFieldInspector.findScrollOffset(); + + // Ensure the textfield scrolled. + expect(scrollOffsetInMiddleOfMomentum, greaterThan(0.0)); + + // Tap down to stop the momentum. + final gesture = await tester.startGesture( + tester.getCenter(find.byType(SuperTextField)), + kind: PointerDeviceKind.trackpad, + ); + + // Let any remaining momentum run (there shouldn't be any). + await tester.pumpAndSettle(); + + // Ensure that the momentum stopped exactly where we tapped. + expect(scrollOffsetInMiddleOfMomentum, SuperTextFieldInspector.findScrollOffset()); + + // Release the pointer. + await gesture.up(); + await tester.pump(); + + // Ensure the selection didn't change. + expect(SuperTextFieldInspector.findSelection(), TextRange.empty); + }); + + testWidgetsOnMobile("multi-line is vertically scrollable when text spans more lines than maxLines", (tester) async { + const initialText = "The first line of text in the field\n" + "The second line of text in the field\n" + "The third line of text in the field"; + final controller = AttributedTextEditingController( + text: AttributedText(initialText), + ); + + // Pump the widget tree with a SuperTextField with a maxHeight of 2 lines + // of text, which should overflow considering there are 3 lines of text. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 40, + ); + + // Ensure the text field has not yet scrolled. + var textTop = tester.getTopRight(find.byType(SuperTextField)).dy; + var viewportTop = tester.getTopRight(find.byType(SuperText)).dy; + expect(textTop, moreOrLessEquals(viewportTop)); + + // Scroll down to reveal the last line of text. + await tester.drag(find.byType(SuperTextField), const Offset(0, -1000.0)); + await tester.pumpAndSettle(); + + // Ensure the text field has scrolled to the bottom. + var textBottom = tester.getBottomRight(find.byType(SuperTextField)).dy; + var viewportBottom = tester.getBottomRight(find.byType(SuperText)).dy; + expect(textBottom, moreOrLessEquals(viewportBottom)); + + // Scroll back up to the top of the text field. + await tester.drag(find.byType(SuperTextField), const Offset(0, 1000.0)); + await tester.pumpAndSettle(); + + // Ensure the text field has scrolled back to the top. + textTop = tester.getTopRight(find.byType(SuperTextField)).dy; + viewportTop = tester.getTopRight(find.byType(SuperText)).dy; + expect(textTop, moreOrLessEquals(viewportTop)); + }); + + testWidgetsOnDesktop("multi-line is vertically scrollable when text spans more lines than maxLines", + (tester) async { + const initialText = "The first line of text in the field\n" + "The second line of text in the field\n" + "The third line of text in the field"; + final controller = AttributedTextEditingController( + text: AttributedText(initialText), + ); + + // Pump the widget tree with a SuperTextField with a maxHeight of 2 lines + // of text, which should overflow considering there are 3 lines of text. + await _pumpTestApp( + tester, + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 40, + ); + + // Ensure the text field has not yet scrolled. + var textTop = tester.getTopRight(find.byType(SuperTextField)).dy; + var viewportTop = tester.getTopRight(find.byType(SuperText)).dy; + expect(textTop, moreOrLessEquals(viewportTop)); + + // Scroll down to reveal the last line of text. + await tester.drag( + find.byType(SuperTextField), + const Offset(0, -1000.0), + kind: PointerDeviceKind.trackpad, + ); + await tester.pumpAndSettle(); + + // Ensure the text field has scrolled to the bottom. + var textBottom = tester.getBottomRight(find.byType(SuperTextField)).dy; + var viewportBottom = tester.getBottomRight(find.byType(SuperText)).dy; + expect(textBottom, moreOrLessEquals(viewportBottom)); + + // Scroll back up to the top of the text field. + await tester.drag( + find.byType(SuperTextField), + const Offset(0, 1000.0), + kind: PointerDeviceKind.trackpad, + ); + await tester.pumpAndSettle(); + + // Ensure the text field has scrolled back to the top. + textTop = tester.getTopRight(find.byType(SuperTextField)).dy; + viewportTop = tester.getTopRight(find.byType(SuperText)).dy; + expect(textTop, moreOrLessEquals(viewportTop)); + }); + }); +} + +Future _pumpTestApp( + WidgetTester tester, { + required AttributedTextEditingController textController, + required int minLines, + required int maxLines, + double? maxWidth, + double? maxHeight, + EdgeInsets? padding, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth ?? double.infinity, + maxHeight: maxHeight ?? double.infinity, + ), + child: SuperTextField( + textController: textController, + lineHeight: 20, + textStyleBuilder: (_) => const TextStyle(fontSize: 20, color: Colors.black), + minLines: minLines, + maxLines: maxLines, + padding: padding, + ), + ), + ), + ), + ); + + // The first frame might have a zero viewport height. Pump a second frame to account for the final viewport size. + await tester.pump(); +} diff --git a/super_editor/test/super_textfield/super_textfield_gestures_interaction_overrides_test.dart b/super_editor/test/super_textfield/super_textfield_gestures_interaction_overrides_test.dart new file mode 100644 index 0000000000..438d42889c --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_gestures_interaction_overrides_test.dart @@ -0,0 +1,432 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/document_gestures_interaction_overrides.dart'; +import 'package:super_editor/super_text_field.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group('SuperTextField gesture interaction overrides > ', () { + group('single tap >', () { + group('single handler >', () { + testWidgetsOnAllPlatforms('can be customized', (tester) async { + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler]); + + // Tap on the text field. + await tester.placeCaretInSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasTapDownHandled, isTrue); + expect(handler.wasTapUpHandled, isTrue); + expect(handler.wasDoubleTapDownHandled, isFalse); + expect(handler.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing the caret was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + + group('multiple handlers >', () { + testWidgetsOnAllPlatforms('run seach handler until the gesture is handled', (tester) async { + final noOpHandler = _NoOpTextFieldTapHandler(); + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); + + // Tap on the text field. + await tester.placeCaretInSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasTapDownHandled, isTrue); + expect(handler.wasTapUpHandled, isTrue); + expect(handler.wasDoubleTapDownHandled, isFalse); + expect(handler.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing the caret was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + + testWidgetsOnAllPlatforms('stops when a handler handles the gesture', (tester) async { + final handler1 = _SuperTextFieldTestTapHandler(); + final handler2 = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler1, handler2]); + + // Tap on the text field. + await tester.placeCaretInSuperTextField(0); + + // Ensure the first tap handler was called. + expect(handler1.wasTapDownHandled, isTrue); + expect(handler1.wasTapUpHandled, isTrue); + expect(handler1.wasDoubleTapDownHandled, isFalse); + expect(handler1.wasTripleTapDownHandled, isFalse); + + // Ensure the second tap handler was not called. + expect(handler2.wasTapDownHandled, isFalse); + expect(handler2.wasTapUpHandled, isFalse); + expect(handler2.wasDoubleTapDownHandled, isFalse); + expect(handler2.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing the caret was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + }); + + group('double tap >', () { + group('single handler >', () { + testWidgetsOnAllPlatforms('can be customized', (tester) async { + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler]); + + await tester.doubleTapAtSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasDoubleTapDownHandled, isTrue); + expect(handler.wasDoubleTapUpHandled, isTrue); + expect(handler.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing the caret was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + + group('multiple handlers > ', () { + testWidgetsOnAllPlatforms('run each handler until the gesture is handled', (tester) async { + final noOpHandler = _NoOpTextFieldTapHandler(); + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); + + await tester.doubleTapAtSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasDoubleTapDownHandled, isTrue); + expect(handler.wasDoubleTapUpHandled, isTrue); + expect(handler.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + + testWidgetsOnAllPlatforms('stops when a handler handles the gesture', (tester) async { + final handler1 = _SuperTextFieldTestTapHandler(); + final handler2 = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler1, handler2]); + + await tester.doubleTapAtSuperTextField(0); + + // Ensure the first tap handler was called. + expect(handler1.wasDoubleTapDownHandled, isTrue); + expect(handler1.wasDoubleTapUpHandled, isTrue); + expect(handler1.wasTripleTapDownHandled, isFalse); + + // Ensure the second tap handler was not called. + expect(handler2.wasDoubleTapDownHandled, isFalse); + expect(handler2.wasDoubleTapUpHandled, isFalse); + expect(handler2.wasTripleTapDownHandled, isFalse); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + }); + + group('triple tap', () { + group('single handler > ', () { + testWidgetsOnAllPlatforms('can be customized', (tester) async { + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler]); + + // Triple tap on the text field. + await tester.tripleTapAtSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasTripleTapDownHandled, isTrue); + expect(handler.wasTripleTapUpHandled, isTrue); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + + group('multiple handlers > ', () { + testWidgetsOnAllPlatforms('run each handler until the gesture is handled', (tester) async { + final noOpHandler = _NoOpTextFieldTapHandler(); + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); + + await tester.tripleTapAtSuperTextField(0); + + // Ensure the custom tap handler was called. + expect(handler.wasTripleTapDownHandled, isTrue); + expect(handler.wasTripleTapUpHandled, isTrue); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + + testWidgetsOnAllPlatforms('stops when a handler handles the gesture', (tester) async { + final handler1 = _SuperTextFieldTestTapHandler(); + final handler2 = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler1, handler2]); + + await tester.tripleTapAtSuperTextField(0); + + // Ensure the first tap handler was called. + expect(handler1.wasTripleTapDownHandled, isTrue); + expect(handler1.wasTripleTapUpHandled, isTrue); + + // Ensure the second tap handler was not called. + expect(handler2.wasTripleTapDownHandled, isFalse); + expect(handler2.wasTripleTapUpHandled, isFalse); + + // Ensure the default behavior of placing an expanded selection + // was not called. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); + }); + }); + }); + + group('secondary tap >', () { + group('single handler >', () { + testWidgetsOnDesktop('can be customized', (tester) async { + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler]); + + // Tap on the text field. + await tester.tapAtSuperTextField(0, buttons: kSecondaryMouseButton); + + // Ensure the custom tap handler was called. + expect(handler.wasSecondaryTapDownHandled, isTrue); + expect(handler.wasSecondaryTapUpHandled, isTrue); + expect(handler.wasTapUpHandled, isFalse); + expect(handler.wasDoubleTapDownHandled, isFalse); + expect(handler.wasTripleTapDownHandled, isFalse); + }); + }); + + group('multiple handlers >', () { + testWidgetsOnDesktop('run seach handler until the gesture is handled', (tester) async { + final noOpHandler = _NoOpTextFieldTapHandler(); + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [noOpHandler, handler]); + + // Tap on the text field. + await tester.tapAtSuperTextField(0, buttons: kSecondaryMouseButton); + + // Ensure the custom tap handler was called. + expect(handler.wasSecondaryTapDownHandled, isTrue); + expect(handler.wasSecondaryTapUpHandled, isTrue); + expect(handler.wasTapUpHandled, isFalse); + expect(handler.wasDoubleTapDownHandled, isFalse); + expect(handler.wasTripleTapDownHandled, isFalse); + }); + + testWidgetsOnDesktop('stops when a handler handles the gesture', (tester) async { + final handler1 = _SuperTextFieldTestTapHandler(); + final handler2 = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler1, handler2]); + + // Tap on the text field. + await tester.tapAtSuperTextField(0, buttons: kSecondaryMouseButton); + + // Ensure the first tap handler was called. + expect(handler1.wasSecondaryTapDownHandled, isTrue); + expect(handler1.wasSecondaryTapUpHandled, isTrue); + expect(handler1.wasTapUpHandled, isFalse); + expect(handler1.wasDoubleTapDownHandled, isFalse); + expect(handler1.wasTripleTapDownHandled, isFalse); + + // Ensure the second tap handler was not called. + expect(handler2.wasSecondaryTapDownHandled, isFalse); + expect(handler2.wasSecondaryTapUpHandled, isFalse); + expect(handler2.wasTapUpHandled, isFalse); + expect(handler2.wasDoubleTapDownHandled, isFalse); + expect(handler2.wasTripleTapDownHandled, isFalse); + }); + }); + }); + + testWidgetsOnDesktop('allows customizing mouse cursor', (tester) async { + final handler = _SuperTextFieldTestTapHandler(); + + await _pumpSingleFieldTestApp(tester, tapHandlers: [handler]); + + // Start a gesture outside SuperTextField bounds. + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + + // Ensure the cursor type is 'basic' when not hovering SuperTextField. + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); + + // Hover over the text field. + await gesture.moveTo(tester.getCenter(find.byType(SuperTextField))); + await tester.pump(); + + // Ensure the cursor type was configured by the custom handler. + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.move); + }); + }); +} + +/// Pump a test app with a single [SuperTextField] that has the given [tapHandlers]. +Future _pumpSingleFieldTestApp( + WidgetTester tester, { + required List tapHandlers, +}) async { + final textController = AttributedTextEditingController( + text: AttributedText('This is a text field'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(20.0), + child: SizedBox( + width: 300, + child: SuperTextField( + textController: textController, + lineHeight: 16, + tapHandlers: tapHandlers, + ), + ), + ), + ), + ), + ); +} + +/// A [SuperTextFieldTapHandler] that records whether each tap was handled and +/// always specifies [SystemMouseCursors.move] as the mouse cursor. +/// +/// This handler prevents any other handlers from running, because it always +/// returns [TapHandlingInstruction.halt]. +class _SuperTextFieldTestTapHandler extends SuperTextFieldTapHandler { + bool get wasTapDownHandled => _wasTapDownHandled; + bool _wasTapDownHandled = false; + + bool get wasTapUpHandled => _wasTapUpHandled; + bool _wasTapUpHandled = false; + + bool get wasDoubleTapDownHandled => _wasDoubleTapDownHandled; + bool _wasDoubleTapDownHandled = false; + + bool get wasDoubleTapUpHandled => _wasDoubleTapUpHandled; + bool _wasDoubleTapUpHandled = false; + + bool get wasTripleTapDownHandled => _wasTripleTapDownHandled; + bool _wasTripleTapDownHandled = false; + + bool get wasTripleTapUpHandled => _wasTripleTapUpHandled; + bool _wasTripleTapUpHandled = false; + + bool get wasSecondaryTapDownHandled => _wasSecondaryTapDownHandled; + bool _wasSecondaryTapDownHandled = false; + + bool get wasSecondaryTapUpHandled => _wasSecondaryTapUpHandled; + bool _wasSecondaryTapUpHandled = false; + + @override + MouseCursor? mouseCursorForContentHover(SuperTextFieldGestureDetails details) { + return SystemMouseCursors.move; + } + + @override + TapHandlingInstruction onTapDown(SuperTextFieldGestureDetails details) { + _wasTapDownHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onTapUp(SuperTextFieldGestureDetails details) { + _wasTapUpHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTapDown(SuperTextFieldGestureDetails details) { + _wasDoubleTapDownHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTapUp(SuperTextFieldGestureDetails details) { + _wasDoubleTapUpHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onTripleTapDown(SuperTextFieldGestureDetails details) { + _wasTripleTapDownHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onTripleTapUp(SuperTextFieldGestureDetails details) { + _wasTripleTapUpHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onSecondaryTapDown(SuperTextFieldGestureDetails details) { + _wasSecondaryTapDownHandled = true; + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onSecondaryTapUp(SuperTextFieldGestureDetails details) { + _wasSecondaryTapUpHandled = true; + return TapHandlingInstruction.halt; + } +} + +/// A [SuperTextFieldTapHandler] that does nothing. +class _NoOpTextFieldTapHandler extends SuperTextFieldTapHandler {} diff --git a/super_editor/test/super_textfield/super_textfield_gestures_test.dart b/super_editor/test/super_textfield/super_textfield_gestures_test.dart index f074b98d76..5159a8dd85 100644 --- a/super_editor/test/super_textfield/super_textfield_gestures_test.dart +++ b/super_editor/test/super_textfield/super_textfield_gestures_test.dart @@ -1,11 +1,11 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; - -import '../test_tools.dart'; -import 'super_textfield_inspector.dart'; +import 'package:super_editor/super_text_field_test.dart'; void main() { group('SuperTextField gestures', () { @@ -157,6 +157,38 @@ void main() { const TextSelection.collapsed(offset: 0), ); }); + + testWidgetsOnAllPlatforms("when a single-line text field contains scrollable text", (tester) async { + // The purpose of this test is to ensure that when placing the caret in a scrollable + // single-line text field (a text field with more text than can fit), the text field + // doesn't erratically move the caret somewhere else due to buggy scroll calculations. + await _pumpSingleLineTextField( + tester, + controller: AttributedTextEditingController( + // Display enough text to ensure the text field is scrollable. + text: AttributedText( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna.", + ), + ), + ); + + // Ensure we begin with no scroll offset. + expect(SuperTextFieldInspector.isScrolledToBeginning(), isTrue); + + // Place the caret at an arbitrary offset other than zero (so that we can + // catch any bug where the caret ends up being placed too far upstream after + // the tap). + await tester.placeCaretInSuperTextField(10); + + // Ensure the caret was placed at the desired text position. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 10), + ); + + // Ensure that placing the caret didn't cause the scroll view to jump anywhere. + expect(SuperTextFieldInspector.isScrolledToBeginning(), isTrue); + }); }); group("on desktop", () { @@ -180,6 +212,288 @@ void main() { await gesture.up(); await gesture.removePointer(); }); + + testWidgetsOnDesktop("scrolls the content when dragging with trackpad down", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText(''' +SuperTextField with a +content that spans +multiple lines +of text to test +scrolling with +a trackpad +'''), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 300), + child: SuperTextField( + textController: controller, + maxLines: 2, + ), + ), + ), + ), + ); + + // Double tap to select "SuperTextField". + await tester.doubleTapAtSuperTextField(0); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 0, extentOffset: 14), + ); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Ensure the textfield didn't start scrolled. + expect(scrollState.position.pixels, 0.0); + + // Simulate the user starting a gesture with two fingers + // somewhere close to the beginning of the text. + final gesture = await tester.startGesture( + tester.getTopLeft(find.byType(SuperTextField)) + const Offset(10, 10), + kind: PointerDeviceKind.trackpad, + ); + await tester.pump(); + + // Move a distance big enough to ensure a pan gesture. + await gesture.moveBy(const Offset(0, kPanSlop)); + await tester.pump(); + + // Drag up. + await gesture.moveBy(const Offset(0, -300)); + await tester.pump(); + + // Ensure the content scrolled to the end of the content. + expect(scrollState.position.pixels, moreOrLessEquals(80.0)); + + // Ensure that the selection didn't change. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 0, extentOffset: 14), + ); + }); + + testWidgetsOnDesktop("scrolls the content when dragging with trackpad up", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText(''' +SuperTextField with a +content that spans +multiple lines +of text to test +scrolling with +a trackpad +'''), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 300), + child: SuperTextField( + textController: controller, + maxLines: 2, + ), + ), + ), + ), + ); + + // Double tap to select "SuperTextField". + await tester.doubleTapAtSuperTextField(0); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 0, extentOffset: 14), + ); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Jump to the end of the textfield. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent); + await tester.pump(); + + // Simulate the user starting a gesture with two fingers + // somewhere close to the end of the text. + final gesture = await tester.startGesture( + tester.getBottomLeft(find.byType(SuperTextField)) + const Offset(10, -1), + kind: PointerDeviceKind.trackpad, + ); + await tester.pump(); + + // Move a distance big enough to ensure a pan gesture. + await gesture.moveBy(const Offset(0, kPanSlop)); + await tester.pump(); + + // Drag down. + await gesture.moveBy(const Offset(0, 300)); + await tester.pump(); + + // Ensure the content scrolled to the beginning of the content. + expect(scrollState.position.pixels, 0.0); + + // Ensure that the selection didn't change. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 0, extentOffset: 14), + ); + }); + + testWidgetsOnDesktop("scrolls the content when dragging the scrollbar down", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText(''' +SuperTextField with a +content that spans +multiple lines +of text to test +scrolling with +a scrollbar +'''), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 300), + child: SuperTextField( + textController: controller, + maxLines: 4, + ), + ), + ), + ), + ); + + // Double tap to select "SuperTextField". + await tester.doubleTapAtSuperTextField(0); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 0, extentOffset: 14), + ); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Ensure the textfield didn't start scrolled. + expect(scrollState.position.pixels, 0.0); + + // Find the approximate position of the scrollbar thumb. + final thumbLocation = tester.getTopRight(find.byType(SuperTextField)) + const Offset(-10, 10); + + // Hover to make the thumb visible with a duration long enough to run the fade in animation. + final testPointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(testPointer.hover(thumbLocation, timeStamp: const Duration(seconds: 1))); + await tester.pumpAndSettle(); + + // Press the thumb. + await tester.sendEventToBinding(testPointer.down(thumbLocation)); + await tester.pump(kTapMinTime); + + // Move the thumb down a distance equals to the max scroll extent. + await tester.sendEventToBinding(testPointer.move(thumbLocation + const Offset(0, 48))); + await tester.pump(); + + // Release the pointer. + await tester.sendEventToBinding(testPointer.up()); + await tester.pump(); + + // Ensure the content scrolled to the end of the content. + expect(scrollState.position.pixels, moreOrLessEquals(scrollState.position.maxScrollExtent)); + + // Ensure that the selection didn't change. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 0, extentOffset: 14), + ); + }); + + testWidgetsOnDesktop("scrolls the content when dragging the scrollbar up", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText(''' +SuperTextField with a +content that spans +multiple lines +of text to test +scrolling with +a scrollbar +'''), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 300), + child: SuperTextField( + textController: controller, + maxLines: 4, + ), + ), + ), + ), + ); + + // Double tap to select "SuperTextField". + await tester.doubleTapAtSuperTextField(0); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 0, extentOffset: 14), + ); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Jump to the end of the textfield. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent); + await tester.pump(); + + // Find the approximate position of the scrollbar thumb. + final thumbLocation = tester.getBottomRight(find.byType(SuperTextField)) - const Offset(10, 10); + + // Hover to make the thumb visible with a duration long enough to run the fade in animation. + final testPointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(testPointer.hover(thumbLocation, timeStamp: const Duration(seconds: 1))); + await tester.pumpAndSettle(); + + // Press the thumb. + await tester.sendEventToBinding(testPointer.down(thumbLocation)); + await tester.pump(kTapMinTime); + + // Move the thumb up a distance equals to the max scroll extent. + await tester.sendEventToBinding(testPointer.move(thumbLocation - const Offset(0, 48))); + await tester.pump(); + + // Release the pointer. + await tester.sendEventToBinding(testPointer.up()); + await tester.pump(); + + // Ensure the content scrolled to the beginning of the content. + expect(scrollState.position.pixels, 0.0); + + // Ensure that the selection didn't change. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 0, extentOffset: 14), + ); + }); }); group("on mobile", () { @@ -222,7 +536,7 @@ void main() { expect(SuperTextFieldInspector.findSelection()!.isValid, true); }); - testWidgetsOnMobile("tap down in focused field moves the caret", (tester) async { + testWidgetsOnMobile("tap down in focused field does nothing", (tester) async { await _pumpTestApp(tester); // Tap in empty space to place the caret at the end of the text. @@ -236,10 +550,138 @@ void main() { addTearDown(() => gesture.removePointer()); await tester.pumpAndSettle(); + // Ensure the caret didn't move. + expect(SuperTextFieldInspector.findSelection()!.extent.offset, 3); + }); + + testWidgetsOnMobile("tap up in focused field moves the caret", (tester) async { + await _pumpTestApp(tester); + + // Tap in empty space to place the caret at the end of the text. + await tester.tapAt(tester.getBottomRight(find.byType(SuperTextField)) - const Offset(10, 10)); + // Without this 'delay' onTapDown is not called the second time. + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + expect(SuperTextFieldInspector.findSelection()!.extent.offset, greaterThan(0)); + + // Tap DOWN at beginning of text to move the caret. + final gesture = await tester.startGesture(tester.getTopLeft(find.byType(SuperTextField))); + await tester.pump(); + await gesture.up(); + await tester.pump(kTapTimeout); + // Ensure the caret moved to the beginning of the text. expect(SuperTextFieldInspector.findSelection()!.extent.offset, 0); }); + // mobile only because precise input (mouse) doesn't use touch slop + testWidgetsOnMobile("MediaQuery gesture settings are respected", (tester) async { + bool horizontalDragStartCalled = false; + final controller = AttributedTextEditingController( + text: AttributedText('a b c'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GestureDetector( + onHorizontalDragStart: (d) { + horizontalDragStartCalled = true; + }, + child: Builder(builder: (context) { + // Custom gesture settings that ensure same value for touchSlop + // and panSlop + final data = MediaQuery.of(context).copyWith( + gestureSettings: const _GestureSettings( + panSlop: 18, + touchSlop: 18, + ), + ); + return MediaQuery( + data: data, + child: SuperTextField( + textController: controller, + ), + ); + }), + ), + ), + ), + ); + + // Tap down and up so the field is focused. + await tester.placeCaretInSuperTextField(0); + + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 0), + ); + + // The following gesture should trigger the selection PanGestureRecognizer instead + // of the HorizontalDragGestureRecognizer, thereby moving the caret. + final gesture = await tester.startGesture(tester.getTopLeft(find.byType(SuperTextField))); + addTearDown(() => gesture.removePointer()); + // This first move is just enough to surpass the touch slop, which then + // triggers _onPanStart, but doesn't impact the text selection. + await gesture.moveBy(const Offset(19, 0)); + // This second move runs _onPanUpdate, which does change the text selection. + await gesture.moveBy(const Offset(1, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(horizontalDragStartCalled, isFalse); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 1), + ); + + // Pump an update with a larger pan slop. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GestureDetector( + onHorizontalDragStart: (d) { + horizontalDragStartCalled = true; + }, + child: Builder(builder: (context) { + // Gesture settings that mimic flutter default where + // panSlop = 2x touchSlop + final data = MediaQuery.of(context).copyWith( + gestureSettings: const _GestureSettings( + touchSlop: 18, + panSlop: 36, + ), + ); + return MediaQuery( + data: data, + child: SuperTextField( + textController: controller, + ), + ); + }), + ), + ), + ), + ); + + // The following gesture, which moves as much as the previous gesture, should + // have no effect on the selection because the pan slop was increased. + final gesture2 = await tester.startGesture(tester.getTopLeft(find.byType(SuperTextField))); + addTearDown(() => gesture2.removePointer()); + await gesture2.moveBy(const Offset(19, 0)); + await gesture2.up(); + await tester.pumpAndSettle(); + + // Ensure that the selection didn't change because the larger pan slop prevented + // the selection pan from winning in the gesture arena. Also, ensure that because + // the selection pan didn't take the gesture, the horizontal drag detector won + // out, instead. + expect(horizontalDragStartCalled, isTrue); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 1), + ); + }); + testWidgetsOnMobile("tap up shows the keyboard if the field already has focus", (tester) async { await _pumpTestApp(tester); @@ -299,6 +741,88 @@ void main() { // Ensure we are connected again. expect(controller.isAttachedToIme, true); }); + + testWidgetsOnMobile("tap up does not shows the toolbar if the field does not have focus", (tester) async { + await _pumpTestAppWithFakeToolbar(tester); + + // Tap down and up so the field is focused. + await tester.tapAt(tester.getTopLeft(find.byKey(_textFieldKey))); + await tester.pumpAndSettle(); + + // Ensure the toolbar isn't visible. + expect(find.byKey(_popoverToolbarKey), findsNothing); + }); + + testWidgetsOnIos("tap up shows the toolbar if the field already has focus", (tester) async { + await _pumpTestAppWithFakeToolbar(tester); + + // Tap down and up so the field is focused. + await tester.tapAt(tester.getTopLeft(find.byKey(_textFieldKey))); + await tester.pumpAndSettle(); + + // Ensure the toolbar isn't visible. + expect(find.byKey(_popoverToolbarKey), findsNothing); + + // Avoid a double tap. + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); + + // Tap down and up again. + await tester.tapAt(tester.getTopLeft(find.byKey(_textFieldKey))); + await tester.pumpAndSettle(); + + // Ensure the toolbar is visible. + expect(find.byKey(_popoverToolbarKey), findsOneWidget); + }); + + testWidgetsOnAndroid("tap up does not shows the toolbar if the field already has focus", (tester) async { + await _pumpTestAppWithFakeToolbar(tester); + + // Tap down and up so the field is focused. + await tester.tapAt(tester.getTopLeft(find.byKey(_textFieldKey))); + await tester.pumpAndSettle(); + + // Ensure the toolbar isn't visible. + expect(find.byKey(_popoverToolbarKey), findsNothing); + + // Avoid a double tap. + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); + + // Tap down and up again. + await tester.tapAt(tester.getTopLeft(find.byKey(_textFieldKey))); + await tester.pumpAndSettle(); + + // Ensure the toolbar is visible. + expect(find.byKey(_popoverToolbarKey), findsNothing); + }); + }); + + testWidgetsOnAllPlatforms("loses focus when user taps outside in a TapRegion", (tester) async { + // Note: the our test scaffold in this suite includes a TapRegion + // that removes focus from the field when tapping outside. This test + // depends upon that TapRegion. + await _pumpTestApp(tester); + await tester.pumpAndSettle(); + + // Give the text field focus. + await tester.tapAt(tester.getCenter(find.byType(SuperTextField))); + await tester.pump(kTapMinTime); + + // Ensure that we start with focus. + expect( + SuperTextFieldInspector.findSelection()!.extentOffset, + greaterThan(-1), + ); + + // Tap outside the text field. + await tester.tapAt(tester.getCenter(find.byType(Scaffold))); + await tester.pump(kTapMinTime); + await tester.pumpAndSettle(); + + // Ensure that focus is gone. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: -1), + ); }); }); } @@ -309,18 +833,130 @@ Future _pumpTestApp( EdgeInsets? padding, TextAlign? textAlign, }) async { + final textFieldFocusNode = FocusNode(); + const tapRegionGroupdId = "test_super_text_field"; + await tester.pumpWidget( MaterialApp( home: Scaffold( - body: SuperTextField( - padding: padding, - textAlign: textAlign ?? TextAlign.left, - textController: controller ?? - AttributedTextEditingController( - text: AttributedText(text: 'abc'), + body: ColoredBox( + color: Colors.green, + child: TapRegion( + groupId: tapRegionGroupdId, + onTapOutside: (_) { + // Unfocus on tap outside so that we're sure that all gesture tests + // pass when using TapRegion's for focus, because apps should be able + // to do that. + textFieldFocusNode.unfocus(); + }, + child: SizedBox.expand( + child: Align( + alignment: Alignment.topCenter, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + ), + child: SuperTextField( + focusNode: textFieldFocusNode, + tapRegionGroupId: tapRegionGroupdId, + padding: padding, + textAlign: textAlign ?? TextAlign.left, + textController: controller ?? + AttributedTextEditingController( + text: AttributedText('abc'), + ), + ), + ), ), + ), + ), ), ), ), ); } + +Future _pumpSingleLineTextField( + WidgetTester tester, { + AttributedTextEditingController? controller, + double? width, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: width ?? 400, + child: DecoratedBox( + decoration: BoxDecoration(border: Border.all(color: Colors.red)), + child: SuperTextField( + textController: controller, + // We use significant padding to catch bugs related to projecting offsets + // between the text layout and the scrolling viewport. + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 48), + minLines: 1, + maxLines: 1, + inputSource: TextInputSource.ime, + ), + ), + ), + ), + ), + ), + ); +} + +/// Pump a test app with either a [SuperAndroidTextField] or a [SuperIOSTextField] with a fake toolbar. +/// +/// The textfield is bound to [_textFieldKey] and the toolbar is bound to [_popoverToolbarKey]. +/// +/// This is used because we cannot configure the toolbar with [SuperTextField]'s public API. +Future _pumpTestAppWithFakeToolbar( + WidgetTester tester, { + ImeAttributedTextEditingController? controller, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 400, + child: DecoratedBox( + decoration: BoxDecoration(border: Border.all(color: Colors.red)), + child: defaultTargetPlatform == TargetPlatform.android + ? SuperAndroidTextField( + key: _textFieldKey, + caretStyle: const CaretStyle(), + textController: controller, + selectionColor: Colors.blue, + handlesColor: Colors.blue, + popoverToolbarBuilder: (context, controller, config) => SizedBox(key: _popoverToolbarKey), + ) + : SuperIOSTextField( + key: _textFieldKey, + caretStyle: const CaretStyle(), + selectionColor: Colors.blue, + handlesColor: Colors.blue, + popoverToolbarBuilder: (context, controller) => SizedBox(key: _popoverToolbarKey), + ), + ), + ), + ), + ), + ), + ); +} + +// Custom gesture settings that ensure panSlop equal to touchSlop +class _GestureSettings extends DeviceGestureSettings { + const _GestureSettings({ + required double touchSlop, + required this.panSlop, + }) : super(touchSlop: touchSlop); + + @override + final double panSlop; +} + +final _popoverToolbarKey = GlobalKey(); +final _textFieldKey = GlobalKey(); diff --git a/super_editor/test/super_textfield/super_textfield_ime_test.dart b/super_editor/test/super_textfield/super_textfield_ime_test.dart new file mode 100644 index 0000000000..c98baee88a --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_ime_test.dart @@ -0,0 +1,1039 @@ +import 'package:flutter/material.dart' hide SelectableText; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group('SuperTextField', () { + group('with IME input source', () { + group('inserts character', () { + testWidgetsOnAllPlatforms('in empty text', (tester) async { + await _pumpEmptySuperTextField(tester); + await tester.placeCaretInSuperTextField(0); + + await tester.ime.typeText("f", getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "f"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); + }); + + testWidgetsOnAllPlatforms('in middle of text', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('--><--'), + ), + ); + await tester.placeCaretInSuperTextField(3); + + await tester.ime.typeText("f", getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "-->f<--"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4)); + }); + + testWidgetsOnAllPlatforms('at end of text', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('-->'), + ), + ); + await tester.placeCaretInSuperTextField(3); + + await tester.ime.typeText("f", getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "-->f"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4)); + }); + + testWidgetsOnAllPlatforms('and replaces selected text', (tester) async { + // TODO: We create the controller outside the pump so that we can explicitly set its selection + // because we don't support gesture selection on mobile, yet. + final controller = AttributedTextEditingController( + text: AttributedText('-->REPLACE<--'), + ); + await _pumpSuperTextField( + tester, + controller, + ); + + // TODO: switch this to gesture selection when we support that on mobile + controller.selection = const TextSelection(baseOffset: 3, extentOffset: 10); + + await tester.ime.typeText("f", getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "-->f<--"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4)); + }); + + testWidgetsOnAllPlatforms('and clears composing region after text changes', (tester) async { + final controller = ImeAttributedTextEditingController(); + await _pumpSuperTextField(tester, controller); + + await tester.placeCaretInSuperTextField(0); + + bool sentToPlatform = false; + int? composingBase; + int? composingExtent; + + // Intercept the setEditingState message sent to the platform. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + if (methodCall.method == 'TextInput.setEditingState') { + sentToPlatform = true; + composingBase = methodCall.arguments["composingBase"]; + composingExtent = methodCall.arguments["composingExtent"]; + } + return null; + }, + ); + + // Type "a". + await tester.ime.typeText('a', getter: imeClientGetter); + + // Manually set the value to "b" to send the value to the IME. + controller.text = AttributedText('b'); + + // Ensure we send the value back to the IME. + expect(sentToPlatform, true); + + // Ensure we cleared the composing region. + expect(composingBase, -1); + expect(composingExtent, -1); + }); + + testWidgetsOnAllPlatforms('and don\'t send editing value back to IME if matches the expected value', + (tester) async { + await _pumpEmptySuperTextField(tester); + await tester.placeCaretInSuperTextField(0); + + bool sentToPlatform = false; + + // Intercept the setEditingState message sent to the platform. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + if (methodCall.method == 'TextInput.setEditingState') { + sentToPlatform = true; + } + return null; + }, + ); + + // Type "a". + // The IME now sees "a" as the editing value. + await tester.ime.typeText('a', getter: imeClientGetter); + + // Ensure that after the insertion our value is also "a". + expect(SuperTextFieldInspector.findText().toPlainText(), 'a'); + + // Ensure we don't send the value back to the OS. + // + // As both us and the IME agree on what's the current editing value, we don't need to send it back. + expect(sentToPlatform, false); + }); + + testWidgetsOnAllPlatforms('and send editing value back to IME if it doesn\'t match the expected value', + (tester) async { + final controller = ImeAttributedTextEditingController( + controller: _ObscuringTextController(), + ); + await _pumpSuperTextField(tester, controller); + + await tester.placeCaretInSuperTextField(0); + + bool sentToPlatform = false; + + // Intercept the setEditingState message sent to the platform. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + if (methodCall.method == 'TextInput.setEditingState') { + sentToPlatform = true; + } + return null; + }, + ); + + // Type "ab". Our controller will change the text to "*b" when the second delta is processed. + await tester.ime.typeText("ab", getter: imeClientGetter); + + // We are using a custom controller which changes every character but the last one to "*". + // After typing "b" the IME thinks the text is "ab". However, for us the text is "*b". + // As our value is different from what the IME thinks it is, we need to send our current value + // back to the IME. + + // Ensure we sent the value back to the IME. + expect(sentToPlatform, true); + }); + + testWidgetsOnAllPlatforms( + 'and don\'t send editing value back to the IME on replacements if matches the expected value', + (tester) async { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + text: AttributedText('-->REPLACE'), + ), + ); + + await _pumpSuperTextField(tester, controller); + + // Select the word REPLACE. + await tester.doubleTapAtSuperTextField(3); + + bool sentToPlatform = false; + + // Intercept the setEditingState message sent to the platform to check if we sent the value + // back to the IME. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + if (methodCall.method == 'TextInput.setEditingState') { + sentToPlatform = true; + } + return null; + }, + ); + + // Simulate the IME sending a replacement with a non-empty composing region. + await tester.ime.sendDeltas([ + const TextEditingDeltaReplacement( + oldText: '-->REPLACE', + replacementText: 'a', + replacedRange: TextRange(start: 3, end: 10), + selection: TextSelection.collapsed(offset: 4), + composing: TextRange(start: 3, end: 4), + ), + ], getter: imeClientGetter); + + // Ensure we send the value back to the IME. + // + // As both us and the IME agree on what's the current editing value, we don't need to send it back. + expect(sentToPlatform, false); + }); + }); + + group('inserts line', () { + testWidgetsOnDesktop('when ENTER is pressed in middle of text', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('this is some text'), + ), + ); + await tester.placeCaretInSuperTextField(8); + + await tester.pressEnterAdaptive(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "this is \nsome text"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 9)); + }); + + testWidgetsOnDesktop('when ENTER is pressed at beginning of text', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('this is some text'), + ), + ); + await tester.placeCaretInSuperTextField(0); + + await tester.pressEnterAdaptive(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "\nthis is some text"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); + }); + + testWidgetsOnDesktop('when ENTER is pressed at end of text', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('this is some text'), + ), + ); + await tester.placeCaretInSuperTextField(17); + + await tester.pressEnterAdaptive(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "this is some text\n"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 18)); + }); + + // TODO: Merge this with the testWidgetsOnMac below when Flutter supports numpad enter on windows + testWidgetsOnLinux('when NUMPAD ENTER is pressed in middle of text', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('this is some text'), + ), + ); + await tester.placeCaretInSuperTextField(8); + + await tester.pressNumpadEnterAdaptive(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "this is \nsome text"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 9)); + }); + + testWidgetsOnMac('when NUMPAD ENTER is pressed in middle of tex', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('this is some text'), + ), + ); + await tester.placeCaretInSuperTextField(8); + + await tester.pressNumpadEnterAdaptive(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "this is \nsome text"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 9)); + }); + + // TODO: Merge this with the testWidgetsOnMac below when Flutter supports numpad enter on windows + testWidgetsOnLinux('when NUMPAD ENTER is pressed at beginning of text', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('this is some text'), + ), + ); + await tester.placeCaretInSuperTextField(0); + + await tester.pressNumpadEnterAdaptive(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "\nthis is some text"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); + }); + + testWidgetsOnMac('when NUMPAD ENTER is pressed at beginning of text', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('this is some text'), + ), + ); + await tester.placeCaretInSuperTextField(0); + + await tester.pressNumpadEnterAdaptive(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "\nthis is some text"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); + }); + + // TODO: Merge this with the testWidgetsOnMac below when Flutter supports numpad enter on windows + testWidgetsOnLinux('when NUMPAD ENTER is pressed at end of text', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('this is some text'), + ), + ); + await tester.placeCaretInSuperTextField(17); + + await tester.pressNumpadEnterAdaptive(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "this is some text\n"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 18)); + }); + + testWidgetsOnMac('when NUMPAD ENTER is pressed at end of text', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText('this is some text'), + ), + ); + await tester.placeCaretInSuperTextField(17); + + await tester.pressNumpadEnterAdaptive(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "this is some text\n"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 18)); + }); + }); + + group('delete text', () { + testWidgetsOnAllPlatforms('BACKSPACE does nothing when text is empty', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText(""), + ), + ); + await tester.placeCaretInSuperTextField(0); + + await tester.ime.backspace(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), ""); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); + }); + + testWidgetsOnAllPlatforms('BACKSPACE deletes the previous character', (tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController( + text: AttributedText("this is some text"), + ), + ); + await tester.placeCaretInSuperTextField(2); + + await tester.ime.backspace(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findText().toPlainText(), "tis is some text"); + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); + }); + + testWidgetsOnAllPlatforms('BACKSPACE deletes selection when selection is expanded', (tester) async { + // TODO: We create the controller outside the pump so that we can explicitly set its selection + // because we don't support gesture selection on mobile, yet. + final controller = AttributedTextEditingController( + text: AttributedText(_multilineLayoutText), + ); + await _pumpSuperTextField( + tester, + controller, + ); + + // TODO: switch this to gesture selection when we support that on mobile + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 10); + + await tester.ime.backspace(getter: imeClientGetter); + + expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); + expect(SuperTextFieldInspector.findText().toPlainText(), + "is long enough to be multiline in the available space"); + }); + }); + + testWidgetsOnAllPlatforms('clears composing region after selection changes', (tester) async { + final controller = ImeAttributedTextEditingController(); + await _pumpSuperTextField(tester, controller); + + // Place the caret at the beginning of the textfield. + await tester.placeCaretInSuperTextField(0); + + // Type something to have some text to tap on. + await tester.typeImeText('Composing: '); + + // Ensure we don't have a composing region. + expect(controller.composingRegion, TextRange.empty); + + // Simulate an insertion containing a composing region. + await tester.ime.sendDeltas( + [ + const TextEditingDeltaInsertion( + oldText: 'Composing: ', + textInserted: "あs", + insertionOffset: 11, + selection: TextSelection.collapsed(offset: 13), + composing: TextRange(start: 11, end: 13), + ), + ], + getter: imeClientGetter, + ); + + // Ensure the textfield applied the composing region. + expect(controller.composingRegion, const TextRange(start: 11, end: 13)); + + int? composingBase; + int? composingExtent; + + // Intercept the setEditingState message sent to the platform to check if we + // cleared the IME composing region when changing the selection. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + composingBase = methodCall.arguments["composingBase"]; + composingExtent = methodCall.arguments["composingExtent"]; + return null; + }, + ); + + // Place the caret at the beginning of the textfield. + await tester.placeCaretInSuperTextField(0); + + // Ensure we cleared the composing region. + expect(composingBase, -1); + expect(composingExtent, -1); + + // Ensure the textfield composing region was cleared. + expect(controller.composingRegion, TextRange.empty); + }); + + testWidgetsOnAllPlatforms('clears composing region after losing focus', (tester) async { + final controller = ImeAttributedTextEditingController(); + final focusNode = FocusNode(); + + await _pumpSuperTextField( + tester, + controller, + focusNode: focusNode, + ); + + // Place the caret at the beginning of the textfield. + await tester.placeCaretInSuperTextField(0); + + // Type something to have some text to tap on. + await tester.typeImeText('Composing: '); + + // Ensure we don't have a composing region. + expect(controller.composingRegion, TextRange.empty); + + // Simulate an insertion containing a composing region. + await tester.ime.sendDeltas( + [ + const TextEditingDeltaInsertion( + oldText: 'Composing: ', + textInserted: "あs", + insertionOffset: 11, + selection: TextSelection.collapsed(offset: 13), + composing: TextRange(start: 11, end: 13), + ), + ], + getter: imeClientGetter, + ); + + // Ensure the textfield applied the composing region. + expect(controller.composingRegion, const TextRange(start: 11, end: 13)); + + // Remove focus from the textfield. + focusNode.unfocus(); + await tester.pump(); + + // Ensure the composing region was cleared. + expect(controller.composingRegion, TextRange.empty); + }); + }); + + testWidgetsOnMobile('configures the software keyboard action button', (tester) async { + await tester.pumpWidget( + _buildScaffold( + child: const SuperTextField( + textInputAction: TextInputAction.next, + ), + ), + ); + + // Holds the keyboard input action sent to the platform. + String? inputAction; + + // Intercept messages sent to the platform. + tester.binding.defaultBinaryMessenger.setMockMessageHandler(SystemChannels.textInput.name, (message) async { + final methodCall = const JSONMethodCodec().decodeMethodCall(message); + if (methodCall.method == 'TextInput.setClient') { + final params = methodCall.arguments[1] as Map; + inputAction = params['inputAction']; + } + return null; + }); + + // Tap the text field to show the software keyboard. + await tester.placeCaretInSuperTextField(0); + + // Ensure the given TextInputAction was applied. + expect(inputAction, 'TextInputAction.next'); + }); + + testWidgetsOnAllPlatforms('disconnects from IME when disposed', (tester) async { + final controller = ImeAttributedTextEditingController(); + await _pumpSuperTextField(tester, controller); + + // Place the caret to open an IME connection. + await tester.placeCaretInSuperTextField(0); + + // Ensure the IME connection is open. + expect(controller.isAttachedToIme, isTrue); + + // Pump a different tree to cause the text field to dispose. + await tester.pumpWidget(const MaterialApp()); + + // Ensure the IME connection is closed. + expect(controller.isAttachedToIme, isFalse); + }); + + testWidgetsOnAllPlatforms('applies custom IME configuration', (tester) async { + // Pump a SuperTextField with an IME configuration with values + // that differ from the defaults. + await tester.pumpWidget( + _buildScaffold( + child: const SuperTextField( + inputSource: TextInputSource.ime, + imeConfiguration: TextInputConfiguration( + enableSuggestions: false, + autocorrect: false, + inputAction: TextInputAction.search, + keyboardAppearance: Brightness.dark, + inputType: TextInputType.number, + enableDeltaModel: false, + ), + ), + ), + ); + + // Holds the IME configuration values passed to the platform. + String? inputAction; + String? inputType; + bool? autocorrect; + bool? enableSuggestions; + String? keyboardAppearance; + bool? enableDeltaModel; + + // Intercept the setClient message sent to the platform to check the configuration. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setClient', + (methodCall) { + final params = methodCall.arguments[1] as Map; + inputAction = params['inputAction']; + autocorrect = params['autocorrect']; + enableSuggestions = params['enableSuggestions']; + keyboardAppearance = params['keyboardAppearance']; + enableDeltaModel = params['enableDeltaModel']; + + final inputTypeConfig = params['inputType'] as Map; + inputType = inputTypeConfig['name']; + + return null; + }, + ); + + // Tap to focus the text field and attach to the IME. + await tester.placeCaretInSuperTextField(0); + + // Ensure we use the values from the configuration. + expect(inputAction, 'TextInputAction.search'); + expect(inputType, 'TextInputType.number'); + expect(autocorrect, false); + expect(enableSuggestions, false); + expect(enableDeltaModel, true); + expect(keyboardAppearance, 'Brightness.dark'); + }); + + testWidgetsOnAllPlatforms('applies viewId when attaching to the IME', (tester) async { + await _pumpEmptySuperTextField(tester); + + // Intercept the messages sent to the platform to check if + // we provided the viewId when attaching to the IME. + int? viewId; + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setClient', + (methodCall) { + final textInputConfig = (methodCall.arguments as List)[1] as Map; + viewId = textInputConfig['viewId']; + return null; + }, + ); + + // Place the caret to attach to the IME. + await tester.placeCaretInSuperTextField(0); + + // Ensure we provided a viewId when attaching to the IME. + expect(viewId, isNotNull); + }); + + group('on iPhone 15 (iOS 17.5)', () { + testWidgetsOnIos('ignores keyboard autocorrections when pressing the action button', (tester) async { + await _pumpEmptySuperTextField(tester); + + // Place the caret at the start of the text field. + await tester.placeCaretInSuperTextField(0); + + // Type some text. + await tester.typeImeText('run tom'); + + // Press the "Done" button. + await tester.testTextInput.receiveAction(TextInputAction.done); + + // Simulate the IME sending a delta replacing "tom" with "Tom". + await tester.ime.sendDeltas([ + const TextEditingDeltaReplacement( + oldText: '. run tom', + replacementText: 'Tom', + replacedRange: TextRange(start: 6, end: 9), + selection: TextSelection.collapsed(offset: 9), + composing: TextRange(start: -1, end: -1), + ), + ], getter: imeClientGetter); + await tester.pump(); + + // Ensure the correction was ignored. + expect(SuperTextFieldInspector.findText().toPlainText(), 'run tom'); + }); + }); + }); + + testWidgetsOnAllPlatforms('updates IME configuration when it changes', (tester) async { + final brightnessNotifier = ValueNotifier(Brightness.dark); + + // Pump a SuperTextField with an IME configuration with values + // that differ from the defaults. + await tester.pumpWidget( + _buildScaffold( + child: ValueListenableBuilder( + valueListenable: brightnessNotifier, + builder: (context, brightness, child) { + return SuperTextField( + inputSource: TextInputSource.ime, + imeConfiguration: TextInputConfiguration( + enableSuggestions: false, + autocorrect: false, + inputAction: TextInputAction.search, + keyboardAppearance: brightness, + inputType: TextInputType.number, + enableDeltaModel: false, + textCapitalization: TextCapitalization.characters, + ), + ); + }, + ), + ), + ); + + // Holds the IME configuration values passed to the platform. + String? inputAction; + String? inputType; + bool? autocorrect; + bool? enableSuggestions; + String? keyboardAppearance; + bool? enableDeltaModel; + String? textCapitalization; + + // Intercept the setClient message sent to the platform to check the configuration. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setClient', + (methodCall) { + final params = methodCall.arguments[1] as Map; + inputAction = params['inputAction']; + autocorrect = params['autocorrect']; + enableSuggestions = params['enableSuggestions']; + keyboardAppearance = params['keyboardAppearance']; + enableDeltaModel = params['enableDeltaModel']; + textCapitalization = params['textCapitalization']; + + final inputTypeConfig = params['inputType'] as Map; + inputType = inputTypeConfig['name']; + + return null; + }, + ); + + // Tap to focus the text field and attach to the IME. + await tester.placeCaretInSuperTextField(0); + + // Ensure we use the values from the configuration. + expect(inputAction, 'TextInputAction.search'); + expect(inputType, 'TextInputType.number'); + expect(autocorrect, false); + expect(enableSuggestions, false); + expect(enableDeltaModel, true); + expect(textCapitalization, 'TextCapitalization.characters'); + expect(keyboardAppearance, 'Brightness.dark'); + + // Change the brightness to rebuild the widget + // and re-attach to the IME. + brightnessNotifier.value = Brightness.light; + await tester.pump(); + + // Ensure we use the values from the configuration, + // updating only the keyboard appearance. + expect(inputAction, 'TextInputAction.search'); + expect(inputType, 'TextInputType.number'); + expect(autocorrect, false); + expect(enableSuggestions, false); + expect(enableDeltaModel, true); + expect(textCapitalization, 'TextCapitalization.characters'); + expect(keyboardAppearance, 'Brightness.light'); + }); + + testWidgetsOnAllPlatforms('doesn\'t re-attach to IME if the configuration doesn\'t change', (tester) async { + // Keeps track of how many times TextInput.setClient was called. + int imeConnectionCount = 0; + + // Explicitly avoid using const to ensure that we have two + // TextInputConfiguration instances with the same values. + // + // ignore: prefer_const_constructors + final configuration1 = TextInputConfiguration( + enableSuggestions: false, + autocorrect: false, + inputAction: TextInputAction.search, + keyboardAppearance: Brightness.dark, + inputType: TextInputType.number, + enableDeltaModel: false, + ); + // ignore: prefer_const_constructors + final configuration2 = TextInputConfiguration( + enableSuggestions: false, + autocorrect: false, + inputAction: TextInputAction.search, + keyboardAppearance: Brightness.dark, + inputType: TextInputType.number, + enableDeltaModel: false, + ); + + final inputConfigurationNotifier = ValueNotifier(configuration1); + + // Pump a SuperTextField with an IME configuration with values + // that differ from the defaults. + await tester.pumpWidget( + _buildScaffold( + child: ValueListenableBuilder( + valueListenable: inputConfigurationNotifier, + builder: (context, inputConfiguration, child) { + return SuperTextField( + inputSource: TextInputSource.ime, + imeConfiguration: inputConfiguration, + ); + }, + ), + ), + ); + + // Intercept the setClient message sent to the platform. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setClient', + (methodCall) { + imeConnectionCount += 1; + return null; + }, + ); + + // Tap to focus the text field and attach to the IME. + await tester.placeCaretInSuperTextField(0); + + // Change the configuration instance to trigger a rebuild. + inputConfigurationNotifier.value = configuration2; + await tester.pump(); + + // Ensure the connection was performed only once. + expect(imeConnectionCount, 1); + }); + + group('SuperTextField on some bad Android software keyboards', () { + testWidgetsOnAndroid('handles BACKSPACE key event instead of deletion for a collapsed selection', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText('This is a text'), + ); + await _pumpScaffoldForBuggyKeyboards(tester, controller: controller); + + // Focus the text field + // TODO: change to use the robot when mobile is supported + await tester.tapAt(tester.getCenter(find.byType(SuperTextField))); + await tester.pump(); + + // Place caret at This|. We don't put caret at the end of the text + // to ensure we are not deleting always the last character + controller.selection = const TextSelection.collapsed(offset: 4); + await tester.pump(); + + await tester.pressBackspace(); + + // Ensure text is deleted + expect(controller.text.toPlainText(), 'Thi is a text'); + }); + + testWidgetsOnAndroid('handles BACKSPACE key event instead of deletion for a expanded selection', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText('This is a text'), + ); + await _pumpScaffoldForBuggyKeyboards(tester, controller: controller); + + // Focus the text field + // TODO: change to use the robot when mobile is supported + await tester.tapAt(tester.getCenter(find.byType(SuperTextField))); + await tester.pump(); + + // Selects ' text' + controller.selection = const TextSelection( + baseOffset: 9, + extentOffset: 14, + ); + await tester.pump(); + + await tester.pressBackspace(); + + // Ensure text is deleted + expect(controller.text.toPlainText(), 'This is a'); + }); + }); +} + +// Based on experiments, the text is laid out as follows (at 320px wide): +// +// (0)this text is long (18 - upstream) +// (18)enough to be (31 - upstream) +// (31)multiline in the (48 - upstream) +// (48)available space(63) +const _multilineLayoutText = 'this text is long enough to be multiline in the available space'; + +Future _pumpEmptySuperTextField(WidgetTester tester) async { + await _pumpSuperTextField( + tester, + AttributedTextEditingController(text: AttributedText('')), + ); +} + +Future _pumpSuperTextField( + WidgetTester tester, + AttributedTextEditingController controller, { + FocusNode? focusNode, + int? minLines, + int? maxLines, +}) async { + await tester.pumpWidget( + MaterialApp( + // The Center allows the content to be smaller than the display + home: Center( + // This SizedBox, combined with the font size in the TextStyle, + // determines the text line wrapping, which is critical for the + // tests in this suite. + child: SizedBox( + width: 320, + child: SuperTextField( + focusNode: focusNode, + textController: controller, + inputSource: TextInputSource.ime, + minLines: minLines, + maxLines: maxLines, + lineHeight: 18, + textStyleBuilder: (_) { + return const TextStyle( + // This font size, combined with the layout width below, are + // critical to determining the text line wrapping. + fontSize: 18, + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The following code prints the bounding box for every + // character of text in the layout. You can use that info + // to figure out where line breaks occur. + // final textLayout = SuperTextFieldInspector.findProseTextLayout(); + // for (int i = 0; i < _multilineLayoutText.length; ++i) { + // print('$i: ${textLayout.getCharacterBox(TextPosition(offset: i))}'); + // } +} + +Future _pumpScaffoldForBuggyKeyboards( + WidgetTester tester, { + required AttributedTextEditingController controller, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 300), + child: SuperTextField( + textController: controller, + ), + ), + ), + ), + ); +} + +Widget _buildScaffold({ + required Widget child, +}) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + child: child, + ), + ), + ); +} + +/// An [AttributedTextEditingController] that uppon insertion replaces every character +/// but the last one with a "*". +/// +/// Used to modify the text when we receive deltas from the IME, causing us to send the editing value +/// back to the IME. +class _ObscuringTextController extends AttributedTextEditingController { + _ObscuringTextController({ + AttributedText? text, + }) : super(text: text); + + @override + void insertAtCaret({ + required String text, + TextRange? newComposingRegion, + }) { + final attributedText = super.text; + + final textAfterInsertion = attributedText.insertString( + textToInsert: text, + startOffset: selection.extentOffset, + applyAttributions: Set.from(composingAttributions), + ); + + // Replace everything but the last char with *. + final updatedText = (''.padLeft(textAfterInsertion.length - 1, '*')) + + textAfterInsertion.toPlainText().substring(textAfterInsertion.length - 1); + + final updatedSelection = _moveSelectionForInsertion( + selection: selection, + insertIndex: selection.extentOffset, + newTextLength: text.length, + ); + + update( + text: AttributedText( + updatedText, + textAfterInsertion.spans, + ), + selection: updatedSelection, + composingRegion: newComposingRegion, + ); + } + + // Copied from AttributedTextEditingController. + TextSelection _moveSelectionForInsertion({ + required TextSelection selection, + required int insertIndex, + required int newTextLength, + }) { + int newBaseOffset = selection.baseOffset; + if ((selection.baseOffset == insertIndex && selection.isCollapsed) || (selection.baseOffset > insertIndex)) { + newBaseOffset = selection.baseOffset + newTextLength; + } + + final newExtentOffset = + selection.extentOffset >= insertIndex ? selection.extentOffset + newTextLength : selection.extentOffset; + + return TextSelection( + baseOffset: newBaseOffset, + extentOffset: newExtentOffset, + ); + } +} diff --git a/super_editor/test/super_textfield/super_textfield_inline_widgets_test.dart b/super_editor/test/super_textfield/super_textfield_inline_widgets_test.dart new file mode 100644 index 0000000000..47d9fcf552 --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_inline_widgets_test.dart @@ -0,0 +1,478 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_text_field_test.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +void main() { + group('SuperTextField > inline widgets >', () { + testWidgetsOnAllPlatforms('renders single inline widget at beginning of the text', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'Hello', + null, + { + 0: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the widget was rendered. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Ensure the inline widget was rendered at the beginning of the textfield. + final inlineWidgetRect = tester.getRect(find.byPlaceholderName('1')); + expect( + inlineWidgetRect.left, + tester.getTopLeft(find.byType(SuperTextField)).dx, + ); + }); + + testWidgetsOnAllPlatforms('renders single inline widget at middle of the text', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'inline', + null, + { + 3: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the inline widget was rendered between characters at offsets + // 2 and 3 of the original string. + final inlineWidgetRect = tester.getRect(find.byPlaceholderName('1')); + final (beforeInlineWidget, afterInlineWidget) = _getOffsetsAroundPosition( + tester, + const TextPosition(offset: 3), + ); + expect(inlineWidgetRect.left, greaterThan(beforeInlineWidget.dx)); + expect(inlineWidgetRect.left, lessThan(afterInlineWidget.dx)); + }); + + testWidgetsOnAllPlatforms('renders single inline widget at end of the text', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'Hello', + null, + { + 5: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the widget was rendered. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Ensure the inline widget was rendered after the last character. + final inlineWidgetRect = tester.getRect(find.byPlaceholderName('1')); + expect( + inlineWidgetRect.left, + greaterThan(_getOffsetAtPosition(tester, const TextPosition(offset: 4)).dx), + ); + }); + + testWidgetsOnAllPlatforms('renders multiple inline widgets', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'Hello', + null, + { + 0: const _NamedPlaceHolder('1'), + 6: const _NamedPlaceHolder('2'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the first widget was rendered. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Ensure the first inline widget was rendered at the beginning of the textfield. + final firstInlineWidgetRect = tester.getRect(find.byPlaceholderName('1')); + expect( + firstInlineWidgetRect.left, + tester.getTopLeft(find.byType(SuperTextField)).dx, + ); + + // Ensure the second widget was rendered. + expect( + find.byPlaceholderName('2'), + findsOneWidget, + ); + + // Ensure the second inline widget was rendered at the end of the textfield. + final secondInlineWidgetRect = tester.getRect(find.byPlaceholderName('2')); + expect( + secondInlineWidgetRect.left, + _getOffsetAtPosition(tester, const TextPosition(offset: 6)).dx, + ); + }); + + testWidgetsOnAllPlatforms('places caret when tapping on inline widget', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'inline', + null, + { + 3: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Tap on the inline widget. + await tester.tapAt(tester.getTopLeft(find.byPlaceholderName('1'))); + await tester.pump(kDoubleTapTimeout); + + // Ensure the caret is placed just before the inline widget. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 3), + ); + }); + + testWidgetsOnDesktop('navigates using arrow keys', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'inline', + null, + { + 3: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Place caret at "in|line". + await tester.placeCaretInSuperTextField(2); + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 2), + ); + + // Place RIGHT ARROW twice to move the caret to the position + // immediately after the inline widget. + await tester.pressRightArrow(); + await tester.pressRightArrow(); + + // Ensure that the caret moved. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 4), + ); + + // Place LEFT ARROW to move the caret back to the position + // immediately before the inline widget. + await tester.pressLeftArrow(); + + // Ensure that the caret moved. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection.collapsed(offset: 3), + ); + }); + + testWidgetsOnDesktop('deletes inline widget with backspace', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'inline', + null, + { + 3: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the widget is present. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Place the caret at the position immediately after the inline widget. + await tester.placeCaretInSuperTextField(4); + + // Press backspace to remove the inline widget. + await tester.pressBackspace(); + + // Ensure the widget was not rendered. + expect( + find.byPlaceholderName('1'), + findsNothing, + ); + + // Ensure the original text remains unmodified. + expect( + SuperTextFieldInspector.findText().toPlainText(), + 'inline', + ); + }); + + testWidgetsOnDesktop('deletes inline widget with delete', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'inline', + null, + { + 3: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the widget is present. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Place the caret before the inline widget. + await tester.placeCaretInSuperTextField(3); + + // Press delete to remove the inline widget. + await tester.pressDelete(); + + // Ensure the widget was not rendered. + expect( + find.byPlaceholderName('1'), + findsNothing, + ); + + // Ensure the original text remains unmodified. + expect( + SuperTextFieldInspector.findText().toPlainText(), + 'inline', + ); + }); + + testWidgetsOnDesktop('deletes inline widget inside expanded selection', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'before inline after', + null, + { + 10: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Ensure the widget is present. + expect( + find.byPlaceholderName('1'), + findsOneWidget, + ); + + // Place caret at "|inline". + await tester.placeCaretInSuperTextField(7); + + // Press shift + right arrow to expand the selection to "|inl�ine|", + // where "�" means the inline widget. + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + await tester.pressShiftRightArrow(); + + // Press backspace to remove the selected content. + await tester.pressBackspace(); + + // Ensure the widget was not rendered. + expect( + find.byPlaceholderName('1'), + findsNothing, + ); + + // Ensure the text was updated. + expect( + SuperTextFieldInspector.findText().toPlainText(), + 'before after', + ); + }); + + testWidgetsOnAllPlatforms('selects inline widget upon double tap', (tester) async { + // This test ensures that SuperTextField does not crash upon double tap + // when there is an inline widget in the text. + // See https://github.com/superlistapp/super_editor/issues/2611 for more details. + + final controller = AttributedTextEditingController( + text: AttributedText( + '< inline', + null, + { + 0: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Double tap at the inline widget. + final inlineWidgetCenter = tester.getCenter(find.byPlaceholderName('1')); + await tester.tapAt(inlineWidgetCenter); + await tester.pump(kDoubleTapMinTime); + await tester.tapAt(inlineWidgetCenter); + // Wait for the double tap to be recognized. + await tester.pump(kTapMinTime); + + // Ensure the inline widget was selected. + expect( + SuperTextFieldInspector.findSelection(), + const TextSelection(baseOffset: 0, extentOffset: 1), + ); + }); + + testWidgetsOnAllPlatforms('does not invalidate layout when selection changes', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'Hello', + null, + { + 5: const _NamedPlaceHolder('1'), + }, + ), + ); + + await _pumpTestApp(tester, controller: controller); + + // Place the caret at the beginning of the textfield. + await tester.placeCaretInSuperTextField(0); + + // Keep track of whether of not the layout was invalidated. + bool wasLayoutInvalidated = false; + + final renderParagraph = find + .byType(LayoutAwareRichText) // + .evaluate() + .first + .findRenderObject() as RenderLayoutAwareParagraph; + renderParagraph.onMarkNeedsLayout = () { + wasLayoutInvalidated = true; + }; + + // Place the selection somewhere else. + await tester.placeCaretInSuperTextField(1); + + // Ensure the layout was not invalidated. + expect(wasLayoutInvalidated, isFalse); + }); + }); +} + +/// Pump a test app with a [SuperTextField] that renders a [ColoredBox] for each +/// [_NamedPlaceHolder] in the text. +Future _pumpTestApp( + WidgetTester tester, { + required AttributedTextEditingController controller, +}) { + return tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 300, + child: SuperTextField( + textController: controller, + inlineWidgetBuilders: const [ + _boxPlaceHolderBuilder, + ], + ), + ), + ), + ), + ); +} + +/// A builder that renders a [ColoredBox] for a [_NamedPlaceHolder]. +Widget? _boxPlaceHolderBuilder(BuildContext context, TextStyle textStyle, Object placeholder) { + if (placeholder is! _NamedPlaceHolder) { + return null; + } + + return KeyedSubtree( + key: ValueKey('placeholder-${placeholder.name}'), + child: LineHeight( + style: textStyle, + child: const SizedBox( + width: 24, + child: ColoredBox( + color: Colors.yellow, + ), + ), + ), + ); +} + +/// Returns the [Offset] of the given [textPosition] in the [SuperTextField], +/// in global coordinates. +Offset _getOffsetAtPosition(WidgetTester tester, TextPosition textPosition) { + final renderBox = tester.renderObject(find.byType(SuperTextField)) as RenderBox; + final textLayout = SuperTextFieldInspector.findProseTextLayout(); + + return renderBox.localToGlobal(textLayout.getOffsetAtPosition(textPosition)); +} + +/// Returns the [Offset]s of the positions before and after the given [textPosition] +/// in the [SuperTextField], in global coordinates. +/// +/// For example, for the text "world" and the position 2, this method will return +/// the offsets for the letters "o" and "l". +/// +/// This method assumes that there are characters before and after the given position. +(Offset offsetBefore, Offset offsetAfter) _getOffsetsAroundPosition(WidgetTester tester, TextPosition textPosition) { + final renderBox = tester.renderObject(find.byType(SuperTextField)) as RenderBox; + final textLayout = SuperTextFieldInspector.findProseTextLayout(); + + final offsetBefore = textLayout.getOffsetAtPosition(TextPosition(offset: textPosition.offset - 1)); + final offsetAfter = textLayout.getOffsetAtPosition(TextPosition(offset: textPosition.offset + 1)); + + return (renderBox.localToGlobal(offsetBefore), renderBox.localToGlobal(offsetAfter)); +} + +/// A placeholder that is identified by a name. +class _NamedPlaceHolder { + const _NamedPlaceHolder(this.name); + + final String name; + + @override + bool operator ==(Object other) => + identical(this, other) || other is _NamedPlaceHolder && runtimeType == other.runtimeType && name == other.name; + + @override + int get hashCode => name.hashCode; +} + +extension _WidgetForPlaceholderFinder on CommonFinders { + /// Finds a widget that represents a placeholder with the given name. + Finder byPlaceholderName(String name) { + return byKey(ValueKey('placeholder-$name')); + } +} diff --git a/super_editor/test/super_textfield/super_textfield_input_actions_test.dart b/super_editor/test/super_textfield/super_textfield_input_actions_test.dart index 4b222c8629..5d407716d3 100644 --- a/super_editor/test/super_textfield/super_textfield_input_actions_test.dart +++ b/super_editor/test/super_textfield/super_textfield_input_actions_test.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; -import '../test_tools.dart'; - void main() { group("SuperTextField input actions", () { testWidgetsOnMobile("unfocus on DONE", (tester) async { diff --git a/super_editor/test/super_textfield/super_textfield_inspector.dart b/super_editor/test/super_textfield/super_textfield_inspector.dart deleted file mode 100644 index 7f5508b105..0000000000 --- a/super_editor/test/super_textfield/super_textfield_inspector.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_text_layout/super_text_layout.dart'; - -/// Inspects that state of a [SuperTextField] in a test. -class SuperTextFieldInspector { - /// Finds and returns the [ProseTextLayout] within a [SuperTextField]. - /// - /// {@macro supertextfield_finder} - static ProseTextLayout findProseTextLayout([Finder? superTextFieldFinder]) { - final finder = superTextFieldFinder ?? find.byType(SuperTextField); - final element = finder.evaluate().single as StatefulElement; - return (element.state as SuperTextFieldState).textLayout; - } - - /// Finds and returns the [AttributedText] within a [SuperTextField]. - /// - /// {@macro supertextfield_finder} - static AttributedText findText([Finder? superTextFieldFinder]) { - final finder = superTextFieldFinder ?? find.byType(SuperTextField); - final element = finder.evaluate().single as StatefulElement; - final state = element.state as SuperTextFieldState; - return state.controller.text; - } - - /// Finds and returns the [TextSelection] within a [SuperTextField]. - /// - /// {@macro supertextfield_finder} - static TextSelection? findSelection([Finder? superTextFieldFinder]) { - final finder = superTextFieldFinder ?? find.byType(SuperTextField); - final element = finder.evaluate().single as StatefulElement; - final state = element.state as SuperTextFieldState; - return state.controller.selection; - } - - SuperTextFieldInspector._(); -} diff --git a/super_editor/test/super_textfield/super_textfield_keyboard_shortcuts_scrolling_test.dart b/super_editor/test/super_textfield/super_textfield_keyboard_shortcuts_scrolling_test.dart new file mode 100644 index 0000000000..7ed4e7e4bd --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_keyboard_shortcuts_scrolling_test.dart @@ -0,0 +1,1595 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:meta/meta.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +import '../test_runners.dart'; + +void main() { + group("SuperTextField", () { + group("scrolling", () { + group("without ancestor scrollable", () { + testWidgetsOnDesktopAndWeb( + 'PAGE DOWN scrolls down by the viewport height', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageDown); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we scrolled down by the viewport height. + expect( + scrollState.position.pixels, + equals(scrollState.position.viewportDimension), + ); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnDesktopAndWeb( + 'PAGE DOWN does not scroll past bottom of the viewport', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the bottom but not all the way to avoid explicit + // checks comparing scroll offset directly against `maxScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent - 10); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageDown); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we didn't scroll past the bottom of the viewport. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnDesktopAndWeb( + 'PAGE UP scrolls up by the viewport height', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll to the bottom of the viewport. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageUp); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we scrolled up by the viewport height. + expect( + scrollState.position.pixels, + equals(scrollState.position.maxScrollExtent - scrollState.position.viewportDimension), + ); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnDesktopAndWeb( + 'PAGE UP does not scroll past top of the viewport', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the top but not all the way to avoid explicit + // checks comparing scroll offset directly against `minScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.minScrollExtent + 10); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageUp); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we didn't scroll past the top of the viewport. + expect(scrollState.position.pixels, equals(scrollState.position.minScrollExtent)); + }, + variant: _scrollingVariant, + ); + + group("scrolls to top of viewport", () { + testWidgetsOnDesktop( + 'using CMD + HOME on mac and CTRL + HOME on other platforms', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll to the bottom of the viewport. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent); + await tester.pump(); + + // Scroll to viewport's top. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we scrolled to the viewport's top. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + }, + variant: _scrollingVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + 'using HOME on mac and web desktop', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll to the bottom of the viewport. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent); + await tester.pump(); + + // Scroll to viewport's top. + await tester.pressHome(); + + // Ensure we scrolled to the viewport's top. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + }, + variant: _scrollingVariant, + ); + }); + + group("does not scroll past top of the viewport", () { + testWidgetsOnDesktop( + "using CMD + HOME on mac and CTRL + HOME on other platforms ", + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the top but not all the way to avoid explicit + // checks comparing scroll offset directly against `minScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.minScrollExtent + 10); + await tester.pump(); + + // Scroll to viewport's top. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we didn't scroll past the viewport's top. + expect(scrollState.position.pixels, equals(scrollState.position.minScrollExtent)); + }, + variant: _scrollingVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + 'using HOME on mac and web desktop', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the top but not all the way to avoid explicit + // checks comparing scroll offset directly against `minScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.minScrollExtent + 10); + await tester.pump(); + + // Scroll to viewport's top. + await tester.pressHome(); + + // Ensure we didn't scroll past the viewport's top. + expect(scrollState.position.pixels, equals(scrollState.position.minScrollExtent)); + }, + variant: _scrollingVariant, + ); + }); + + group("scrolls to bottom of viewport", () { + testWidgetsOnDesktop( + "using CMD + END on mac and CTRL + END on other platforms ", + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll to viewport's bottom. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + + // Ensure we scrolled to the viewport's bottom. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + 'using END on mac and web desktop', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll to viewport's bottom. + await tester.pressEnd(); + + // Ensure we scrolled to the viewport's bottom. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + }); + + group("does not scroll past bottom of the viewport", () { + testWidgetsOnDesktop( + "using CMD + END on mac and CTRL + END on other platforms", + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the bottom but not all the way to avoid explicit + // checks comparing scroll offset directly against `maxScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent - 10); + await tester.pump(); + + // Scroll to viewport's bottom. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + // Ensure we didn't scroll past the viewport's bottom. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + 'using END on mac and web desktop', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldTestApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the bottom but not all the way to avoid explicit + // checks comparing scroll offset directly against `maxScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent - 10); + await tester.pump(); + + // Scroll to viewport's bottom. + await tester.pressEnd(); + + // Ensure we didn't scroll past the viewport's bottom. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + }); + }); + + group("inside ancestor scrollable", () { + testWidgetsOnDesktopAndWeb( + 'PAGE DOWN scrolls down by the viewport height', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageDown); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we scrolled down by the viewport height. + expect( + scrollState.position.pixels, + equals(scrollState.position.viewportDimension), + ); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnDesktopAndWeb( + 'PAGE DOWN does not scroll past bottom of the viewport', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the bottom but not all the way to avoid explicit + // checks comparing scroll offset directly against `maxScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent - 10); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageDown); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we didn't scroll past the bottom of the viewport. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnDesktopAndWeb( + 'PAGE UP scrolls up by the viewport height', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll to the bottom of the viewport. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageUp); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we scrolled up by the viewport height. + expect( + scrollState.position.pixels, + equals(scrollState.position.maxScrollExtent - scrollState.position.viewportDimension), + ); + }, + variant: _scrollingVariant, + ); + + testWidgetsOnDesktopAndWeb( + 'PAGE UP does not scroll past top of the viewport', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the top but not all the way to avoid explicit + // checks comparing scroll offset directly against `minScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.minScrollExtent + 10); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.pageUp); + + // Let the scrolling system auto-scroll, as desired. + await tester.pumpAndSettle(); + + // Ensure we didn't scroll past the top of the viewport. + expect(scrollState.position.pixels, equals(scrollState.position.minScrollExtent)); + }, + variant: _scrollingVariant, + ); + + group("scrolls to top of viewport", () { + testWidgetsOnDesktop( + 'using CMD + HOME on mac and CTRL + HOME on other platforms', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll to the bottom of the viewport. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent); + await tester.pump(); + + // Scroll to viewport's top. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we scrolled to the viewport's top. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + }, + variant: _scrollingVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + 'using HOME on mac and web desktop', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll to the bottom of the viewport. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent); + await tester.pump(); + + // Scroll to viewport's top. + await tester.pressHome(); + + // Ensure we scrolled to the viewport's top. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + }, + variant: _scrollingVariant, + ); + }); + + group("does not scroll past top of the viewport", () { + testWidgetsOnDesktop( + "using CMD + HOME on mac and CTRL + HOME on other platforms ", + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the top but not all the way to avoid explicit + // checks comparing scroll offset directly against `minScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.minScrollExtent + 10); + await tester.pump(); + + // Scroll to viewport's top. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we didn't scroll past the viewport's top. + expect(scrollState.position.pixels, equals(scrollState.position.minScrollExtent)); + }, + variant: _scrollingVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + 'using HOME on mac and web desktop', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the top but not all the way to avoid explicit + // checks comparing scroll offset directly against `minScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.minScrollExtent + 10); + await tester.pump(); + + // Scroll to viewport's top. + await tester.pressHome(); + + // Ensure we didn't scroll past the viewport's top. + expect(scrollState.position.pixels, equals(scrollState.position.minScrollExtent)); + }, + variant: _scrollingVariant, + ); + }); + + group("scrolls to bottom of viewport", () { + testWidgetsOnDesktop( + "using CMD + END on mac and CTRL + END on other platforms ", + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll to viewport's bottom. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + + // Ensure we scrolled to the viewport's bottom. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + 'using END on mac and web desktop', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll to viewport's bottom. + await tester.pressEnd(); + + // Ensure we scrolled to the viewport's bottom. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + }); + + group("does not scroll past bottom of the viewport", () { + testWidgetsOnDesktop( + "using CMD + END on mac and CTRL + END on other platforms", + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the bottom but not all the way to avoid explicit + // checks comparing scroll offset directly against `maxScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent - 10); + await tester.pump(); + + // Scroll to viewport's bottom. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + // Ensure we didn't scroll past the viewport's bottom. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + 'using END on mac and web desktop', + (tester) async { + final currentVariant = _scrollingVariant.currentValue!; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant.textInputSource, + verticalAlignment: currentVariant.verticalAlignment, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Scroll very close to the bottom but not all the way to avoid explicit + // checks comparing scroll offset directly against `maxScrollExtent` + // and test scrolling behaviour in more realistic manner. + scrollState.position.jumpTo(scrollState.position.maxScrollExtent - 10); + await tester.pump(); + + // Scroll to viewport's bottom. + await tester.pressEnd(); + + // Ensure we didn't scroll past the viewport's bottom. + expect(scrollState.position.pixels, equals(scrollState.position.maxScrollExtent)); + }, + variant: _scrollingVariant, + ); + }); + }); + }); + + group("scrolling within ancestor scrollable", () { + group("scrolls from the text field's top to bottom and then towards the page bottom and back to the page top", + () { + testWidgetsOnDesktop( + "using CMD + HOME/END on mac and CTRL + HOME/END on other platforms", + (tester) async { + final currentVariant = _textFieldInputSourceVariant.currentValue; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant!, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.top, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Find the text field's ancestor scrollable + final ancestorScrollState = tester.state( + find.byType(Scrollable).first, + ); + + // Scrolls to text field's bottom. + + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + + // Ensure we scrolled to text field's bottom. + expect( + scrollState.position.pixels, + equals(scrollState.position.maxScrollExtent), + ); + + // Scrolls to ancestor scrollable's bottom. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + // Ensure we scrolled to ancestor scrollable's bottom. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.maxScrollExtent), + ); + + // Scrolls to text field's top. + + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we scrolled to text field's top. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + + // Scrolls to ancestor scrollable's top. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we scrolled to ancestor scrollable's top. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.minScrollExtent), + ); + }, + variant: _textFieldInputSourceVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + "using HOME and END on mac and web desktop", + (tester) async { + final currentVariant = _textFieldInputSourceVariant.currentValue; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant!, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.top, + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Find text field scrollable. + final scrollState = tester.state(find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + )); + + // Find the text field's ancestor scrollable + final ancestorScrollState = tester.state( + find.byType(Scrollable).first, + ); + + // Scrolls to text field's bottom. + await tester.pressEnd(); + + // Ensure we scrolled to text field's bottom. + expect( + scrollState.position.pixels, + equals(scrollState.position.maxScrollExtent), + ); + + // Scrolls to ancestor scrollable's bottom. + await tester.pressEnd(); + + // Ensure we scrolled to ancestor scrollable's bottom. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.maxScrollExtent), + ); + + // Scrolls to text field's top. + await tester.pressHome(); + + // Ensure we scrolled to text field's top. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + + // Scrolls to ancestor scrollable's top. + await tester.pressHome(); + + // Ensure we scrolled to ancestor scrollable's top. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.minScrollExtent), + ); + }, + variant: _textFieldInputSourceVariant, + ); + }); + + group( + "when placed at the page bottom, scrolls from the text field's top to the page bottom and back to the page top", + () { + testWidgetsOnDesktop( + "using CMD + HOME/END on mac and CTRL + HOME/END on other platforms", + (tester) async { + final currentVariant = _textFieldInputSourceVariant.currentValue; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant!, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.bottom, + ); + + // Find the text field's ancestor scrollable + final ancestorScrollState = tester.state( + find.byType(Scrollable).first, + ); + + ancestorScrollState.position.jumpTo(ancestorScrollState.position.maxScrollExtent); + await tester.pump(); + + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.maxScrollExtent), + ); + + // Find SuperTextField scrollable + final scrollState = tester.state( + find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + ), + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Scroll all the way to the text field's bottom. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + + expect( + scrollState.position.pixels, + equals(scrollState.position.maxScrollExtent), + ); + + // Scrolls to text field's top. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we scrolled to text field's top. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + + // Scrolls to ancestor scrollable's top. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we scrolled to ancestor scrollable's top. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.minScrollExtent), + ); + }, + variant: _textFieldInputSourceVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + "using HOME and END on mac and web desktop", + (tester) async { + final currentVariant = _textFieldInputSourceVariant.currentValue; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant!, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.bottom, + ); + + // Find the text field's ancestor scrollable + final ancestorScrollState = tester.state( + find.byType(Scrollable).first, + ); + + ancestorScrollState.position.jumpTo(ancestorScrollState.position.maxScrollExtent); + await tester.pump(); + + // Ensure we are at the bottom of the page. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.maxScrollExtent), + ); + + // Find SuperTextField scrollable + final scrollState = tester.state( + find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + ), + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Scroll all the way to the text field's bottom. + await tester.pressEnd(); + + expect( + scrollState.position.pixels, + equals(scrollState.position.maxScrollExtent), + ); + + // Scrolls to text field's top. + await tester.pressHome(); + + // Ensure we scrolled to text field's top. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + + // Scrolls to ancestor scrollable's top. + await tester.pressHome(); + + // Ensure we scrolled to ancestor scrollable's top. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.minScrollExtent), + ); + }, + variant: _textFieldInputSourceVariant, + ); + }); + + group( + "when placed at the page center, scrolls from text field's top to the page bottom, and then back to the page top", + () { + testWidgetsOnDesktop( + "using CMD + HOME/END on mac and CTRL + HOME/END on other platforms", + (tester) async { + final currentVariant = _textFieldInputSourceVariant.currentValue; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant!, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.center, + ); + + // Find the text field's ancestor scrollable. + final ancestorScrollState = tester.state( + find.byType(Scrollable).first, + ); + + // Find text field scrollable. + final scrollState = tester.state( + find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + ), + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Ensure we are at the top of the textfiled. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + + // Scrolls to text field's bottom. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + + // Ensure we scrolled to text field's bottom. + expect( + scrollState.position.pixels, + equals(scrollState.position.maxScrollExtent), + ); + + // Scrolls to ancestor scrollable's bottom. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdEnd(tester); + } else { + await tester.pressCtrlEnd(tester); + } + + // Ensure we scrolled to ancestor scrollable's bottom. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.maxScrollExtent), + ); + + // Scrolls to text field's top. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we scrolled to text field's top. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + + // Scrolls to ancestor scrollable's top. + if (defaultTargetPlatform == TargetPlatform.macOS) { + await tester.pressCmdHome(tester); + } else { + await tester.pressCtrlHome(tester); + } + + // Ensure we scrolled to ancestor scrollable's top. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.minScrollExtent), + ); + }, + variant: _textFieldInputSourceVariant, + ); + + _testWidgetsOnMacAndWebDesktop( + "using HOME and END on mac and web desktop", + (tester) async { + final currentVariant = _textFieldInputSourceVariant.currentValue; + + // Pump the widget tree with a SuperTextField which is four lines tall. + await _pumpSuperTextFieldScrollSliverApp( + tester, + textInputSource: currentVariant!, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.center, + ); + + // Find the text field's ancestor scrollable. + final ancestorScrollState = tester.state( + find.byType(Scrollable).first, + ); + + // Find text field scrollable. + final scrollState = tester.state( + find.descendant( + of: find.byType(SuperTextField), + matching: find.byType(Scrollable), + ), + ); + + // Tap on the text field to focus it. + await tester.placeCaretInSuperTextField(0); + + // Ensure we are at the top of the textfiled. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + + // Scrolls to text field's bottom. + await tester.pressEnd(); + + // Ensure we scrolled to text field's bottom. + expect( + scrollState.position.pixels, + equals(scrollState.position.maxScrollExtent), + ); + + // Scrolls to ancestor scrollable's bottom. + await tester.pressEnd(); + + // Ensure we scrolled to ancestor scrollable's bottom. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.maxScrollExtent), + ); + + // Scrolls to text field's top. + await tester.pressHome(); + + // Ensure we scrolled to text field's top. + expect( + scrollState.position.pixels, + equals(scrollState.position.minScrollExtent), + ); + + // Scrolls to ancestor scrollable's top. + await tester.pressHome(); + + // Ensure we scrolled to ancestor scrollable's top. + expect( + ancestorScrollState.position.pixels, + equals(ancestorScrollState.position.minScrollExtent), + ); + }, + variant: _textFieldInputSourceVariant, + ); + }); + }); + }); +} + +/// Variant for an [SuperTextField] experience with/without ancestor scrollable. +final _scrollingVariant = ValueVariant<_SuperTextFieldScrollSetup>({ + const _SuperTextFieldScrollSetup( + textInputSource: TextInputSource.ime, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.top, + ), + const _SuperTextFieldScrollSetup( + textInputSource: TextInputSource.keyboard, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.top, + ), + const _SuperTextFieldScrollSetup( + textInputSource: TextInputSource.ime, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.center, + ), + const _SuperTextFieldScrollSetup( + textInputSource: TextInputSource.keyboard, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.center, + ), + const _SuperTextFieldScrollSetup( + textInputSource: TextInputSource.ime, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.bottom, + ), + const _SuperTextFieldScrollSetup( + textInputSource: TextInputSource.keyboard, + verticalAlignment: _TextFieldVerticalAlignmentWithinScrollable.bottom, + ), +}); + +/// Variant for [SuperTextField]'s text input source. +final _textFieldInputSourceVariant = ValueVariant({ + TextInputSource.keyboard, + TextInputSource.ime, +}); + +/// Pumps a [SuperTextField]. +Future _pumpSuperTextFieldTestApp( + WidgetTester tester, { + TextInputSource textInputSource = TextInputSource.keyboard, + _TextFieldVerticalAlignmentWithinScrollable verticalAlignment = _TextFieldVerticalAlignmentWithinScrollable.top, +}) async { + final textController = AttributedTextEditingController( + text: AttributedText(_textFieldInput), + ); + + return await _pumpTestApp( + tester, + textController: textController, + minLines: 8, + maxLines: 8, + textInputSource: textInputSource, + ); +} + +Future _pumpTestApp( + WidgetTester tester, { + required AttributedTextEditingController textController, + required int minLines, + required int maxLines, + double? maxWidth, + double? maxHeight, + TextInputSource textInputSource = TextInputSource.keyboard, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth ?? double.infinity, + maxHeight: maxHeight ?? double.infinity, + ), + child: SuperTextField( + textController: textController, + configuration: SuperTextFieldPlatformConfiguration.desktop, + textStyleBuilder: (_) => const TextStyle(fontSize: 20), + minLines: minLines, + maxLines: maxLines, + inputSource: textInputSource, + ), + ), + ), + ), + ); + + // The first frame might have a zero viewport height. Pump a second frame to account for the final viewport size. + await tester.pump(); +} + +/// Pumps a [SuperTextField] wrapped within [Scrollable]. +Future _pumpSuperTextFieldScrollSliverApp( + WidgetTester tester, { + TextInputSource textInputSource = TextInputSource.keyboard, + _TextFieldVerticalAlignmentWithinScrollable verticalAlignment = _TextFieldVerticalAlignmentWithinScrollable.top, +}) async { + final textController = AttributedTextEditingController( + text: AttributedText(_textFieldInput), + ); + + final slivers = [ + if (verticalAlignment == _TextFieldVerticalAlignmentWithinScrollable.bottom || + verticalAlignment == _TextFieldVerticalAlignmentWithinScrollable.center) + SliverToBoxAdapter( + child: Builder(builder: (context) { + return SizedBox( + // Occupy enough vertical space to push text field slightly across the viewport + // to introduce scrollable content but small enough to keep it within viewport to be + // detected in tests. + height: MediaQuery.of(context).size.height * 0.95, + width: double.infinity, + child: const Placeholder( + child: Center( + child: Text("Content"), + ), + ), + ); + }), + ), + SliverToBoxAdapter( + child: SuperTextField( + textController: textController, + configuration: SuperTextFieldPlatformConfiguration.desktop, + textStyleBuilder: (_) => const TextStyle(fontSize: 20), + // Force the text field to be tall enough to easily see content scrolling by, + // but short enough to ensure that the content is scrollable. + minLines: 8, + maxLines: 8, + inputSource: textInputSource, + ), + ), + if (verticalAlignment == _TextFieldVerticalAlignmentWithinScrollable.top || + verticalAlignment == _TextFieldVerticalAlignmentWithinScrollable.center) + SliverToBoxAdapter( + child: Builder(builder: (context) { + return SizedBox( + height: MediaQuery.of(context).size.height, + width: double.infinity, + child: const Placeholder( + child: Center( + child: Text("Content"), + ), + ), + ); + }), + ), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: slivers, + ), + ), + ), + ); + + // The first frame might have a zero viewport height. Pump a second frame to account for + // the final viewport size. + await tester.pump(); +} + +/// An arbitrary input, long enough to introduce scrollable content +/// within text field. +final String _textFieldInput = List.generate(20, (index) => "Line $index").join("\n"); + +/// Defines [SuperTextField] test configurations for a test variant. +/// +/// Specificed configurations alter the conditions under which we test +/// [SuperTextField] scrolling on scroll actions invoked through shortcuts. +class _SuperTextFieldScrollSetup { + const _SuperTextFieldScrollSetup({ + required this.textInputSource, + required this.verticalAlignment, + }); + final TextInputSource textInputSource; + final _TextFieldVerticalAlignmentWithinScrollable verticalAlignment; + + @override + String toString() { + return 'SuperTextFieldScrollSetup: aligned at ${verticalAlignment.name}, ${textInputSource.toString()}'; + } +} + +/// Defines the vertical alignment of [SuperTextField] within ancestor +/// scrollable. +/// +/// Testing against different layouts helps verify that the text field's scrolling +/// through scroll shortcuts remains same irrespective of the text field's vertical alignment +/// within ancestor scrollable. +enum _TextFieldVerticalAlignmentWithinScrollable { + top, + center, + bottom; +} + +/// Runs the test on mac desktop, and on web across all desktop platforms. +@isTestGroup +void _testWidgetsOnMacAndWebDesktop( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnMac(description, test, skip: skip, variant: variant); + + testWidgetsOnWebDesktop(description, test, skip: skip, variant: variant); +} diff --git a/super_editor/test/super_textfield/super_textfield_mobile_keyboard_test.dart b/super_editor/test/super_textfield/super_textfield_mobile_keyboard_test.dart deleted file mode 100644 index 409379b8fe..0000000000 --- a/super_editor/test/super_textfield/super_textfield_mobile_keyboard_test.dart +++ /dev/null @@ -1,303 +0,0 @@ -import 'package:flutter/material.dart' hide SelectableText; -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_test_robots/flutter_test_robots.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_editor/super_editor_test.dart'; - -import '../test_tools.dart'; -import 'super_textfield_inspector.dart'; -import 'super_textfield_robot.dart'; - -void main() { - group('SuperTextField', () { - group('on mobile', () { - group('inserts character', () { - testWidgetsOnMobile('in empty text', (tester) async { - await _pumpEmptySuperTextField(tester); - await tester.placeCaretInSuperTextField(0); - - await tester.ime.typeText("f", getter: imeClientGetter); - - expect(SuperTextFieldInspector.findText().text, "f"); - expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); - }); - - testWidgetsOnMobile('in middle of text', (tester) async { - await _pumpSuperTextField( - tester, - AttributedTextEditingController( - text: AttributedText(text: '--><--'), - ), - ); - await tester.placeCaretInSuperTextField(3); - - await tester.ime.typeText("f", getter: imeClientGetter); - - expect(SuperTextFieldInspector.findText().text, "-->f<--"); - expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4)); - }); - - testWidgetsOnMobile('at end of text', (tester) async { - await _pumpSuperTextField( - tester, - AttributedTextEditingController( - text: AttributedText(text: '-->'), - ), - ); - await tester.placeCaretInSuperTextField(3); - - await tester.ime.typeText("f", getter: imeClientGetter); - - expect(SuperTextFieldInspector.findText().text, "-->f"); - expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4)); - }); - - testWidgetsOnMobile('and replaces selected text', (tester) async { - // TODO: We create the controller outside the pump so that we can explicitly set its selection - // because we don't support gesture selection on mobile, yet. - final controller = AttributedTextEditingController( - text: AttributedText(text: '-->REPLACE<--'), - ); - await _pumpSuperTextField( - tester, - controller, - ); - - // TODO: switch this to gesture selection when we support that on mobile - controller.selection = const TextSelection(baseOffset: 3, extentOffset: 10); - - await tester.ime.typeText("f", getter: imeClientGetter); - - expect(SuperTextFieldInspector.findText().text, "-->f<--"); - expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 4)); - }); - }); - - // TODO: implement newline tests when SuperTextField supports configuration of the action button - // group('inserts line', () { - // testWidgetsOnDesktop('when ENTER is pressed in middle of text', (tester) async { - // await _pumpSuperTextField( - // tester, - // AttributedTextEditingController( - // text: AttributedText(text: 'this is some text'), - // ), - // ); - // await tester.placeCaretInSuperTextField(8); - // - // await tester.pressEnter(); - // - // expect(SuperTextFieldInspector.findText().text, "this is \nsome text"); - // expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 9)); - // }); - // - // testWidgetsOnDesktop('when ENTER is pressed at beginning of text', (tester) async { - // await _pumpSuperTextField( - // tester, - // AttributedTextEditingController( - // text: AttributedText(text: 'this is some text'), - // ), - // ); - // await tester.placeCaretInSuperTextField(0); - // - // await tester.pressEnter(); - // - // expect(SuperTextFieldInspector.findText().text, "\nthis is some text"); - // expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); - // }); - // - // testWidgetsOnDesktop('when ENTER is pressed at end of text', (tester) async { - // await _pumpSuperTextField( - // tester, - // AttributedTextEditingController( - // text: AttributedText(text: 'this is some text'), - // ), - // ); - // await tester.placeCaretInSuperTextField(17); - // - // await tester.pressEnter(); - // - // expect(SuperTextFieldInspector.findText().text, "this is some text\n"); - // expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 18)); - // }); - // }); - // - group('delete text', () { - testWidgetsOnMobile('BACKSPACE does nothing when text is empty', (tester) async { - await _pumpSuperTextField( - tester, - AttributedTextEditingController( - text: AttributedText(text: ""), - ), - ); - await tester.placeCaretInSuperTextField(0); - - await tester.ime.backspace(getter: imeClientGetter); - - expect(SuperTextFieldInspector.findText().text, ""); - expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); - }); - - testWidgetsOnMobile('BACKSPACE deletes the previous character', (tester) async { - await _pumpSuperTextField( - tester, - AttributedTextEditingController( - text: AttributedText(text: "this is some text"), - ), - ); - await tester.placeCaretInSuperTextField(2); - - await tester.ime.backspace(getter: imeClientGetter); - - expect(SuperTextFieldInspector.findText().text, "tis is some text"); - expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 1)); - }); - - testWidgetsOnMobile('BACKSPACE deletes selection when selection is expanded', (tester) async { - // TODO: We create the controller outside the pump so that we can explicitly set its selection - // because we don't support gesture selection on mobile, yet. - final controller = AttributedTextEditingController( - text: AttributedText(text: _multilineLayoutText), - ); - await _pumpSuperTextField( - tester, - controller, - ); - - // TODO: switch this to gesture selection when we support that on mobile - controller.selection = const TextSelection(baseOffset: 0, extentOffset: 10); - - await tester.ime.backspace(getter: imeClientGetter); - - expect(SuperTextFieldInspector.findSelection(), const TextSelection.collapsed(offset: 0)); - expect(SuperTextFieldInspector.findText().text, "is long enough to be multiline in the available space"); - }); - }); - }); - }); - - group('SuperTextField on some bad Android software keyboards', () { - testWidgetsOnAndroid('handles BACKSPACE key event instead of deletion for a collapsed selection (on Android)', - (tester) async { - final controller = AttributedTextEditingController( - text: AttributedText(text: 'This is a text'), - ); - await _pumpScaffoldForBuggyKeyboards(tester, controller: controller); - - // Focus the text field - // TODO: change to use the robot when mobile is supported - await tester.tapAt(tester.getCenter(find.byType(SuperTextField))); - await tester.pump(); - - // Place caret at This|. We don't put caret at the end of the text - // to ensure we are not deleting always the last character - controller.selection = const TextSelection.collapsed(offset: 4); - await tester.pump(); - - await tester.pressBackspace(); - - // Ensure text is deleted - expect(controller.text.text, 'Thi is a text'); - }); - - testWidgetsOnAndroid('handles BACKSPACE key event instead of deletion for a expanded selection (on Android)', - (tester) async { - final controller = AttributedTextEditingController( - text: AttributedText(text: 'This is a text'), - ); - await _pumpScaffoldForBuggyKeyboards(tester, controller: controller); - - // Focus the text field - // TODO: change to use the robot when mobile is supported - await tester.tapAt(tester.getCenter(find.byType(SuperTextField))); - await tester.pump(); - - // Selects ' text' - controller.selection = const TextSelection( - baseOffset: 9, - extentOffset: 14, - ); - await tester.pump(); - - await tester.pressBackspace(); - - // Ensure text is deleted - expect(controller.text.text, 'This is a'); - }); - }); -} - -// Based on experiments, the text is laid out as follows (at 320px wide): -// -// (0)this text is long (18 - upstream) -// (18)enough to be (31 - upstream) -// (31)multiline in the (48 - upstream) -// (48)available space(63) -const _multilineLayoutText = 'this text is long enough to be multiline in the available space'; - -Future _pumpEmptySuperTextField(WidgetTester tester) async { - await _pumpSuperTextField( - tester, - AttributedTextEditingController(text: AttributedText(text: '')), - ); -} - -Future _pumpSuperTextField( - WidgetTester tester, - AttributedTextEditingController controller, { - int? minLines, - int? maxLines, -}) async { - await tester.pumpWidget( - MaterialApp( - // The Center allows the content to be smaller than the display - home: Center( - // This SizedBox, combined with the font size in the TextStyle, - // determines the text line wrapping, which is critical for the - // tests in this suite. - child: SizedBox( - width: 320, - child: SuperTextField( - textController: controller, - minLines: minLines, - maxLines: maxLines, - lineHeight: 18, - textStyleBuilder: (_) { - return const TextStyle( - // This font size, combined with the layout width below, are - // critical to determining the text line wrapping. - fontSize: 18, - ); - }, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - // The following code prints the bounding box for every - // character of text in the layout. You can use that info - // to figure out where line breaks occur. - // final textLayout = SuperTextFieldInspector.findProseTextLayout(); - // for (int i = 0; i < _multilineLayoutText.length; ++i) { - // print('$i: ${textLayout.getCharacterBox(TextPosition(offset: i))}'); - // } -} - -Future _pumpScaffoldForBuggyKeyboards( - WidgetTester tester, { - required AttributedTextEditingController controller, -}) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: ConstrainedBox( - constraints: const BoxConstraints(minWidth: 300), - child: SuperTextField( - textController: controller, - ), - ), - ), - ), - ); -} diff --git a/super_editor/test/super_textfield/super_textfield_rendering_test.dart b/super_editor/test/super_textfield/super_textfield_rendering_test.dart new file mode 100644 index 0000000000..a1d6958b9e --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_rendering_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +void main() { + group('SuperTextField', () { + testWidgetsOnAllPlatforms('renders text on the first frame when given a line height', (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText('Editing text'), + ); + + // Indicates whether we should display the Text or the SuperTextField. + final showTextField = ValueNotifier(false); + + // Pump the widget tree showing the Text widget. + await _pumpSwitchableTestApp( + tester, + controller: controller, + showTextField: showTextField, + lineHeight: 16, + ); + + // Switch to display the SuperTextField. + showTextField.value = true; + + // Pump exactly one frame to inspect if the text was rendered. + await tester.pump(); + + // Ensure the SuperTextField rendered the text. + expect(find.text('Editing text', findRichText: true), findsOneWidget); + }); + + testWidgetsOnMobile('expands to respect minLines', (tester) async { + // Indicates whether we should display the Text or the SuperTextField. + final showTextField = ValueNotifier(false); + + // Pump the widget tree showing the Text widget. + await _pumpSwitchableTestApp( + tester, + controller: AttributedTextEditingController( + text: AttributedText('1'), + ), + showTextField: showTextField, + minLines: 5, + ); + + // Switch to display the SuperTextField, so we can inspect it on its first frame. + showTextField.value = true; + await tester.pump(); + + // Ensure the text is rendered in the first frame. + expect(find.text('1', findRichText: true), findsOneWidget); + + // Ensure the text field expanded to the height of minLines. + final textHeight = tester.getSize(find.byType(SuperText)).height; + final textFieldHeight = tester.getSize(find.byType(SuperTextField)).height; + final minLinesHeight = textHeight * 5; + expect(textFieldHeight, moreOrLessEquals(minLinesHeight)); + }); + + testWidgetsOnMobile('shrinks to fit maxLines', (tester) async { + // Indicates whether we should display the Text or the SuperTextField. + final showTextField = ValueNotifier(false); + + // Pump the widget tree showing the Text widget. + await _pumpSwitchableTestApp( + tester, + controller: AttributedTextEditingController( + text: AttributedText('1\n2\n3\n4\n5\n6'), + ), + showTextField: showTextField, + maxLines: 3, + ); + + // Switch to display the SuperTextField, so we can inspect it on its first frame. + showTextField.value = true; + await tester.pump(); + + // Ensure the text is rendered in the first frame. + expect(find.text('1\n2\n3\n4\n5\n6', findRichText: true), findsOneWidget); + + // Ensure the text field shrank to half of the text size. + final textHeight = tester.getSize(find.byType(SuperText)).height; + final textFieldHeight = tester.getSize(find.byType(SuperTextField)).height; + final maxLinesHeight = textHeight / 2; + expect(textFieldHeight, moreOrLessEquals(maxLinesHeight)); + }); + + testWidgetsOnAllPlatforms('renders a hint with baseline cross-axis alignment', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperTextField( + textController: AttributedTextEditingController(), + minLines: 1, + maxLines: 3, + hintBuilder: (context) => const Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text('Hint one'), + Text('Hint two'), + ], + ), + ), + ), + ), + ); + + // Reaching this point means that SuperTextField was able to render without errors. + }); + }); +} + +/// Pumps a widget tree that switches between a [Text] and a [SuperTextField] depending on [showTextField]. +Future _pumpSwitchableTestApp( + WidgetTester tester, { + required AttributedTextEditingController controller, + required ValueNotifier showTextField, + double? lineHeight, + int? minLines, + int? maxLines, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListenableBuilder( + listenable: showTextField, + builder: (context, _) { + return showTextField.value + ? SuperTextField( + textController: controller, + lineHeight: lineHeight, + minLines: minLines, + maxLines: maxLines, + ) + : const Text(''); + }, + ), + ), + ), + ); +} diff --git a/super_editor/test/super_textfield/super_textfield_robot.dart b/super_editor/test/super_textfield/super_textfield_robot.dart deleted file mode 100644 index 7c0d662b44..0000000000 --- a/super_editor/test/super_textfield/super_textfield_robot.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_text_layout/super_text_layout.dart'; - -/// Extensions on [WidgetTester] for interacting with a [SuperTextField] the way -/// a user would. -extension SuperTextFieldRobot on WidgetTester { - /// Taps to place a caret at the given [offset]. - /// - /// {@template supertextfield_finder} - /// By default, this method expects a single [SuperTextField] in the widget tree and - /// finds it `byType`. To specify one [SuperTextField] among many, pass a [superTextFieldFinder]. - /// {@endtemplate} - Future placeCaretInSuperTextField(int offset, - [Finder? superTextFieldFinder, TextAffinity affinity = TextAffinity.downstream]) async { - final fieldFinder = _findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); - final match = fieldFinder.evaluate().single.widget; - bool found = false; - - if (match is SuperDesktopTextField) { - final didTap = - await _tapAtTextPositionOnDesktop(state(fieldFinder), offset, affinity); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); - } - found = true; - } else if (match is SuperAndroidTextField) { - final didTap = - await _tapAtTextPositionOnAndroid(state(fieldFinder), offset, affinity); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); - } - found = true; - } else if (match is SuperIOSTextField) { - final didTap = await _tapAtTextPositionOnIOS(state(fieldFinder), offset, affinity); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); - } - found = true; - } - - if (found) { - await pumpAndSettle(); - } else { - throw Exception("Couldn't find a SuperTextField with the given Finder: $fieldFinder"); - } - } - - /// Double taps in a [SuperTextField] at the given [offset] - /// - /// {@macro supertextfield_finder} - Future doubleTapAtSuperTextField(int offset, - [Finder? superTextFieldFinder, TextAffinity affinity = TextAffinity.downstream]) async { - // TODO: De-duplicate this behavior with placeCaretInSuperTextField - final fieldFinder = _findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); - final match = fieldFinder.evaluate().single.widget; - - if (match is SuperDesktopTextField) { - final superDesktopTextField = state(fieldFinder); - - bool didTap = await _tapAtTextPositionOnDesktop(superDesktopTextField, offset, affinity); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); - } - await pump(kDoubleTapMinTime); - - didTap = await _tapAtTextPositionOnDesktop(superDesktopTextField, offset, affinity); - if (!didTap) { - throw Exception("The desired text offset wasn't tappable in SuperTextField: $offset"); - } - - await pumpAndSettle(); - - return; - } - - if (match is SuperAndroidTextField) { - throw Exception("Entering text on an Android SuperTextField is not yet supported"); - } - - if (match is SuperIOSTextField) { - throw Exception("Entering text on an iOS SuperTextField is not yet supported"); - } - - throw Exception("Couldn't find a SuperTextField with the given Finder: $fieldFinder"); - } - - Future _tapAtTextPositionOnDesktop(SuperDesktopTextFieldState textField, int offset, - [TextAffinity textAffinity = TextAffinity.downstream]) async { - final textFieldBox = textField.context.findRenderObject() as RenderBox; - return await _tapAtTextPositionInTextLayout(textField.textLayout, textFieldBox, offset, textAffinity); - } - - Future _tapAtTextPositionOnAndroid(SuperAndroidTextFieldState textField, int offset, - [TextAffinity textAffinity = TextAffinity.downstream]) async { - final textFieldBox = textField.context.findRenderObject() as RenderBox; - return await _tapAtTextPositionInTextLayout(textField.textLayout, textFieldBox, offset, textAffinity); - } - - Future _tapAtTextPositionOnIOS(SuperIOSTextFieldState textField, int offset, - [TextAffinity textAffinity = TextAffinity.downstream]) async { - final textFieldBox = textField.context.findRenderObject() as RenderBox; - return await _tapAtTextPositionInTextLayout(textField.textLayout, textFieldBox, offset, textAffinity); - } - - Future _tapAtTextPositionInTextLayout(TextLayout textLayout, RenderBox textFieldBox, int offset, - [TextAffinity textAffinity = TextAffinity.downstream]) async { - final textPositionOffset = textLayout.getOffsetForCaret( - TextPosition(offset: offset, affinity: textAffinity), - ); - - // When upgrading Superlist to Flutter 3, some tests showed a caret offset - // dy of -0.2. This didn't happen everywhere, but it did happen some places. - // Until we get to the bottom of this issue, we'll add a constant offset to - // make up for this. - Offset adjustedOffset = textPositionOffset + const Offset(0, 0.2); - - // There's a problem on Windows and Linux where we get -0.0 instead of 0.0. - // We adjust the offset to get rid of the -0.0, because a -0.0 fails the - // Rect bounds check. (https://github.com/flutter/flutter/issues/100033) - adjustedOffset = Offset( - adjustedOffset.dx, - // I tried checking "== -0.0" but it didn't catch the problem. This - // approach looks for an arbitrarily small epsilon and then interprets - // any such bounds as zero. - adjustedOffset.dy.abs() < 1e-6 ? 0.0 : adjustedOffset.dy, - ); - - if (adjustedOffset.dx == textFieldBox.size.width) { - adjustedOffset += const Offset(-10, 0); - } - - if (!textFieldBox.size.contains(adjustedOffset)) { - print("Couldn't tap at $adjustedOffset in text field with size ${textFieldBox.size}"); - return false; - } - - final globalTapOffset = adjustedOffset + textFieldBox.localToGlobal(Offset.zero); - await tapAt(globalTapOffset); - return true; - } - - Future selectSuperTextFieldText(int start, int end, [Finder? superTextFieldFinder]) async { - final fieldFinder = _findInnerPlatformTextField(superTextFieldFinder ?? find.byType(SuperTextField)); - final match = fieldFinder.evaluate().single.widget; - - if (match is SuperDesktopTextField) { - final didSelectText = await _selectTextOnDesktop(state(fieldFinder), start, end); - if (!didSelectText) { - throw Exception("One or both of the desired text offsets weren't tappable in SuperTextField: $start -> $end"); - } - - // Pump and settle so that the gesture recognizer doesn't retain pending timers. - await pumpAndSettle(); - - return; - } - - if (match is SuperAndroidTextField) { - throw Exception("Selecting text on an Android SuperTextField is not yet supported"); - } - - if (match is SuperIOSTextField) { - throw Exception("Selecting text on an iOS SuperTextField is not yet supported"); - } - - throw Exception("Couldn't find a SuperTextField with the given Finder: $fieldFinder"); - } - - Future _selectTextOnDesktop(SuperDesktopTextFieldState textField, int start, int end) async { - final startTextPositionOffset = textField.textLayout.getOffsetForCaret(TextPosition(offset: start)); - final endTextPositionOffset = textField.textLayout.getOffsetForCaret(TextPosition(offset: end)); - final textFieldBox = textField.context.findRenderObject() as RenderBox; - - // When upgrading Superlist to Flutter 3, some tests showed a caret offset - // dy of -0.2. This didn't happen everywhere, but it did happen some places. - // Until we get to the bottom of this issue, we'll add a constant offset to - // make up for this. - Offset adjustedStartOffset = startTextPositionOffset + const Offset(0, 0.2); - Offset adjustedEndOffset = endTextPositionOffset + const Offset(0, 0.2); - - // There's a problem on Windows and Linux where we get -0.0 instead of 0.0. - // We adjust the offset to get rid of the -0.0, because a -0.0 fails the - // Rect bounds check. (https://github.com/flutter/flutter/issues/100033) - adjustedStartOffset = Offset( - adjustedStartOffset.dx, - // I tried checking "== -0.0" but it didn't catch the problem. This - // approach looks for an arbitrarily small epsilon and then interprets - // any such bounds as zero. - adjustedStartOffset.dy.abs() < 1e-6 ? 0.0 : adjustedStartOffset.dy, - ); - adjustedEndOffset = Offset( - adjustedEndOffset.dx, - adjustedEndOffset.dy.abs() < 1e-6 ? 0.0 : adjustedEndOffset.dy, - ); - - if (!textFieldBox.size.contains(adjustedStartOffset)) { - return false; - } - if (!textFieldBox.size.contains(adjustedEndOffset)) { - return false; - } - - final globalStartDragOffset = adjustedStartOffset + textFieldBox.localToGlobal(Offset.zero); - final globalEndDragOffset = adjustedEndOffset + textFieldBox.localToGlobal(Offset.zero); - - await dragFrom(globalStartDragOffset, globalEndDragOffset - globalStartDragOffset); - - return true; - } - - Finder _findInnerPlatformTextField(Finder rootFieldFinder) { - final rootMatches = rootFieldFinder.evaluate(); - if (rootMatches.isEmpty) { - throw Exception("Couldn't find a super text field variant with the given finder: $rootFieldFinder"); - } - if (rootMatches.length > 1) { - throw Exception("Found more than 1 super text field match with finder: $rootFieldFinder"); - } - - final rootMatch = rootMatches.single.widget; - if (rootMatch is! SuperTextField) { - // The match isn't a generic SuperTextField. Assume that it's a platform - // specific super text field, which is what we're looking for. Return it. - return rootFieldFinder; - } - - final desktopFieldCandidates = - find.descendant(of: rootFieldFinder, matching: find.byType(SuperDesktopTextField)).evaluate(); - if (desktopFieldCandidates.isNotEmpty) { - return find.descendant(of: rootFieldFinder, matching: find.byType(SuperDesktopTextField)); - } - - final androidFieldCandidates = - find.descendant(of: rootFieldFinder, matching: find.byType(SuperAndroidTextField)).evaluate(); - if (androidFieldCandidates.isNotEmpty) { - return find.descendant(of: rootFieldFinder, matching: find.byType(SuperAndroidTextField)); - } - - final iosFieldCandidates = - find.descendant(of: rootFieldFinder, matching: find.byType(SuperIOSTextField)).evaluate(); - if (iosFieldCandidates.isNotEmpty) { - return find.descendant(of: rootFieldFinder, matching: find.byType(SuperIOSTextField)); - } - - throw Exception( - "Couldn't find the platform-specific super text field within the root SuperTextField. Root finder: $rootFieldFinder"); - } -} diff --git a/super_editor/test/super_textfield/super_textfield_scrolling_test.dart b/super_editor/test/super_textfield/super_textfield_scrolling_test.dart new file mode 100644 index 0000000000..e5fc601473 --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_scrolling_test.dart @@ -0,0 +1,242 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/infrastructure/text_input.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +import '../test_tools.dart'; + +void main() { + group("SuperTextField > scrolling >", () { + group("single line >", () { + testWidgetsOnAllPlatforms("scroll bar doesn't appear when empty", (tester) async { + await _pumpSingleLineTextField(tester); + + // The bug that originally caused an issue with empty scrolling (#1749) didn't have + // a scrollable distance until the 2nd frame. Therefore, we pump one extra frame. + await tester.pump(); + await tester.pump(); + + // Ensure that the text field isn't scrollable (the content shouldn't exceed the viewport). + expect(SuperTextFieldInspector.hasScrollableExtent(), isFalse); + }); + + testWidgetsOnAllPlatforms("scroll bar doesn't appear when viewport width slightly shrinks", (tester) async { + // Display a text field without an icon to the right. + await _pumpSingleLineTextField(tester); + + // Pump a new frame where we add an icon to the right of the text field, slightly + // reducing the width of the text field. + await _pumpSingleLineTextField(tester, showClearIcon: true); + + // Ensure that the text field isn't scrollable (the content shouldn't exceed the viewport). + expect(SuperTextFieldInspector.hasScrollableExtent(), isFalse); + }); + + testWidgetsOnAllPlatforms("auto scrolls when the user types beyond viewport edge", (tester) async { + const textFieldWidth = 400.0; + + final controller = AttributedTextEditingController(); + await _pumpSingleLineTextField( + tester, + controller: controller, + width: textFieldWidth, + ); + + // Place the caret at the beginning of the text. + await tester.placeCaretInSuperTextField(0); + + // Ensure the text field has a selection. + expect(SuperTextFieldInspector.findSelection()!.isValid, isTrue); + + // Type characters to the right, well beyond the right edge of the + // viewport. Ensure that the caret remains visible at all times. + const textToType = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna."; + for (int i = 0; i < textToType.length; i += 1) { + await tester.typeImeText(textToType[i]); + await tester.pump(); + + // Ensure that the caret is still visible. + // TODO: Change lessThanOrEqualTo() to strictly lessThan() after #1770 + expect(SuperTextFieldInspector.findCaretRectInViewport()!.left, lessThanOrEqualTo(textFieldWidth), + reason: "Failed to auto-scroll on character $i - '${textToType[i]}'"); + } + }); + + testWidgetsOnArbitraryDesktop("auto scrolls when caret moves beyond viewport edge", (tester) async { + const textFieldWidth = 400.0; + + await _pumpSingleLineTextField( + tester, + controller: AttributedTextEditingController( + text: AttributedText( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget."), + ), + width: textFieldWidth, + ); + + // Place the caret at the beginning of the text. + await tester.placeCaretInSuperTextField(0); + + // Ensure the text field has a selection. + expect(SuperTextFieldInspector.findSelection()!.isValid, isTrue); + + // Move the caret to the right a large number of times, such that it's + // guaranteed to push beyond the right edge of the viewport. Ensure that + // the caret remains visible at all times. + for (int i = 0; i < 100; i += 1) { + await tester.pressRightArrow(); + await tester.pump(); + + // Ensure that the caret is still visible. + // TODO: Change lessThanOrEqualTo() to strictly lessThan() after #1770 + expect(SuperTextFieldInspector.findCaretRectInViewport()!.left, lessThanOrEqualTo(textFieldWidth)); + } + }); + + testWidgetsOnMobile("auto scrolls when caret is dragged into auto-scroll region", (tester) async { + const textFieldWidth = 400.0; + + await _pumpSingleLineTextField( + tester, + controller: AttributedTextEditingController( + text: AttributedText( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id."), + ), + width: textFieldWidth, + ); + + // Place the caret at the beginning of the text. + await tester.placeCaretInSuperTextField(0); + + // Ensure the text field has a selection. + expect(SuperTextFieldInspector.findSelection()!.isValid, isTrue); + + // Drag the caret from the left side of the text field to the right side + // of the text field and hold it in the auto-scroll region. + final drag = await tester.dragCaretByDistanceInSuperTextField(const Offset(textFieldWidth, 0)); + + // While holding the caret in the auto-scroll region, we expect the scroll offset to + // increase on every frame, until we get to the end. + var scrollOffset = SuperTextFieldInspector.findScrollOffset()!; + var previousScrollOffset = scrollOffset; + do { + previousScrollOffset = scrollOffset; + + // Pump a frame to auto-scroll to the right. Pass a duration that's long enough + // to pass the minimum auto-scroll time in SuperTextField. + await tester.pump(const Duration(milliseconds: 50)); + + scrollOffset = SuperTextFieldInspector.findScrollOffset()!; + + // Ensure that we haven't exceeded the max scroll offset. If there's a bug that allows + // us to exceed the max scroll offset, this test might run forever. + expect( + SuperTextFieldInspector.findScrollOffset(), + lessThanOrEqualTo(SuperTextFieldInspector.findMaxScrollOffset()!), + reason: + "While auto-scrolling to the right, we exceeded the max scroll offset of the text field, which should never be allowed to happen", + ); + } while (scrollOffset > previousScrollOffset); + + // Now that we've auto-scrolled as far to the right as possible, we should be + // at the max scroll offset. + expect(SuperTextFieldInspector.findScrollOffset(), SuperTextFieldInspector.findMaxScrollOffset()); + + // Drag all the way back to the left side of the text field. + // + // +20 to get past the auto-scroll boundary and then also add some + // scroll speed for a faster test run. Note: it seems that we need + // at least +2 to even trigger aut-scroll. The rest is for speed. + // This might be due to touch slop, or possibly some other gesture + // detail that impacts how far we initially dragged to the right. + await tester.dragContinuation(drag, const Offset(-(textFieldWidth + 20), 0)); + + // Now that we auto-scrolled all the way to the right, auto-scroll back all the way to + // the left. + do { + previousScrollOffset = scrollOffset; + + // Pump a frame to auto-scroll to the right. Pass a duration that's long enough + // to pass the minimum auto-scroll time in SuperTextField. + await tester.pump(const Duration(milliseconds: 50)); + + scrollOffset = SuperTextFieldInspector.findScrollOffset()!; + + // Ensure that we haven't exceeded the min scroll offset. If there's a bug that allows + // us to exceed the min scroll offset, this test might run forever. + expect( + SuperTextFieldInspector.findScrollOffset(), + greaterThanOrEqualTo(0), + reason: + "While auto-scrolling to the left, we exceeded the min scroll offset of the text field, which should never be allowed to happen", + ); + } while (previousScrollOffset > scrollOffset); + + // Now that we've auto-scrolled as far to the left as possible, we should be + // at the min scroll offset. + expect(SuperTextFieldInspector.findScrollOffset(), 0); + + // Release the gesture so the test can end. + await drag.up(); + }); + }); + }); +} + +Future _pumpSingleLineTextField( + WidgetTester tester, { + AttributedTextEditingController? controller, + double? width, + bool showClearIcon = false, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: width ?? 400, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: SuperTextField( + textController: controller, + hintBuilder: _createHintBuilder("Hint text..."), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), + minLines: 1, + maxLines: 1, + inputSource: TextInputSource.ime, + ), + ), + if (showClearIcon) // + const Padding( + padding: EdgeInsets.only(right: 8), + child: Icon(Icons.clear, size: 16), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); +} + +WidgetBuilder _createHintBuilder(String hintText) { + return (BuildContext context) { + return Text( + hintText, + style: const TextStyle(color: Colors.grey), + ); + }; +} diff --git a/super_editor/test/super_textfield/super_textfield_test.dart b/super_editor/test/super_textfield/super_textfield_test.dart index a548e910a2..ddf4b14591 100644 --- a/super_editor/test/super_textfield/super_textfield_test.dart +++ b/super_editor/test/super_textfield/super_textfield_test.dart @@ -1,11 +1,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_text_field_test.dart'; import 'package:super_text_layout/super_text_layout.dart'; -import '../test_tools.dart'; - void main() { group("SuperTextField", () { group("configures for", () { @@ -95,76 +96,193 @@ void main() { }); group("on mobile", () { - group("configures inner textfield textInputAction for newline when it's multiline", () { - testWidgetsOnAndroid('(on Android)', (tester) async { - await tester.pumpWidget( - _buildScaffold( - child: const SuperTextField( - minLines: 10, - maxLines: 10, - ), + testWidgetsOnAndroid("configures inner textfield textInputAction for newline when it's multiline", + (tester) async { + await tester.pumpWidget( + _buildScaffold( + child: const SuperTextField( + minLines: 10, + maxLines: 10, ), - ); + ), + ); - final innerTextField = tester.widget(find.byType(SuperAndroidTextField).first); + final innerTextField = tester.widget(find.byType(SuperAndroidTextField).first); - // Ensure inner textfield action is configured to newline - // so we are able to receive new lines - expect(innerTextField.textInputAction, TextInputAction.newline); - }); + // Ensure inner textfield action is configured to newline + // so we are able to receive new lines + expect(innerTextField.textInputAction, TextInputAction.newline); + }); - testWidgetsOnIos('(on iOS)', (tester) async { - await tester.pumpWidget( - _buildScaffold( - child: const SuperTextField( - minLines: 10, - maxLines: 10, - ), + testWidgetsOnIos("configures inner textfield textInputAction for newline when it's multiline", (tester) async { + await tester.pumpWidget( + _buildScaffold( + child: const SuperTextField( + minLines: 10, + maxLines: 10, ), - ); + ), + ); - final innerTextField = tester.widget(find.byType(SuperIOSTextField).first); + final innerTextField = tester.widget(find.byType(SuperIOSTextField).first); - // Ensure inner textfield action is configured to newline - // so we are able to receive new lines - expect(innerTextField.textInputAction, TextInputAction.newline); - }); + // Ensure inner textfield action is configured to newline + // so we are able to receive new lines + expect(innerTextField.textInputAction, TextInputAction.newline); }); - group("configures inner textfield textInputAction for done when it's singleline", () { - testWidgetsOnAndroid('(on Android)', (tester) async { - await tester.pumpWidget( - _buildScaffold( - child: const SuperTextField( - minLines: 1, - maxLines: 1, - ), + testWidgetsOnAndroid("configures inner textfield textInputAction for done when it's singleline", (tester) async { + await tester.pumpWidget( + _buildScaffold( + child: const SuperTextField( + minLines: 1, + maxLines: 1, ), - ); + ), + ); - final innerTextField = tester.widget(find.byType(SuperAndroidTextField).first); + final innerTextField = tester.widget(find.byType(SuperAndroidTextField).first); - // Ensure inner textfield action is configured to done - // because we should NOT receive new lines - expect(innerTextField.textInputAction, TextInputAction.done); - }); + // Ensure inner textfield action is configured to done + // because we should NOT receive new lines + expect(innerTextField.textInputAction, TextInputAction.done); + }); - testWidgetsOnIos('(on iOS)', (tester) async { - await tester.pumpWidget( - _buildScaffold( - child: const SuperTextField( - minLines: 1, - maxLines: 1, - ), + testWidgetsOnIos("configures inner textfield textInputAction for done when it's singleline", (tester) async { + await tester.pumpWidget( + _buildScaffold( + child: const SuperTextField( + minLines: 1, + maxLines: 1, ), - ); + ), + ); - final innerTextField = tester.widget(find.byType(SuperIOSTextField).first); + final innerTextField = tester.widget(find.byType(SuperIOSTextField).first); - // Ensure inner textfield action is configured to done - // because we should NOT receive new lines - expect(innerTextField.textInputAction, TextInputAction.done); + // Ensure inner textfield action is configured to done + // because we should NOT receive new lines + expect(innerTextField.textInputAction, TextInputAction.done); + }); + + testWidgetsOnIos('applies keyboard appearance', (tester) async { + await tester.pumpWidget( + _buildScaffold( + child: SuperTextField( + textController: ImeAttributedTextEditingController( + keyboardAppearance: Brightness.dark, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Holds the keyboard appearance sent to the platform. + String? keyboardAppearance; + + // Intercept messages sent to the platform. + tester.binding.defaultBinaryMessenger.setMockMessageHandler(SystemChannels.textInput.name, (message) async { + final methodCall = const JSONMethodCodec().decodeMethodCall(message); + if (methodCall.method == 'TextInput.setClient') { + final params = methodCall.arguments[1] as Map; + keyboardAppearance = params['keyboardAppearance']; + } + return null; }); + + // Tap the text field to show the software keyboard. + await tester.placeCaretInSuperTextField(0); + + // Ensure the given keyboardAppearance was applied. + expect(keyboardAppearance, 'Brightness.dark'); + }); + + testWidgetsOnIos('updates keyboard appearance', (tester) async { + final controller = ImeAttributedTextEditingController( + keyboardAppearance: Brightness.light, + ); + + await tester.pumpWidget( + _buildScaffold( + child: SuperTextField( + textController: controller, + ), + ), + ); + await tester.pumpAndSettle(); + + // Holds the keyboard appearance sent to the platform. + String? keyboardAppearance; + + // Intercept the setClient message sent to the platform. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setClient', + (methodCall) { + final params = methodCall.arguments[1] as Map; + keyboardAppearance = params['keyboardAppearance']; + return null; + }, + ); + + // Tap the text field to show the software keyboard with the light appearance. + await tester.placeCaretInSuperTextField(0); + + // Ensure the initial keyboardAppearance was applied. + expect(keyboardAppearance, 'Brightness.light'); + + // Change the keyboard appearance from light to dark. + controller.updateTextInputConfiguration( + viewId: 0, + keyboardAppearance: Brightness.dark, + ); + await tester.pump(); + + // Ensure the given keyboardAppearance was applied. + expect(keyboardAppearance, 'Brightness.dark'); + }); + + testWidgetsOnIos('updates keyboard appearance when not attached to IME', (tester) async { + final controller = ImeAttributedTextEditingController( + keyboardAppearance: Brightness.light, + ); + + await tester.pumpWidget( + _buildScaffold( + child: SuperTextField( + textController: controller, + ), + ), + ); + await tester.pumpAndSettle(); + + // Holds the keyboard appearance sent to the platform. + String? keyboardAppearance; + + // Intercept the setClient message sent to the platform. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setClient', + (methodCall) { + final params = methodCall.arguments[1] as Map; + keyboardAppearance = params['keyboardAppearance']; + return null; + }, + ); + + // Change the keyboard appearance from light to dark while detached from IME. + controller.updateTextInputConfiguration( + viewId: 0, + keyboardAppearance: Brightness.dark, + ); + + // Tap the text field to show the software keyboard. + await tester.placeCaretInSuperTextField(0); + + // Ensure the initial keyboardAppearance was dark. + expect(keyboardAppearance, 'Brightness.dark'); }); }); @@ -200,6 +318,34 @@ void main() { expect(_isCaretPresent(tester), isTrue); }); + + testWidgetsOnAllPlatforms( + "is inserted automatically when the field is initialized with a focused node used by another widget", + (tester) async { + final node = FocusNode()..requestFocus(); + + await tester.pumpWidget( + _buildScaffold( + child: Focus( + focusNode: node, + child: const SizedBox.shrink(), + ), + ), + ); + + // Pumps a second widget tree, to simulate switching the FocusNode + // from one widget to another. + await tester.pumpWidget( + _buildScaffold( + child: SuperTextField( + focusNode: node, + ), + ), + ); + await tester.pump(); + + expect(_isCaretPresent(tester), isTrue); + }); }); group('padding', () { @@ -217,7 +363,7 @@ void main() { await tester.pumpAndSettle(); final textFieldRect = tester.getRect(find.byType(SuperTextField)); - final contentRect = tester.getRect(find.byType(SuperTextWithSelection)); + final contentRect = tester.getRect(find.byType(SuperText)); // Ensure padding was applied. expect(contentRect.left - textFieldRect.left, 5); @@ -244,7 +390,7 @@ void main() { // Change the text so the content height is greater // than the initial content height. controller.text = AttributedText( - text: """ + """ This is a multi-line @@ -253,7 +399,7 @@ SuperTextField ); await tester.pumpAndSettle(); - final textSize = tester.getSize(find.byType(SuperTextWithSelection)); + final textSize = tester.getSize(find.byType(SuperText)); final textFieldSize = tester.getSize(find.byType(SuperTextField)); // Ensure the text field height is big enough to display the whole content. @@ -280,7 +426,7 @@ SuperTextField // Change the text, so the content height is greater // than the initial content height. controller.text = AttributedText( - text: """ + """ This is a multi-line diff --git a/super_editor/test/super_textfield/super_textfield_text_alignment_test.dart b/super_editor/test/super_textfield/super_textfield_text_alignment_test.dart index 6ae8b5e13f..7ca54848df 100644 --- a/super_editor/test/super_textfield/super_textfield_text_alignment_test.dart +++ b/super_editor/test/super_textfield/super_textfield_text_alignment_test.dart @@ -1,190 +1,12 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; import 'package:super_editor/super_editor.dart'; -import '../test_tools.dart'; - -void main() { - // These golden tests are being skipped on macOS because the text seems to be - // a bit bigger in this platform, causing the tests to fail. +void main() { group('SuperTextField', () { - group('single line', () { - group('displays different alignments', () { - testGoldens('(on Android)', (tester) async { - await _pumpScaffold( - tester, - children: [ - _buildSuperTextField( - text: "Left", - textAlign: TextAlign.left, - maxLines: 1, - configuration: SuperTextFieldPlatformConfiguration.android, - ), - _buildSuperTextField( - text: "Center", - textAlign: TextAlign.center, - maxLines: 1, - configuration: SuperTextFieldPlatformConfiguration.android, - ), - _buildSuperTextField( - text: "Right", - textAlign: TextAlign.right, - maxLines: 1, - configuration: SuperTextFieldPlatformConfiguration.android, - ), - ], - ); - - await screenMatchesGolden(tester, 'super_textfield_alignments_singleline_android'); - }, skip: Platform.isMacOS); - - testGoldens('(on iOS)', (tester) async { - await _pumpScaffold( - tester, - children: [ - _buildSuperTextField( - text: "Left", - textAlign: TextAlign.left, - maxLines: 1, - configuration: SuperTextFieldPlatformConfiguration.iOS, - ), - _buildSuperTextField( - text: "Center", - textAlign: TextAlign.center, - maxLines: 1, - configuration: SuperTextFieldPlatformConfiguration.iOS, - ), - _buildSuperTextField( - text: "Right", - textAlign: TextAlign.right, - maxLines: 1, - configuration: SuperTextFieldPlatformConfiguration.iOS, - ), - ], - ); - - await screenMatchesGolden(tester, 'super_textfield_alignments_singleline_ios'); - }, skip: Platform.isMacOS); - - testGoldens('(on Desktop)', (tester) async { - await _pumpScaffold( - tester, - children: [ - _buildSuperTextField( - text: "Left", - textAlign: TextAlign.left, - maxLines: 1, - configuration: SuperTextFieldPlatformConfiguration.desktop, - ), - _buildSuperTextField( - text: "Center", - textAlign: TextAlign.center, - maxLines: 1, - configuration: SuperTextFieldPlatformConfiguration.desktop, - ), - _buildSuperTextField( - text: "Right", - textAlign: TextAlign.right, - maxLines: 1, - configuration: SuperTextFieldPlatformConfiguration.desktop, - ), - ], - ); - - await screenMatchesGolden(tester, 'super_textfield_alignments_singleline_desktop'); - }, skip: Platform.isMacOS); - }); - }); - group('multi line', () { const multilineText = 'First Line\nSecond Line\nThird Line\nFourth Line'; - group('displays different alignments', () { - testGoldens('(on Android)', (tester) async { - await _pumpScaffold( - tester, - children: [ - _buildSuperTextField( - text: multilineText, - textAlign: TextAlign.left, - maxLines: 4, - configuration: SuperTextFieldPlatformConfiguration.android, - ), - _buildSuperTextField( - text: multilineText, - textAlign: TextAlign.center, - maxLines: 4, - configuration: SuperTextFieldPlatformConfiguration.android, - ), - _buildSuperTextField( - text: multilineText, - textAlign: TextAlign.right, - maxLines: 4, - configuration: SuperTextFieldPlatformConfiguration.android, - ), - ], - ); - - await screenMatchesGolden(tester, 'super_textfield_alignments_multiline_android'); - }, skip: Platform.isMacOS); - - testGoldens('(on iOS)', (tester) async { - await _pumpScaffold( - tester, - children: [ - _buildSuperTextField( - text: multilineText, - textAlign: TextAlign.left, - maxLines: 4, - configuration: SuperTextFieldPlatformConfiguration.iOS, - ), - _buildSuperTextField( - text: multilineText, - textAlign: TextAlign.center, - maxLines: 4, - configuration: SuperTextFieldPlatformConfiguration.iOS, - ), - _buildSuperTextField( - text: multilineText, - textAlign: TextAlign.right, - maxLines: 4, - configuration: SuperTextFieldPlatformConfiguration.iOS, - ), - ], - ); - - await screenMatchesGolden(tester, 'super_textfield_alignments_multiline_ios'); - }, skip: Platform.isMacOS); - - testGoldens('(on Desktop)', (tester) async { - await _pumpScaffold( - tester, - children: [ - _buildSuperTextField( - text: multilineText, - textAlign: TextAlign.left, - maxLines: 4, - configuration: SuperTextFieldPlatformConfiguration.desktop, - ), - _buildSuperTextField( - text: multilineText, - textAlign: TextAlign.center, - maxLines: 4, - configuration: SuperTextFieldPlatformConfiguration.desktop, - ), - _buildSuperTextField( - text: multilineText, - textAlign: TextAlign.right, - maxLines: 4, - configuration: SuperTextFieldPlatformConfiguration.desktop, - ), - ], - ); - - await screenMatchesGolden(tester, 'super_textfield_alignments_multiline_desktop'); - }); - }, skip: Platform.isMacOS); testWidgetsOnAllPlatforms('makes scrollview fill all the field width', (tester) async { await _pumpScaffold( @@ -212,12 +34,12 @@ void main() { Widget _buildSuperTextField({ required String text, - required TextAlign textAlign, - SuperTextFieldPlatformConfiguration? configuration, + required TextAlign textAlign, + SuperTextFieldPlatformConfiguration? configuration, int? maxLines, }) { final controller = AttributedTextEditingController( - text: AttributedText(text: text), + text: AttributedText(text), ); return SizedBox( @@ -227,8 +49,8 @@ Widget _buildSuperTextField({ textController: controller, textAlign: textAlign, maxLines: maxLines, - minLines: 1, - lineHeight: 20, + minLines: 1, + lineHeight: 20, textStyleBuilder: (_) { return const TextStyle( color: Colors.black, diff --git a/super_editor/test/super_textfield/super_textfield_theme_test.dart b/super_editor/test/super_textfield/super_textfield_theme_test.dart new file mode 100644 index 0000000000..0ae872f121 --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_theme_test.dart @@ -0,0 +1,129 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/src/super_textfield/android/android_textfield.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/attributed_text_editing_controller.dart'; +import 'package:super_editor/src/super_textfield/input_method_engine/_ime_text_editing_controller.dart'; +import 'package:super_editor/src/super_textfield/ios/ios_textfield.dart'; +import 'package:super_editor/super_text_field_test.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +void main() { + group('SuperTextField', () { + testWidgetsOnIos('applies app theme to the popover toolbar', (tester) async { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + text: AttributedText('A single line textfield'), + ), + ); + + // Used to switch between dark/light mode. + ValueNotifier themeData = ValueNotifier(ThemeData.dark()); + + // Holds the popover theme's brightness. + Brightness? popoverBrightness; + + await _pumpTestAppScaffold( + tester, + theme: themeData, + child: SuperIOSTextField( + textController: controller, + caretStyle: const CaretStyle(), + selectionColor: Colors.blue, + handlesColor: Colors.blue, + popoverToolbarBuilder: (context, overlayController) { + popoverBrightness = Theme.of(context).brightness; + return const SizedBox(); + }, + ), + ); + + // Double tap to show the toolbar. + await tester.doubleTapAtSuperTextField(0, find.byType(SuperIOSTextField)); + + // Ensure the toolbar has a dark theme. + expect(popoverBrightness, Brightness.dark); + + // Switch the theme to light. + themeData.value = ThemeData.light(); + await tester.pump(); + + // Ensure the toolbar also switched to a light theme. + expect(popoverBrightness, Brightness.light); + }); + + testWidgetsOnAndroid('applies app theme to the popover toolbar', (tester) async { + final controller = ImeAttributedTextEditingController( + controller: AttributedTextEditingController( + text: AttributedText('A single line textfield'), + ), + ); + + // Used to switch between dark/light mode. + ValueNotifier themeData = ValueNotifier(ThemeData.dark()); + + // Holds the popover theme's brightness. + Brightness? popoverBrightness; + + await _pumpTestAppScaffold( + tester, + theme: themeData, + child: SuperAndroidTextField( + textController: controller, + caretStyle: const CaretStyle(), + selectionColor: Colors.blue, + handlesColor: Colors.blue, + popoverToolbarBuilder: (context, overlayController, config) { + popoverBrightness = Theme.of(context).brightness; + return const SizedBox(); + }, + ), + ); + + // Double tap to show the toolbar. + await tester.doubleTapAtSuperTextField(0, find.byType(SuperAndroidTextField)); + + // Ensure the toolbar has a dark theme. + expect(popoverBrightness, Brightness.dark); + + // Switch the theme to light. + themeData.value = ThemeData.light(); + await tester.pump(); + + // Ensure the toolbar also switched to a light theme. + expect(popoverBrightness, Brightness.light); + }); + }); +} + +/// Pumps a [Scaffold] which applies the given [theme]'s value to its body. +/// +/// The body rebuilds itself whenever the [theme]'s value changes. +Future _pumpTestAppScaffold( + WidgetTester tester, { + required ValueListenable theme, + required Widget child, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: theme, + builder: (context, _, __) { + // The theme must be placed below the MaterialApp + // so it isn't applied to the app's Overlay. + return Theme( + data: theme.value, + child: SizedBox( + width: 300, + child: child, + ), + ); + }, + ), + ), + ), + ); +} diff --git a/super_editor/test/super_textfield/type_into_super_textfield_test.dart b/super_editor/test/super_textfield/type_into_super_textfield_test.dart index 4e9d1f73b6..28af4cd512 100644 --- a/super_editor/test/super_textfield/type_into_super_textfield_test.dart +++ b/super_editor/test/super_textfield/type_into_super_textfield_test.dart @@ -3,9 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_robots/flutter_test_robots.dart'; import 'package:super_editor/super_editor.dart'; - -import 'super_textfield_inspector.dart'; -import 'super_textfield_robot.dart'; +import 'package:super_editor/super_text_field_test.dart'; void main() { group("SuperTextField", () { @@ -17,7 +15,7 @@ void main() { await tester.pumpAndSettle(); await tester.typeKeyboardText("Hello, World!"); - expect(SuperTextFieldInspector.findText().text, "Hello, World!"); + expect(SuperTextFieldInspector.findText().toPlainText(), "Hello, World!"); }); testWidgets("symbol characters", (tester) async { @@ -27,21 +25,21 @@ void main() { await tester.pumpAndSettle(); await tester.typeKeyboardText("@"); - expect(SuperTextFieldInspector.findText().text, "@"); + expect(SuperTextFieldInspector.findText().toPlainText(), "@"); }); testWidgets("in middle of existing text", (tester) async { await _pumpDesktopScaffold( tester, AttributedTextEditingController( - text: AttributedText(text: "hello world"), + text: AttributedText("hello world"), ), ); await tester.placeCaretInSuperTextField(6); await tester.pumpAndSettle(); await tester.typeKeyboardText("new "); - expect(SuperTextFieldInspector.findText().text, "hello new world"); + expect(SuperTextFieldInspector.findText().toPlainText(), "hello new world"); }); testWidgets("doesn't support Android", (tester) async { diff --git a/super_editor/test/test_runners.dart b/super_editor/test/test_runners.dart new file mode 100644 index 0000000000..e1bef9ae95 --- /dev/null +++ b/super_editor/test/test_runners.dart @@ -0,0 +1,309 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:logging/logging.dart' as logging; +import 'package:meta/meta.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/platforms/platform.dart'; +import 'package:super_editor/src/infrastructure/text_input.dart'; + +@isTestGroup +void groupWithLogging(String description, logging.Level logLevel, Set loggers, VoidCallback body) { + initLoggers(logLevel, loggers); + + group(description, body); + + deactivateLoggers(loggers); +} + +/// A widget test that runs a variant for every desktop platform as native and web, e.g., +/// Mac, Windows, Linux. +@isTestGroup +void testWidgetsOnDesktopAndWeb( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnDesktop(description, test, skip: skip, variant: variant); + testWidgetsOnWebDesktop(description, test, skip: skip, variant: variant); +} + +/// A widget test that runs a variant for every desktop platform on web, e.g., +/// Mac, Windows, Linux. +@isTestGroup +void testWidgetsOnWebDesktop( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnMacWeb(description, test, skip: skip, variant: variant); + testWidgetsOnWindowsWeb(description, test, skip: skip, variant: variant); + testWidgetsOnLinuxWeb(description, test, skip: skip, variant: variant); +} + +/// A widget test that runs a variant for every mobile platform on web, e.g., +/// iOS, Android. +@isTestGroup +void testWidgetsOnWebMobile( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnWebIos(description, test, skip: skip, variant: variant); + testWidgetsOnWebAndroid(description, test, skip: skip, variant: variant); +} + +@isTestGroup +void testWidgetsOnMacDesktopAndWeb( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnMac(description, test, skip: skip, variant: variant); + testWidgetsOnMacWeb(description, test, skip: skip, variant: variant); +} + +@isTestGroup +void testWidgetsOnMacWeb( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgets("$description (on MAC Web)", (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + debugIsWebOverride = WebPlatformOverride.web; + + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugIsWebOverride = null; + } + }, variant: variant, skip: skip); +} + +/// A widget test that runs a variant for Mac and iOS. +@isTestGroup +void testWidgetsOnApple( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnMac(description, test, variant: variant, skip: skip); + testWidgetsOnIos(description, test, variant: variant, skip: skip); +} + +@isTestGroup +void testWidgetsOnIosDeviceAndWeb( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnIos(description, test, skip: skip, variant: variant); + testWidgetsOnWebIos(description, test, skip: skip, variant: variant); +} + +@isTestGroup +void testWidgetsOnWebIos( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgets("$description (on iOS Web)", (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + debugIsWebOverride = WebPlatformOverride.web; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugIsWebOverride = null; + } + }, variant: variant, skip: skip); +} + +@isTestGroup +void testWidgetsOnAndroidDeviceAndWeb( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgetsOnAndroid(description, test, skip: skip, variant: variant); + testWidgetsOnWebAndroid(description, test, skip: skip, variant: variant); +} + +@isTestGroup +void testWidgetsOnWebAndroid( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgets("$description (on Android Web)", (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + debugIsWebOverride = WebPlatformOverride.web; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugIsWebOverride = null; + } + }, variant: variant, skip: skip); +} + +@isTestGroup +void testWidgetsOnWindowsWeb( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgets("$description (on Windows Web)", (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + debugIsWebOverride = WebPlatformOverride.web; + + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugIsWebOverride = null; + } + }, variant: variant, skip: skip); +} + +@isTestGroup +void testWidgetsOnLinuxWeb( + String description, + WidgetTesterCallback test, { + bool skip = false, + TestVariant variant = const DefaultTestVariant(), +}) { + testWidgets("$description (on Linux Web)", (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + debugIsWebOverride = WebPlatformOverride.web; + + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0; + + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugIsWebOverride = null; + } + }, variant: variant, skip: skip); +} + +/// A widget test that runs a variant for every desktop platform, e.g., +/// Mac, Windows, Linux, and for all [TextInputSource]s. +@isTestGroup +void testAllInputsOnDesktop( + String description, + InputModeTesterCallback test, { + bool skip = false, +}) { + testWidgetsOnDesktop("$description (keyboard)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.keyboard); + }, skip: skip); + + testWidgetsOnDesktop("$description (IME)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.ime); + }, skip: skip); +} + +/// A widget test that runs a variant for every platform +/// and for all [TextInputSource]s. +@isTestGroup +void testAllInputsOnAllPlatforms( + String description, + InputModeTesterCallback test, { + bool skip = false, +}) { + testWidgetsOnAllPlatforms("$description (keyboard)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.keyboard); + }, skip: skip); + + testWidgetsOnAllPlatforms("$description (IME)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.ime); + }, skip: skip); +} + +/// A widget test that runs as a Mac, and for all [TextInputSource]s. +@isTestGroup +void testAllInputsOnMac( + String description, + InputModeTesterCallback test, { + bool skip = false, +}) { + testWidgetsOnMac("$description (keyboard)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.keyboard); + }, skip: skip); + + testWidgetsOnMac("$description (IME)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.ime); + }, skip: skip); +} + +/// A widget test that runs as a Mac and iOS, and for all [TextInputSource]s. +@isTestGroup +void testAllInputsOnApple( + String description, + InputModeTesterCallback test, { + bool skip = false, +}) { + testWidgetsOnMac("$description (keyboard)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.keyboard); + }, skip: skip); + + testWidgetsOnMac("$description (IME)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.ime); + }, skip: skip); + + testWidgetsOnIos("$description (keyboard)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.keyboard); + }, skip: skip); + + testWidgetsOnIos("$description (IME)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.ime); + }, skip: skip); +} + +/// A widget test that runs a variant for Windows and Linux, and for all [TextInputSource]s. +@isTestGroup +void testAllInputsOnWindowsAndLinux( + String description, + InputModeTesterCallback test, { + bool skip = false, +}) { + testWidgetsOnWindowsAndLinux("$description (keyboard)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.keyboard); + }, skip: skip); + + testWidgetsOnWindowsAndLinux("$description (IME)", (WidgetTester tester) async { + await test(tester, inputSource: TextInputSource.ime); + }, skip: skip); +} + +typedef InputModeTesterCallback = Future Function( + WidgetTester widgetTester, { + required TextInputSource inputSource, +}); diff --git a/super_editor/test/test_tools.dart b/super_editor/test/test_tools.dart index 8d9208b175..1512e90072 100644 --- a/super_editor/test/test_tools.dart +++ b/super_editor/test/test_tools.dart @@ -1,298 +1,132 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:logging/logging.dart'; -import 'package:logging/logging.dart' as logging; -import 'package:super_editor/super_editor.dart'; - -void groupWithLogging(String description, Level logLevel, Set loggers, VoidCallback body) { - initLoggers(logLevel, loggers); - - group(description, body); - - deactivateLoggers(loggers); -} - -/// A widget test that runs a variant for every desktop platform, e.g., -/// Mac, Windows, Linux, and for all [DocumentInputSource]s. -void testAllInputsOnDesktop( - String description, - InputModeTesterCallback test, { - bool skip = false, -}) { - testWidgetsOnDesktop("$description (keyboard)", (WidgetTester tester) async { - await test(tester, inputSource: DocumentInputSource.keyboard); - }, skip: skip); - - testWidgetsOnDesktop("$description (IME)", (WidgetTester tester) async { - await test(tester, inputSource: DocumentInputSource.ime); - }, skip: skip); -} - -/// A widget test that runs as a Mac, and for all [DocumentInputSource]s. -void testAllInputsOnMac( - String description, - InputModeTesterCallback test, { - bool skip = false, -}) { - testWidgetsOnMac("$description (keyboard)", (WidgetTester tester) async { - await test(tester, inputSource: DocumentInputSource.keyboard); - }, skip: skip); - - testWidgetsOnMac("$description (IME)", (WidgetTester tester) async { - await test(tester, inputSource: DocumentInputSource.ime); - }, skip: skip); -} - -/// A widget test that runs a variant for Windows and Linux, and for all [DocumentInputSource]s. -void testAllInputsOnWindowsAndLinux( - String description, - InputModeTesterCallback test, { - bool skip = false, -}) { - testWidgetsOnWindowsAndLinux("$description (keyboard)", (WidgetTester tester) async { - await test(tester, inputSource: DocumentInputSource.keyboard); - }, skip: skip); - - testWidgetsOnWindowsAndLinux("$description (IME)", (WidgetTester tester) async { - await test(tester, inputSource: DocumentInputSource.ime); - }, skip: skip); -} - -typedef InputModeTesterCallback = Future Function( - WidgetTester widgetTester, { - required DocumentInputSource inputSource, -}); - -/// A widget test that runs a variant for every desktop platform, e.g., -/// Mac, Windows, Linux. -void testWidgetsOnDesktop( - String description, - WidgetTesterCallback test, { - bool skip = false, -}) { - testWidgetsOnMac("$description (on MAC)", test, skip: skip); - testWidgetsOnWindows("$description (on Windows)", test, skip: skip); - testWidgetsOnLinux("$description (on Linux)", test, skip: skip); -} - -/// A widget test that runs a variant for every mobile platform, e.g., -/// Android and iOS -void testWidgetsOnMobile( - String description, - WidgetTesterCallback test, { - bool skip = false, -}) { - testWidgetsOnAndroid("$description (on Android)", test, skip: skip); - testWidgetsOnIos("$description (on iOS)", test, skip: skip); -} - -/// A widget test that runs a variant for every platform, e.g., -/// Mac, Windows, Linux, Android and iOS. -void testWidgetsOnAllPlatforms( - String description, - WidgetTesterCallback test, { - bool skip = false, -}) { - testWidgetsOnMac("$description (on MAC)", test, skip: skip); - testWidgetsOnWindows("$description (on Windows)", test, skip: skip); - testWidgetsOnLinux("$description (on Linux)", test, skip: skip); - testWidgetsOnAndroid("$description (on Android)", test, skip: skip); - testWidgetsOnIos("$description (on iOS)", test, skip: skip); -} - -/// A widget test that runs a variant for Windows and Linux. -/// -/// This test method exists because many keyboard shortcuts are identical -/// between Windows and Linux. It would be superfluous to replicate so -/// many shortcut tests. Instead, this test method runs the given [test] -/// with a simulated Windows and Linux platform. -void testWidgetsOnWindowsAndLinux( - String description, - WidgetTesterCallback test, { - bool skip = false, -}) { - testWidgetsOnWindows("$description (on Windows)", test, skip: skip); - testWidgetsOnLinux("$description (on Linux)", test, skip: skip); +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/infrastructure/links.dart'; + +/// A [UrlLauncher] that logs each attempt to launch a URL, but doesn't +/// attempt to actually launch the URLs. +class TestUrlLauncher implements UrlLauncher { + final _urlLaunchLog = []; + + List get urlLaunchLog => _urlLaunchLog; + + void clearUrlLaunchLog() => _urlLaunchLog.clear(); + + @override + Future launchUrl(Uri url) async { + _urlLaunchLog.add(url); + return true; + } +} + +/// Extension on [WidgetTester] to make it easier to perform drag gestures. +extension DragExtensions on WidgetTester { + /// Simulates a user drag from [startLocation] to `startLocation + totalDragOffset`. + /// + /// Starts a gesture at [startLocation] and repeatedly drags the gesture + /// across [frameCount] frames, pumping a frame between each drag. + /// The gesture moves a distance each frame that's calculated as + /// `totalDragOffset / frameCount`. + /// + /// This method does not call `pumpAndSettle()`, so that the client can inspect + /// the app state immediately after the drag completes. + /// + /// The client must call [TestGesture.up] on the returned [TestGesture]. + Future dragByFrameCount({ + required Offset startLocation, + required Offset totalDragOffset, + int frameCount = 10, + }) async { + final dragGesture = await startGesture(startLocation); + await dragContinuation(dragGesture, totalDragOffset, frameCount: frameCount); + + return dragGesture; + } + + /// Simulates a user drag with an existing [gesture]. + /// + /// This is useful, for example, when simulating multiple drags without the user + /// lifting his finger. + Future dragContinuation( + TestGesture dragGesture, + Offset delta, { + int frameCount = 10, + }) async { + final dragPerFrame = Offset(delta.dx / frameCount, delta.dy / frameCount); + + for (int i = 0; i < frameCount; i += 1) { + await dragGesture.moveBy(dragPerFrame); + await pump(); + } + } } -/// A widget test that configures itself for an arbitrary desktop environment. +/// Compares two selections, ignoring selection affinities. /// -/// There's no guarantee which desktop environment is used. The purpose of this -/// test method is to cause all relevant configurations to setup for desktop, -/// without concern for any features that change between desktop platforms. -void testWidgetsOnArbitraryDesktop( - String description, - WidgetTesterCallback test, { - bool skip = false, -}) { - testWidgetsOnMac(description, test, skip: skip); -} - -/// A widget test that configures itself as a Mac platform before executing the -/// given [test], and nullifies the Mac configuration when the test is done. -void testWidgetsOnMac( - String description, - WidgetTesterCallback test, { - bool skip = false, -}) { - testWidgets(description, (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.macOS; +/// Some node positions, like [TextNodePosition], have a concept of affinity (upstream/downstream), +/// which is used when making particular selection decisions, but doesn't impact equivalency. +Matcher selectionEquivalentTo(DocumentSelection expectedSelection) => EquivalentSelectionMatcher(expectedSelection); - tester.binding.window - ..devicePixelRatioTestValue = 1.0 - ..platformDispatcher.textScaleFactorTestValue = 1.0; - - try { - await test(tester); - } finally { - debugDefaultTargetPlatformOverride = null; - } - }, skip: skip); -} - -/// A Dart test that configures the [Platform] to think its a [MacPlatform], -/// then runs the [realTest], and then sets the [Platform] back to null. +/// A [Matcher] that compares two selections, ignoring selection affinities. /// -/// [testOnMac] should only be used for unit tests and component tests that -/// care about the platform. In general, platform-specific behavior comes from -/// the widget tree, which should be tested with [testWidgetsOnMac]. In the -/// rare cases where a specific object, handler, or subsystem needs to be tested -/// in isolation, and it cares about the platform, you can use this test method. -void testOnMac( - String description, - VoidCallback realTest, { - bool skip = false, -}) { - test(description, () { - debugDefaultTargetPlatformOverride = TargetPlatform.macOS; - try { - realTest(); - } finally { - debugDefaultTargetPlatformOverride = null; +/// Some node positions, like [TextNodePosition], have a concept of affinity (upstream/downstream), +/// which is used when making particular selection decisions, but doesn't impact equivalency. +class EquivalentSelectionMatcher extends Matcher { + EquivalentSelectionMatcher( + this.expectedSelection, + ); + + final DocumentSelection expectedSelection; + + @override + Description describe(Description description) { + return description.add("given selection is equivalent to expected selection"); + } + + @override + bool matches(covariant Object target, Map matchState) { + return _calculateMismatchReason(target, matchState) == null; + } + + @override + Description describeMismatch( + covariant Object target, + Description mismatchDescription, + Map matchState, + bool verbose, + ) { + final mismatchReason = _calculateMismatchReason(target, matchState); + if (mismatchReason != null) { + mismatchDescription.add(mismatchReason); } - }, skip: skip); -} - -/// A widget test that configures itself as a Windows platform before executing the -/// given [test], and nullifies the Windows configuration when the test is done. -void testWidgetsOnWindows( - String description, - WidgetTesterCallback test, { - bool skip = false, -}) { - testWidgets(description, (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.windows; - - tester.binding.window - ..devicePixelRatioTestValue = 1.0 - ..platformDispatcher.textScaleFactorTestValue = 1.0; - - try { - await test(tester); - } finally { - debugDefaultTargetPlatformOverride = null; + return mismatchDescription; + } + + String? _calculateMismatchReason( + Object target, + Map matchState, + ) { + if (target is! DocumentSelection) { + return "the given target isn't a DocumentSelection"; } - }, skip: skip); -} -/// A Dart test that configures the [Platform] to think its a [WindowsPlatform], -/// then runs the [realTest], and then sets the [Platform] back to null. -/// -/// [testOnWindows] should only be used for unit tests and component tests that -/// care about the platform. In general, platform-specific behavior comes from -/// the widget tree, which should be tested with [testWidgetsOnWindows]. In the -/// rare cases where a specific object, handler, or subsystem needs to be tested -/// in isolation, and it cares about the platform, you can use this test method. -void testOnWindows( - String description, - VoidCallback realTest, { - bool skip = false, -}) { - test(description, () { - debugDefaultTargetPlatformOverride = TargetPlatform.windows; - try { - realTest(); - } finally { - debugDefaultTargetPlatformOverride = null; + if (target.base.nodeId != expectedSelection.base.nodeId) { + return "The selection doesn't start at the expected node.\nExpected: $expectedSelection\nActual: $target"; } - }, skip: skip); -} - -/// A widget test that configures itself as a Linux platform before executing the -/// given [test], and nullifies the Linux configuration when the test is done. -void testWidgetsOnLinux( - String description, - WidgetTesterCallback test, { - bool skip = false, -}) { - testWidgets(description, (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.linux; - - tester.binding.window - ..devicePixelRatioTestValue = 1.0 - ..platformDispatcher.textScaleFactorTestValue = 1.0; - try { - await test(tester); - } finally { - debugDefaultTargetPlatformOverride = null; + if (target.extent.nodeId != expectedSelection.extent.nodeId) { + return "The selection doesn't end at the expected node.\nExpected: $expectedSelection\nActual: $target"; } - }, skip: skip); -} -/// A Dart test that configures the [Platform] to think its a [LinuxPlatform], -/// then runs the [realTest], and then sets the [Platform] back to null. -/// -/// [testOnLinux] should only be used for unit tests and component tests that -/// care about the platform. In general, platform-specific behavior comes from -/// the widget tree, which should be tested with [testWidgetsOnLinux]. In the -/// rare cases where a specific object, handler, or subsystem needs to be tested -/// in isolation, and it cares about the platform, you can use this test method. -void testOnLinux( - String description, - VoidCallback realTest, { - bool skip = false, -}) { - test(description, () { - debugDefaultTargetPlatformOverride = TargetPlatform.linux; - try { - realTest(); - } finally { - debugDefaultTargetPlatformOverride = null; + if (!target.base.nodePosition.isEquivalentTo(expectedSelection.base.nodePosition)) { + // The base node positions aren't the same. + return 'The selection starts at the correct node, but at a wrong position.\nExpected: $expectedSelection\nActual: $target'; } - }, skip: skip); -} -/// A widget test that configures itself as a Android platform before executing the -/// given [test], and nullifies the Android configuration when the test is done. -void testWidgetsOnAndroid( - String description, - WidgetTesterCallback test, { - bool skip = false, -}) { - testWidgets(description, (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - try { - await test(tester); - } finally { - debugDefaultTargetPlatformOverride = null; + if (!target.extent.nodePosition.isEquivalentTo(expectedSelection.extent.nodePosition)) { + // The extent node positions aren't the same. + return 'The selection ends at the correct node, but at a wrong position.\nExpected: $expectedSelection\nActual: $target'; } - }, skip: skip); -} -/// A widget test that configures itself as a iOS platform before executing the -/// given [test], and nullifies the iOS configuration when the test is done. -void testWidgetsOnIos( - String description, - WidgetTesterCallback test, { - bool skip = false, -}) { - testWidgets(description, (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - try { - await test(tester); - } finally { - debugDefaultTargetPlatformOverride = null; - } - }, skip: skip); + return null; + } } diff --git a/super_editor/test_goldens/editor/components/_components_test_utils.dart b/super_editor/test_goldens/editor/components/_components_test_utils.dart index 0dbb9337e6..de2ed19927 100644 --- a/super_editor/test_goldens/editor/components/_components_test_utils.dart +++ b/super_editor/test_goldens/editor/components/_components_test_utils.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_test.dart'; void testComponentGolden(String description, Widget componentBuilder, String fileName) { - testGoldens(description, (tester) async { - tester.binding.window - ..physicalSizeTestValue = const Size(600, 400) - ..devicePixelRatioTestValue = 1.0; + testGoldensOnAndroid(description, (tester) async { + tester.view + ..physicalSize = const Size(600, 400) + ..devicePixelRatio = 1.0; tester.binding.platformDispatcher.textScaleFactorTestValue = 1.0; await tester.pumpWidget( diff --git a/super_editor/test_goldens/editor/components/goldens/paragraph_alignments.png b/super_editor/test_goldens/editor/components/goldens/paragraph_alignments.png index 467367abb3..79a1cf0203 100644 Binary files a/super_editor/test_goldens/editor/components/goldens/paragraph_alignments.png and b/super_editor/test_goldens/editor/components/goldens/paragraph_alignments.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_aligns_dot_with_text_with_font_sizes.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_aligns_dot_with_text_with_font_sizes.png new file mode 100644 index 0000000000..5adc6ee379 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_aligns_dot_with_text_with_font_sizes.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_aligns_dot_with_text_with_font_sizes_and_line_multiplier.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_aligns_dot_with_text_with_font_sizes_and_line_multiplier.png new file mode 100644 index 0000000000..a1ea08a9d0 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_aligns_dot_with_text_with_font_sizes_and_line_multiplier.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_alpha_numeral_component_builder.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_alpha_numeral_component_builder.png new file mode 100644 index 0000000000..59d3a713c0 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_alpha_numeral_component_builder.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_alpha_numeral_stylesheet.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_alpha_numeral_stylesheet.png new file mode 100644 index 0000000000..59d3a713c0 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_alpha_numeral_stylesheet.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_roman_numeral_component_builder.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_roman_numeral_component_builder.png new file mode 100644 index 0000000000..1aff3502d3 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_roman_numeral_component_builder.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_roman_numeral_stylesheet.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_roman_numeral_stylesheet.png new file mode 100644 index 0000000000..1aff3502d3 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_lower_roman_numeral_stylesheet.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_alpha_numeral_component_builder.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_alpha_numeral_component_builder.png new file mode 100644 index 0000000000..aa9924b2ee Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_alpha_numeral_component_builder.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_alpha_numeral_stylesheet.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_alpha_numeral_stylesheet.png new file mode 100644 index 0000000000..aa9924b2ee Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_alpha_numeral_stylesheet.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_roman_numeral_component_builder.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_roman_numeral_component_builder.png new file mode 100644 index 0000000000..1d89469112 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_roman_numeral_component_builder.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_roman_numeral_stylesheet.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_roman_numeral_stylesheet.png new file mode 100644 index 0000000000..1d89469112 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_ordered_upper_roman_numeral_stylesheet.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_aligns_dot_with_text_with_font_sizes.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_aligns_dot_with_text_with_font_sizes.png new file mode 100644 index 0000000000..5b492791e9 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_aligns_dot_with_text_with_font_sizes.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_aligns_dot_with_text_with_font_sizes_and_line_multiplier.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_aligns_dot_with_text_with_font_sizes_and_line_multiplier.png new file mode 100644 index 0000000000..68bbb50dfd Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_aligns_dot_with_text_with_font_sizes_and_line_multiplier.png differ diff --git a/super_editor/test/super_editor/goldens/mobile/supereditor_ios_expanded_handle_color.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_color_component_builder.png similarity index 59% rename from super_editor/test/super_editor/goldens/mobile/supereditor_ios_expanded_handle_color.png rename to super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_color_component_builder.png index e9e4dd8603..dd4b0587b0 100644 Binary files a/super_editor/test/super_editor/goldens/mobile/supereditor_ios_expanded_handle_color.png and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_color_component_builder.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_color_stylesheet.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_color_stylesheet.png new file mode 100644 index 0000000000..dd4b0587b0 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_color_stylesheet.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_shape_component_builder.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_shape_component_builder.png new file mode 100644 index 0000000000..a3cfce6b53 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_shape_component_builder.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_shape_stylesheet.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_shape_stylesheet.png new file mode 100644 index 0000000000..a3cfce6b53 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_shape_stylesheet.png differ diff --git a/super_editor/test/super_editor/goldens/mobile/supereditor_ios_collapsed_handle_color.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_size_component_builder.png similarity index 65% rename from super_editor/test/super_editor/goldens/mobile/supereditor_ios_collapsed_handle_color.png rename to super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_size_component_builder.png index d283495ca9..57b1aa89fe 100644 Binary files a/super_editor/test/super_editor/goldens/mobile/supereditor_ios_collapsed_handle_color.png and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_size_component_builder.png differ diff --git a/super_editor/test/super_editor/goldens/mobile/supereditor_android_expanded_handle_color.png b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_size_stylesheet.png similarity index 63% rename from super_editor/test/super_editor/goldens/mobile/supereditor_android_expanded_handle_color.png rename to super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_size_stylesheet.png index 8c2aeb2f5d..57b1aa89fe 100644 Binary files a/super_editor/test/super_editor/goldens/mobile/supereditor_android_expanded_handle_color.png and b/super_editor/test_goldens/editor/components/goldens/super_editor_list_item_unordered_custom_dot_size_stylesheet.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_border.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_border.png new file mode 100644 index 0000000000..9860c4634f Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_border.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_decoration.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_decoration.png new file mode 100644 index 0000000000..fd0b2f5dbe Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_decoration.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_padding.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_padding.png new file mode 100644 index 0000000000..22c552b3ab Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_padding.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_decoration.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_decoration.png new file mode 100644 index 0000000000..508fb2771c Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_decoration.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_text_style.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_text_style.png new file mode 100644 index 0000000000..4e28371648 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_text_style.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_row_decoration.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_row_decoration.png new file mode 100644 index 0000000000..0f77f80a9d Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_row_decoration.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_text_style.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_text_style.png new file mode 100644 index 0000000000..35f7166d04 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_text_style.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_different_alignments.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_different_alignments.png new file mode 100644 index 0000000000..f4bafb909a Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_different_alignments.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_fills_width.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_fills_width.png new file mode 100644 index 0000000000..be598f99f5 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_fills_width.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_inline_styles.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_inline_styles.png new file mode 100644 index 0000000000..5462ecb3c0 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_inline_styles.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_missing_columns.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_missing_columns.png new file mode 100644 index 0000000000..996a650666 Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_missing_columns.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_shrinks_to_fit_width.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_shrinks_to_fit_width.png new file mode 100644 index 0000000000..8a0b08481f Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_shrinks_to_fit_width.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_single_header_cell.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_single_header_cell.png new file mode 100644 index 0000000000..9037eeca2b Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_single_header_cell.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_without_data_rows.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_without_data_rows.png new file mode 100644 index 0000000000..f803c4e7bd Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_without_data_rows.png differ diff --git a/super_editor/test_goldens/editor/components/goldens/text_with_hint.png b/super_editor/test_goldens/editor/components/goldens/text_with_hint.png index 0a82e46b5c..e4791b7eaf 100644 Binary files a/super_editor/test_goldens/editor/components/goldens/text_with_hint.png and b/super_editor/test_goldens/editor/components/goldens/text_with_hint.png differ diff --git a/super_editor/test_goldens/editor/components/list_items_test.dart b/super_editor/test_goldens/editor/components/list_items_test.dart new file mode 100644 index 0000000000..a002576a67 --- /dev/null +++ b/super_editor/test_goldens/editor/components/list_items_test.dart @@ -0,0 +1,423 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +Future main() async { + await loadAppFonts(); + + group('SuperEditor > list items', () { + group('unordered', () { + testGoldensOnMac('aligns the dot vertically with the text', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + _createListItemNode(text: 'Font size of 8', fontSize: 8), + _createListItemNode(text: 'Font size of 10', fontSize: 10), + _createListItemNode(text: 'Font size of 12', fontSize: 12), + _createListItemNode(text: 'Font size of 14', fontSize: 14), + _createListItemNode(text: 'Font size of 16', fontSize: 16), + _createListItemNode(text: 'Font size of 18', fontSize: 18), + _createListItemNode(text: 'Font size of 24', fontSize: 24), + _createListItemNode(text: 'Font size of 40', fontSize: 40), + ], + ), + ) + .useStylesheet(_createStylesheet()) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_list_item_unordered_aligns_dot_with_text_with_font_sizes'); + }); + + testGoldensOnMac('aligns the dot vertically with the text with a line multiplier', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + _createListItemNode(text: 'Font size of 8', fontSize: 8), + _createListItemNode(text: 'Font size of 10', fontSize: 10), + _createListItemNode(text: 'Font size of 12', fontSize: 12), + _createListItemNode(text: 'Font size of 14', fontSize: 14), + _createListItemNode(text: 'Font size of 16', fontSize: 16), + _createListItemNode(text: 'Font size of 18', fontSize: 18), + _createListItemNode(text: 'Font size of 24', fontSize: 24), + _createListItemNode(text: 'Font size of 40', fontSize: 40), + ], + ), + ) + .useStylesheet(_createStylesheet(lineHeightMultiplier: 3.0)) + .pump(); + + await screenMatchesGolden( + tester, 'super_editor_list_item_unordered_aligns_dot_with_text_with_font_sizes_and_line_multiplier'); + }); + + testGoldensOnMac('allows customizing the dot size with stylesheet', (tester) async { + await tester // + .createDocument() + .fromMarkdown('- Item 1') + .useStylesheet( + _createStylesheet().copyWith(addRulesAfter: [ + StyleRule( + const BlockSelector('listItem'), + (doc, docNode) { + return { + Styles.dotSize: const Size(14, 14), + }; + }, + ), + ]), + ) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_list_item_unordered_custom_dot_size_stylesheet'); + }); + + testGoldensOnMac('allows customizing the dot size with component builder', (tester) async { + await tester // + .createDocument() + .fromMarkdown('- Item 1') + .useStylesheet(_createStylesheet()) + .withAddedComponents( + [ + const _ListItemWithCustomStyleBuilder( + dotStyle: ListItemDotStyle( + size: Size(14, 14), + ), + ), + ], + ).pump(); + + await screenMatchesGolden(tester, 'super_editor_list_item_unordered_custom_dot_size_component_builder'); + }); + + testGoldensOnMac('allows customizing the dot shape with stylesheet', (tester) async { + await tester // + .createDocument() + .fromMarkdown('- Item 1') + .useStylesheet( + _createStylesheet().copyWith(addRulesAfter: [ + StyleRule( + const BlockSelector('listItem'), + (doc, docNode) { + return { + Styles.dotShape: BoxShape.rectangle, + }; + }, + ), + ]), + ) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_list_item_unordered_custom_dot_shape_stylesheet'); + }); + + testGoldensOnMac('allows customizing the dot size with component builder', (tester) async { + await tester // + .createDocument() + .fromMarkdown('- Item 1') + .useStylesheet(_createStylesheet()) + .withAddedComponents( + [ + const _ListItemWithCustomStyleBuilder( + dotStyle: ListItemDotStyle( + shape: BoxShape.rectangle, + ), + ), + ], + ).pump(); + + await screenMatchesGolden(tester, 'super_editor_list_item_unordered_custom_dot_shape_component_builder'); + }); + + testGoldensOnMac('allows customizing the dot color with stylesheet', (tester) async { + await tester // + .createDocument() + .fromMarkdown('- Item 1') + .useStylesheet( + _createStylesheet().copyWith(addRulesAfter: [ + StyleRule( + const BlockSelector('listItem'), + (doc, docNode) { + return { + Styles.dotColor: Colors.red, + }; + }, + ), + ]), + ) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_list_item_unordered_custom_dot_color_stylesheet'); + }); + + testGoldensOnMac('allows customizing the dot color with component builder', (tester) async { + await tester // + .createDocument() + .fromMarkdown('- Item 1') + .useStylesheet(_createStylesheet()) + .withAddedComponents( + [ + const _ListItemWithCustomStyleBuilder( + dotStyle: ListItemDotStyle( + color: Colors.red, + ), + ), + ], + ).pump(); + + await screenMatchesGolden(tester, 'super_editor_list_item_unordered_custom_dot_color_component_builder'); + }); + }); + + group('ordered', () { + testGoldensOnMac('aligns the dot vertically with the text', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + _createListItemNode(text: 'Font size of 8', fontSize: 8, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 10', fontSize: 10, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 12', fontSize: 12, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 14', fontSize: 14, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 16', fontSize: 16, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 18', fontSize: 18, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 24', fontSize: 24, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 40', fontSize: 40, listItemType: ListItemType.ordered), + ], + ), + ) + .useStylesheet(_createStylesheet()) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_list_item_ordered_aligns_dot_with_text_with_font_sizes'); + }); + + testGoldensOnMac('aligns the dot vertically with the text with a line multiplier', (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + _createListItemNode(text: 'Font size of 8', fontSize: 8, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 10', fontSize: 10, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 12', fontSize: 12, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 14', fontSize: 14, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 16', fontSize: 16, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 18', fontSize: 18, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 24', fontSize: 24, listItemType: ListItemType.ordered), + _createListItemNode(text: 'Font size of 40', fontSize: 40, listItemType: ListItemType.ordered), + ], + ), + ) + .useStylesheet(_createStylesheet(lineHeightMultiplier: 3.0)) + .pump(); + + await screenMatchesGolden( + tester, 'super_editor_list_item_ordered_aligns_dot_with_text_with_font_sizes_and_line_multiplier'); + }); + + testGoldensOnMac('allows customizing the numeral as lower roman with stylesheet', (tester) async { + await _pumpOrderedListItemStyleTestApp(tester, style: OrderedListNumeralStyle.lowerRoman); + + await screenMatchesGolden(tester, 'super_editor_list_item_ordered_lower_roman_numeral_stylesheet'); + }); + + testGoldensOnMac('allows customizing the numeral as lower roman with component_builder', (tester) async { + await _pumpOrderedListItemStyleTestApp( + tester, + style: OrderedListNumeralStyle.lowerRoman, + fromStylesheet: false, + ); + + await screenMatchesGolden(tester, 'super_editor_list_item_ordered_lower_roman_numeral_component_builder'); + }); + + testGoldensOnMac('allows customizing the numeral as upper roman with stylesheet', (tester) async { + await _pumpOrderedListItemStyleTestApp(tester, style: OrderedListNumeralStyle.upperRoman); + + await screenMatchesGolden(tester, 'super_editor_list_item_ordered_upper_roman_numeral_stylesheet'); + }); + + testGoldensOnMac('allows customizing the numeral as upper roman with component builder', (tester) async { + await _pumpOrderedListItemStyleTestApp( + tester, + style: OrderedListNumeralStyle.upperRoman, + fromStylesheet: false, + ); + + await screenMatchesGolden(tester, 'super_editor_list_item_ordered_upper_roman_numeral_component_builder'); + }); + + testGoldensOnMac('allows customizing the numeral as lower alpha with stylesheet', (tester) async { + await _pumpOrderedListItemStyleTestApp(tester, style: OrderedListNumeralStyle.lowerAlpha); + + await screenMatchesGolden(tester, 'super_editor_list_item_ordered_lower_alpha_numeral_stylesheet'); + }); + + testGoldensOnMac('allows customizing the numeral as lower alpha with component builder', (tester) async { + await _pumpOrderedListItemStyleTestApp( + tester, + style: OrderedListNumeralStyle.lowerAlpha, + fromStylesheet: false, + ); + + await screenMatchesGolden(tester, 'super_editor_list_item_ordered_lower_alpha_numeral_component_builder'); + }); + + testGoldensOnMac('allows customizing the numeral as upper alpha with stylesheet', (tester) async { + await _pumpOrderedListItemStyleTestApp(tester, style: OrderedListNumeralStyle.upperAlpha); + + await screenMatchesGolden(tester, 'super_editor_list_item_ordered_upper_alpha_numeral_stylesheet'); + }); + + testGoldensOnMac('allows customizing the numeral as upper alpha with component builder', (tester) async { + await _pumpOrderedListItemStyleTestApp( + tester, + style: OrderedListNumeralStyle.upperAlpha, + fromStylesheet: false, + ); + + await screenMatchesGolden(tester, 'super_editor_list_item_ordered_upper_alpha_numeral_component_builder'); + }); + }); + }); +} + +/// Pumps a test app that displays ordered list items with the given [style]. +/// +/// When [fromStylesheet] is `true`, the style is applied via a stylesheet. +/// +/// When [fromStylesheet] is `false`, the style is applied via a component builder. +Future _pumpOrderedListItemStyleTestApp( + WidgetTester tester, { + required OrderedListNumeralStyle style, + bool fromStylesheet = true, +}) async { + await tester // + .createDocument() + .withCustomContent(MutableDocument(nodes: [ + for (int i = 1; i <= 10; i++) + ListItemNode.ordered( + id: Editor.createNodeId(), + text: AttributedText('Item $i.'), + ) + ])) + .useStylesheet(_createStylesheet().copyWith( + addRulesAfter: [ + if (fromStylesheet) + StyleRule( + const BlockSelector('listItem'), + (doc, docNode) { + return { + Styles.listNumeralStyle: style, + }; + }, + ), + ], + )) + .withAddedComponents( + [ + if (!fromStylesheet) + _ListItemWithCustomStyleBuilder( + numeralStyle: style, + ), + ], + ).pump(); +} + +ListItemNode _createListItemNode({ + required String text, + required double fontSize, + ListItemType listItemType = ListItemType.unordered, +}) { + return ListItemNode( + id: Editor.createNodeId(), + itemType: listItemType, + text: AttributedText( + text, + AttributedSpans(attributions: [ + SpanMarker( + attribution: FontSizeAttribution(fontSize), + offset: 0, + markerType: SpanMarkerType.start, + ), + SpanMarker( + attribution: FontSizeAttribution(fontSize), + offset: text.length - 1, + markerType: SpanMarkerType.end, + ), + ]), + ), + ); +} + +Stylesheet _createStylesheet({ + double lineHeightMultiplier = 1.0, +}) { + return defaultStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: TextStyle( + fontFamily: 'Roboto', + height: lineHeightMultiplier, + leadingDistribution: TextLeadingDistribution.even, + ), + }; + }, + ), + ], + ); +} + +/// A [ComponentBuilder] that styles list items with custom styles. +/// +/// If [dotStyle] is non-`null`, unordered list items are styled with the given [dotStyle]. Otherwise, +/// the default style is applied for unordered list items. +/// +/// If [numeralStyle] is non-`null`, ordered list items are styled with the given [numeralStyle]. Otherwise, +/// the default style is applied for ordered list items. +class _ListItemWithCustomStyleBuilder implements ComponentBuilder { + const _ListItemWithCustomStyleBuilder({ + this.dotStyle, + this.numeralStyle, + }); + + final ListItemDotStyle? dotStyle; + final OrderedListNumeralStyle? numeralStyle; + + @override + SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) { + if (node is! ListItemNode) { + return null; + } + + // Use the default component builder to create the view model, because we only want + // to customize the style. + final viewModel = const ListItemComponentBuilder().createViewModel(document, node); + + if (viewModel is UnorderedListItemComponentViewModel && dotStyle != null) { + viewModel.dotStyle = dotStyle!; + } else if (viewModel is OrderedListItemComponentViewModel && numeralStyle != null) { + viewModel.numeralStyle = numeralStyle!; + } + + return viewModel; + } + + @override + Widget? createComponent( + SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) { + // We can use the default component for list items. + return null; + } +} diff --git a/super_editor/test_goldens/editor/components/markdown_table_test.dart b/super_editor/test_goldens/editor/components/markdown_table_test.dart new file mode 100644 index 0000000000..e54f9f578d --- /dev/null +++ b/super_editor/test_goldens/editor/components/markdown_table_test.dart @@ -0,0 +1,361 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group('SuperEditor > Markdown Table >', () { + group('layout >', () { + testGoldensOnMac('expands to fill width', (tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +| Header 1 | +|---| +| Cell 1 | +| Cell 2 | +| Cell 3 |''') + .withAddedComponents([const MarkdownTableComponentBuilder()]) + .useStylesheet(markdownTableStylesheet) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_fills_width'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('shrinks to fit width', (tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +| Header 1 | Header 2 | Header 3 | Header 4 | Header 5 | +|---|---|---|---|---| +| Cell 1 | Cell 2 | Cell 3 | Cell 4 | Cell 5 | +| Cell 1 | Cell 2 | Cell 3 | Cell 4 | Cell 5 |''') + .withAddedComponents([const MarkdownTableComponentBuilder()]) + .useStylesheet(markdownTableStylesheet) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_shrinks_to_fit_width'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('without data rows', (tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +| Header 1 | Header 2 | +|---|---|''') + .withAddedComponents([const MarkdownTableComponentBuilder()]) + .useStylesheet(markdownTableStylesheet) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_without_data_rows'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('missing columns', (tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +| Header 1 | Header 2 | Header 3 | +|---|---|---| +| Cell 1 | Cell 2 | +| Cell 3 | +''') + .withAddedComponents([const MarkdownTableComponentBuilder()]) + .useStylesheet(markdownTableStylesheet) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_missing_columns'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('single header cell', (tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +| Header 1 | +|---|''') + .withAddedComponents([const MarkdownTableComponentBuilder()]) + .useStylesheet(markdownTableStylesheet) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_single_header_cell'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('different alignments', (tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +| Column 1 | Column 2 | Column 3 | +|---|:---:|---:| +| Cell 1 | Cell 2 | Cell 3 | +| Cell 1 | Cell 2 | Cell 3 | +| Cell 1 | Cell 2 | Cell 3 | +| Cell 1 | Cell 2 | Cell 3 | +''') + .withAddedComponents([const MarkdownTableComponentBuilder()]) + .useStylesheet(markdownTableStylesheet) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_different_alignments'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('different alignments', (tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +| Column 1 | Column 2 | Column 3 | +|---|:---:|---:| +| Cell 1 | Cell 2 | Cell 3 | +| Cell 1 | Cell 2 | Cell 3 | +| Cell 1 | Cell 2 | Cell 3 | +| Cell 1 | Cell 2 | Cell 3 | +''') + .withAddedComponents([const MarkdownTableComponentBuilder()]) + .useStylesheet(markdownTableStylesheet) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_different_alignments'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('inline styles', (tester) async { + await tester // + .createDocument() + .fromMarkdown(''' +| Column 1 | ~Column 2~ | ¬Column 3¬ | +|---|---|---| +| **Cell 1** | Cell 2 | Cell 3 | +| Cell 1 | *Cell 2* | Cell 3 | +| Cell 1 | Cell 2 | *Cell 3* | +''') + .withAddedComponents([const MarkdownTableComponentBuilder()]) + .useStylesheet(markdownTableStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector.all, + (document, node) { + return { + Styles.textStyle: const TextStyle( + fontFamily: 'Roboto', + ), + }; + }, + ), + ], + )) + .pump(); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_inline_styles'); + }, windowSize: goldenSizeMedium); + }); + + group('customization >', () { + testGoldensOnMac('text style', (tester) async { + await _pumpCustomizationTestApp( + tester, + stylesheet: markdownTableStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector(tableBlockAttribution.name), + (document, node) { + return { + Styles.textStyle: const TextStyle( + color: Colors.red, + ), + }; + }, + ), + ], + ), + ); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_text_style'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('header text style', (tester) async { + await _pumpCustomizationTestApp( + tester, + stylesheet: markdownTableStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector(tableBlockAttribution.name), + (document, node) { + return { + TableStyles.headerTextStyle: const TextStyle( + color: Colors.red, + ), + }; + }, + ), + ], + ), + ); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_header_text_style'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('border', (tester) async { + await _pumpCustomizationTestApp( + tester, + stylesheet: markdownTableStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector(tableBlockAttribution.name), + (document, node) { + return { + TableStyles.border: const TableBorder(), + }; + }, + ), + ], + ), + ); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_border'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('cell padding', (tester) async { + await _pumpCustomizationTestApp( + tester, + stylesheet: markdownTableStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector(tableBlockAttribution.name), + (document, node) { + return { + TableStyles.cellPadding: const CascadingPadding.all(20.0), + }; + }, + ), + ], + ), + ); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_cell_padding'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('header row decoration', (tester) async { + await _pumpCustomizationTestApp( + tester, + stylesheet: markdownTableStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector(tableBlockAttribution.name), + (document, node) { + return { + TableStyles.cellDecorator: ({ + required int rowIndex, + required int columnIndex, + required AttributedText cellText, + required Map cellMetadata, + }) { + if (rowIndex == 0) { + // Header row. + return const BoxDecoration(color: Colors.blue); + } + + return null; + }, + }; + }, + ), + ], + ), + ); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_header_decoration'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('data row decoration', (tester) async { + await _pumpCustomizationTestApp( + tester, + stylesheet: markdownTableStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector(tableBlockAttribution.name), + (document, node) { + return { + TableStyles.cellDecorator: ({ + required int rowIndex, + required int columnIndex, + required AttributedText cellText, + required Map cellMetadata, + }) { + if (rowIndex > 0 && rowIndex % 2 == 0) { + // Even data row. + return const BoxDecoration( + color: Colors.green, + ); + } + + return null; + }, + }; + }, + ), + ], + ), + ); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_row_decoration'); + }, windowSize: goldenSizeMedium); + + testGoldensOnMac('cell decoration', (tester) async { + await _pumpCustomizationTestApp( + tester, + stylesheet: markdownTableStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector(tableBlockAttribution.name), + (document, node) { + return { + TableStyles.cellDecorator: ({ + required int rowIndex, + required int columnIndex, + required AttributedText cellText, + required Map cellMetadata, + }) { + if (columnIndex == 1) { + return const BoxDecoration(color: Colors.red); + } + + return null; + }, + }; + }, + ), + ], + ), + ); + + await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_cell_decoration'); + }, windowSize: goldenSizeMedium); + }); + }); +} + +Future _pumpCustomizationTestApp( + WidgetTester tester, { + required Stylesheet stylesheet, +}) async { + await tester // + .createDocument() + .fromMarkdown(''' +| Header 1 | Header 2 | Header 3 | +|---|---|---| +| Cell 1 | Cell 2 | Cell 3 | +| Cell 1 | Cell 2 | +| Cell 1 | Cell 2 | +| Cell 1 | Cell 2 | +| Cell 1 | Cell 2 | +''') + .withAddedComponents([const MarkdownTableComponentBuilder()]) + .useStylesheet(stylesheet) + .pump(); +} + +/// Applies the markdown table styles to the default stylesheet. +final markdownTableStylesheet = defaultStylesheet.copyWith( + addRulesAfter: [ + markdownTableStyles, + ], +); diff --git a/super_editor/test_goldens/editor/components/paragraph_test.dart b/super_editor/test_goldens/editor/components/paragraph_test.dart index 2de29c26e3..d45f948ba0 100644 --- a/super_editor/test_goldens/editor/components/paragraph_test.dart +++ b/super_editor/test_goldens/editor/components/paragraph_test.dart @@ -1,16 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:super_editor/super_editor.dart'; - -import '../../../test/super_editor/document_test_tools.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; void main() { group('SuperEditor', () { - testGoldens('displays paragraphs with different alignments', (tester) async { - await tester - .createDocument() - .withCustomContent(_createParagraphTestDoc()) - .pump(); + testGoldensOnAndroid('displays paragraphs with different alignments', (tester) async { + await tester.createDocument().withCustomContent(_createParagraphTestDoc()).pump(); await screenMatchesGolden(tester, 'paragraph_alignments'); }); @@ -21,43 +18,42 @@ MutableDocument _createParagraphTestDoc() { return MutableDocument( nodes: [ ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'Various paragraph formations', + 'Various paragraph formations', ), metadata: { 'blockType': header1Attribution, }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'This is a short\nparagraph of text\nthat is left aligned', + 'This is a short\nparagraph of text\nthat is left aligned', ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'This is a short\nparagraph of text\nthat is center aligned', + 'This is a short\nparagraph of text\nthat is center aligned', ), metadata: { 'textAlign': 'center', }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'This is a short\nparagraph of text\nthat is right aligned', + 'This is a short\nparagraph of text\nthat is right aligned', ), metadata: { 'textAlign': 'right', }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'orem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', + 'orem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus sed sagittis urna. Aenean mattis ante justo, quis sollicitudin metus interdum id. Aenean ornare urna ac enim consequat mollis. In aliquet convallis efficitur. Phasellus convallis purus in fringilla scelerisque. Ut ac orci a turpis egestas lobortis. Morbi aliquam dapibus sem, vitae sodales arcu ultrices eu. Duis vulputate mauris quam, eleifend pulvinar quam blandit eget.', ), ), ], diff --git a/super_editor/test_goldens/editor/components/text_with_hint_test.dart b/super_editor/test_goldens/editor/components/text_with_hint_test.dart index 10e09103c0..91df4f6bca 100644 --- a/super_editor/test_goldens/editor/components/text_with_hint_test.dart +++ b/super_editor/test_goldens/editor/components/text_with_hint_test.dart @@ -13,16 +13,16 @@ void main() { mainAxisSize: MainAxisSize.min, children: [ TextWithHintComponent( - text: AttributedText(text: ''), + text: AttributedText(), textStyleBuilder: _textStyleBuilder, - hintText: AttributedText(text: "this is a hint..."), + hintText: AttributedText("this is a hint..."), hintStyleBuilder: (_) => _hintStyle, ), const SizedBox(height: 24), TextWithHintComponent( - text: AttributedText(text: 'This is content text.'), + text: AttributedText('This is content text.'), textStyleBuilder: _textStyleBuilder, - hintText: AttributedText(text: "this is a hint..."), + hintText: AttributedText("this is a hint..."), hintStyleBuilder: (_) => _hintStyle, ), ], diff --git a/super_editor/test_goldens/editor/goldens/super-editor-android-custom-caret-width.png b/super_editor/test_goldens/editor/goldens/super-editor-android-custom-caret-width.png new file mode 100644 index 0000000000..d064a94ef5 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-android-custom-caret-width.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-after-android.png b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-after-android.png new file mode 100644 index 0000000000..3669767e14 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-after-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-after-ios.png b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-after-ios.png new file mode 100644 index 0000000000..9591127963 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-after-ios.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-before-android.png b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-before-android.png new file mode 100644 index 0000000000..5a3de2bdcc Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-before-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-before-ios.png b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-before-ios.png new file mode 100644 index 0000000000..324639c4b5 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-landscape-portrait-before-ios.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-after-android.png b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-after-android.png new file mode 100644 index 0000000000..5a3de2bdcc Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-after-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-after-ios.png b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-after-ios.png new file mode 100644 index 0000000000..324639c4b5 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-after-ios.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-before-android.png b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-before-android.png new file mode 100644 index 0000000000..3669767e14 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-before-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-before-ios.png b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-before-ios.png new file mode 100644 index 0000000000..9591127963 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-caret-rotation-portrait-landscape-before-ios.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-image-caret-downstream-android.png b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-downstream-android.png new file mode 100644 index 0000000000..c7bbd55404 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-downstream-android.png differ diff --git a/super_editor/test/super_textfield/goldens/super_textfield_alignments_singleline_android.png b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-downstream-ios.png similarity index 77% rename from super_editor/test/super_textfield/goldens/super_textfield_alignments_singleline_android.png rename to super_editor/test_goldens/editor/goldens/super-editor-image-caret-downstream-ios.png index 1eec8b2213..a4903593e8 100644 Binary files a/super_editor/test/super_textfield/goldens/super_textfield_alignments_singleline_android.png and b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-downstream-ios.png differ diff --git a/super_editor/test/super_textfield/goldens/super_textfield_alignments_multiline_desktop.png b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-downstream-mac.png similarity index 66% rename from super_editor/test/super_textfield/goldens/super_textfield_alignments_multiline_desktop.png rename to super_editor/test_goldens/editor/goldens/super-editor-image-caret-downstream-mac.png index cc82aafea1..7e1171d8d7 100644 Binary files a/super_editor/test/super_textfield/goldens/super_textfield_alignments_multiline_desktop.png and b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-downstream-mac.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-image-caret-upstream-android.png b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-upstream-android.png new file mode 100644 index 0000000000..55a1c0de87 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-upstream-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-image-caret-upstream-ios.png b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-upstream-ios.png new file mode 100644 index 0000000000..ba75df7e02 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-upstream-ios.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-image-caret-upstream-mac.png b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-upstream-mac.png new file mode 100644 index 0000000000..0d2d71b9fb Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-image-caret-upstream-mac.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-ios-custom-caret-width.png b/super_editor/test_goldens/editor/goldens/super-editor-ios-custom-caret-width.png new file mode 100644 index 0000000000..e9e3146296 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-ios-custom-caret-width.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-ios-custom-handle-ball-diameter.png b/super_editor/test_goldens/editor/goldens/super-editor-ios-custom-handle-ball-diameter.png new file mode 100644 index 0000000000..cdf30e9718 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-ios-custom-handle-ball-diameter.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-ios-custom-handle-width.png b/super_editor/test_goldens/editor/goldens/super-editor-ios-custom-handle-width.png new file mode 100644 index 0000000000..a4d22d65be Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-ios-custom-handle-width.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-android.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-android.png new file mode 100644 index 0000000000..bf1ce88409 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-iOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-iOS.png new file mode 100644 index 0000000000..1be671a09e Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-iOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-linux.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-linux.png new file mode 100644 index 0000000000..e1e946561a Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-linux.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-macOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-macOS.png new file mode 100644 index 0000000000..e1e946561a Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-macOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-windows.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-windows.png new file mode 100644 index 0000000000..e1e946561a Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-ordered-list-item-windows.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-android.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-android.png new file mode 100644 index 0000000000..d2e21accd4 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-iOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-iOS.png new file mode 100644 index 0000000000..e8f985c152 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-iOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-linux.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-linux.png new file mode 100644 index 0000000000..38c198d712 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-linux.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-macOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-macOS.png new file mode 100644 index 0000000000..38c198d712 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-macOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-windows.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-windows.png new file mode 100644 index 0000000000..38c198d712 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-paragraph-windows.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-android.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-android.png new file mode 100644 index 0000000000..b250555fc2 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-iOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-iOS.png new file mode 100644 index 0000000000..528e328904 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-iOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-linux.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-linux.png new file mode 100644 index 0000000000..2d6219ccbf Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-linux.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-macOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-macOS.png new file mode 100644 index 0000000000..2d6219ccbf Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-macOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-windows.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-windows.png new file mode 100644 index 0000000000..2d6219ccbf Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-task-windows.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-android.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-android.png new file mode 100644 index 0000000000..98afcedf85 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-android.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-iOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-iOS.png new file mode 100644 index 0000000000..ec432fbebf Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-iOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-linux.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-linux.png new file mode 100644 index 0000000000..3e2afda0c4 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-linux.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-macOS.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-macOS.png new file mode 100644 index 0000000000..3e2afda0c4 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-macOS.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-windows.png b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-windows.png new file mode 100644 index 0000000000..3e2afda0c4 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor-rtl-caret-at-leftmost-character-unordered-list-item-windows.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor_selection-color_custom.png b/super_editor/test_goldens/editor/goldens/super-editor_selection-color_custom.png new file mode 100644 index 0000000000..fa83c4c6a1 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor_selection-color_custom.png differ diff --git a/super_editor/test_goldens/editor/goldens/super-editor_selection-color_default.png b/super_editor/test_goldens/editor/goldens/super-editor_selection-color_default.png new file mode 100644 index 0000000000..159f5dadd7 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/super-editor_selection-color_default.png differ diff --git a/super_editor/test_goldens/editor/goldens/text-scaling-blockquote.png b/super_editor/test_goldens/editor/goldens/text-scaling-blockquote.png new file mode 100644 index 0000000000..d199f08fbb Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/text-scaling-blockquote.png differ diff --git a/super_editor/test_goldens/editor/goldens/text-scaling-header.png b/super_editor/test_goldens/editor/goldens/text-scaling-header.png new file mode 100644 index 0000000000..9f8e826997 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/text-scaling-header.png differ diff --git a/super_editor/test_goldens/editor/goldens/text-scaling-ordered-list.png b/super_editor/test_goldens/editor/goldens/text-scaling-ordered-list.png new file mode 100644 index 0000000000..43db270293 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/text-scaling-ordered-list.png differ diff --git a/super_editor/test_goldens/editor/goldens/text-scaling-paragraph-collapsed-selection.png b/super_editor/test_goldens/editor/goldens/text-scaling-paragraph-collapsed-selection.png new file mode 100644 index 0000000000..485753e044 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/text-scaling-paragraph-collapsed-selection.png differ diff --git a/super_editor/test_goldens/editor/goldens/text-scaling-paragraph-expanded-selection.png b/super_editor/test_goldens/editor/goldens/text-scaling-paragraph-expanded-selection.png new file mode 100644 index 0000000000..53bb32ff8f Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/text-scaling-paragraph-expanded-selection.png differ diff --git a/super_editor/test_goldens/editor/goldens/text-scaling-paragraph.png b/super_editor/test_goldens/editor/goldens/text-scaling-paragraph.png new file mode 100644 index 0000000000..5d08240a0b Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/text-scaling-paragraph.png differ diff --git a/super_editor/test_goldens/editor/goldens/text-scaling-unordered-list.png b/super_editor/test_goldens/editor/goldens/text-scaling-unordered-list.png new file mode 100644 index 0000000000..77fce3ee30 Binary files /dev/null and b/super_editor/test_goldens/editor/goldens/text-scaling-unordered-list.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_double-tap-text.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_double-tap-text.png index 3f3f61a325..5602b483be 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_double-tap-text.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_double-tap-text.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-base-upstream.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-base-upstream.png index cd1e7e1ce9..fb34bf2ee7 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-base-upstream.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-base-upstream.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-collapsed-downstream.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-collapsed-downstream.png index 49702876a7..b135e3a666 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-collapsed-downstream.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-collapsed-downstream.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-collapsed-upstream.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-collapsed-upstream.png index 5d959d017d..53b812c5aa 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-collapsed-upstream.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-collapsed-upstream.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-extent-downstream.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-extent-downstream.png index 98d320ce65..a0ba338459 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-extent-downstream.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-extent-downstream.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-extent-upstream.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-extent-upstream.png index 0f74a14723..aac91f2e4f 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-extent-upstream.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_drag-extent-upstream.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_single-tap-text.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_single-tap-text.png index a30bff6c64..b8bd32706d 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_single-tap-text.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_single-tap-text.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_trip-tap-text.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_trip-tap-text.png index 3656c39cad..758b273e01 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_trip-tap-text.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_android_trip-tap-text.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_double-tap-text.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_double-tap-text.png index 87805e65b7..eb145bf4d6 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_double-tap-text.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_double-tap-text.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-base-upstream.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-base-upstream.png index 16b78fa8f0..d40c55bbb0 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-base-upstream.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-base-upstream.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-collapsed-downstream.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-collapsed-downstream.png index 92397fb23a..d9b4bd6fbd 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-collapsed-downstream.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-collapsed-downstream.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-collapsed-upstream.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-collapsed-upstream.png index 750bcfcc15..a8f06fda1b 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-collapsed-upstream.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-collapsed-upstream.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-extent-downstream.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-extent-downstream.png index c8dd5ccee1..0e8b2ce24e 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-extent-downstream.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-extent-downstream.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-extent-upstream.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-extent-upstream.png index bb02db4c30..d40cc65997 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-extent-upstream.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_drag-extent-upstream.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_single-tap-text.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_single-tap-text.png index 3ad52acbcb..a6e83a2914 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_single-tap-text.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_single-tap-text.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_trip-tap-text.png b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_trip-tap-text.png index 4b3d590540..36641d11dc 100644 Binary files a/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_trip-tap-text.png and b/super_editor/test_goldens/editor/mobile/goldens/mobile-selection_ios_trip-tap-text.png differ diff --git a/super_editor/test/super_editor/goldens/mobile/supereditor_android_collapsed_handle_color.png b/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_collapsed_handle_color.png similarity index 60% rename from super_editor/test/super_editor/goldens/mobile/supereditor_android_collapsed_handle_color.png rename to super_editor/test_goldens/editor/mobile/goldens/supereditor_android_collapsed_handle_color.png index ba15e2c3cf..178c8edbe8 100644 Binary files a/super_editor/test/super_editor/goldens/mobile/supereditor_android_collapsed_handle_color.png and b/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_collapsed_handle_color.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_expanded_handle_color.png b/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_expanded_handle_color.png new file mode 100644 index 0000000000..6b10fb81fc Binary files /dev/null and b/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_expanded_handle_color.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_magnifier_screen_edges.png b/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_magnifier_screen_edges.png new file mode 100644 index 0000000000..89323962f0 Binary files /dev/null and b/super_editor/test_goldens/editor/mobile/goldens/supereditor_android_magnifier_screen_edges.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_collapsed_handle_color.png b/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_collapsed_handle_color.png new file mode 100644 index 0000000000..6cf7838659 Binary files /dev/null and b/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_collapsed_handle_color.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_expanded_handle_color.png b/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_expanded_handle_color.png new file mode 100644 index 0000000000..69336bd30f Binary files /dev/null and b/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_expanded_handle_color.png differ diff --git a/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_magnifier_screen_edges.png b/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_magnifier_screen_edges.png new file mode 100644 index 0000000000..28e59d5c4b Binary files /dev/null and b/super_editor/test_goldens/editor/mobile/goldens/supereditor_ios_magnifier_screen_edges.png differ diff --git a/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart b/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart index bace08a5f2..80b01bf4d3 100644 --- a/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart +++ b/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart @@ -3,69 +3,136 @@ import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; void main() { group('SuperEditor', () { + group("mobile drag handles", () { + testGoldensOnAndroid("with caret change colors", (tester) async { + final testContext = await tester // + .createDocument() // + .fromMarkdown("This is some text to select.") // + .useAppTheme(ThemeData(primaryColor: Colors.red)) // + // Don't build a floating toolbar. It's a distraction for the details we care to verify. + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => const SizedBox()) + .withAndroidToolbarBuilder((context, mobileToolbarKey, focalPoint) => const SizedBox()) + .pump(); + final nodeId = testContext.findEditContext().document.first.id; + + await tester.placeCaretInParagraph(nodeId, 15); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile("goldens/supereditor_android_collapsed_handle_color.png"), + ); + }); + + testGoldensOnAndroid("with selection change colors", (tester) async { + final testContext = await tester // + .createDocument() // + .fromMarkdown("This is some text to select.") // + .useAppTheme(ThemeData(primaryColor: Colors.red)) // + // Don't build a floating toolbar. It's a distraction for the details we care to verify. + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => const SizedBox()) + .withAndroidToolbarBuilder((context, mobileToolbarKey, focalPoint) => const SizedBox()) + .pump(); + final nodeId = testContext.findEditContext().document.first.id; + + await tester.doubleTapInParagraph(nodeId, 15); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile("goldens/supereditor_android_expanded_handle_color.png"), + ); + }); + + testGoldensOniOS("with caret change colors", (tester) async { + final testContext = await tester // + .createDocument() // + .fromMarkdown("This is some text to select.") // + .useAppTheme(ThemeData(primaryColor: Colors.red)) // + // Don't build a floating toolbar. It's a distraction for the details we care to verify. + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => const SizedBox()) + .withAndroidToolbarBuilder((context, mobileToolbarKey, focalPoint) => const SizedBox()) + .pump(); + final nodeId = testContext.findEditContext().document.first.id; + + await tester.placeCaretInParagraph(nodeId, 15); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile("goldens/supereditor_ios_collapsed_handle_color.png"), + ); + }); + + testGoldensOniOS("with selection change colors", (tester) async { + final testContext = await tester // + .createDocument() // + .fromMarkdown("This is some text to select.") // + .useAppTheme(ThemeData(primaryColor: Colors.red)) // + // Don't build a floating toolbar. It's a distraction for the details we care to verify. + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => const SizedBox()) + .withAndroidToolbarBuilder((context, mobileToolbarKey, focalPoint) => const SizedBox()) + .pump(); + final nodeId = testContext.findEditContext().document.first.id; + + await tester.doubleTapInParagraph(nodeId, 15); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile("goldens/supereditor_ios_expanded_handle_color.png"), + ); + }); + }); + group('mobile selection', () { group('Android', () { - testParagraphSelection( + _testParagraphSelection( 'single tap text', DocumentGestureMode.android, "mobile-selection_android_single-tap-text", - (tester, composer, docKey, _) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBox = docLayout.getRectForPosition( + (tester, docKey, _) async { + await tester.tapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); - - await tester.tapAt( - docBox.localToGlobal(characterBox!.center), - ); await tester.pumpAndSettle(); }, + maxPixelMismatchCount: 51, ); - testParagraphSelection( + _testParagraphSelection( 'drag collapsed handle upstream', DocumentGestureMode.android, "mobile-selection_android_drag-collapsed-upstream", - (tester, composer, docKey, dragLine) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBoxStart = docLayout.getRectForPosition( + (tester, docKey, dragLine) async { + await tester.tapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); - final characterBoxEnd = docLayout.getRectForPosition( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 28)), - ); - final dragDelta = characterBoxEnd!.center - characterBoxStart!.center; - - await tester.tapAt( - docBox.localToGlobal(characterBoxStart.center), - ); await tester.pumpAndSettle(); - final handleFinder = find.byType(AndroidSelectionHandle); - final handleBox = handleFinder.evaluate().first.renderObject as RenderBox; - final handleRectGlobal = Rect.fromPoints( - handleBox.localToGlobal(Offset.zero), - handleBox.localToGlobal( - Offset(handleBox.size.width, handleBox.size.height), - ), - ); + final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 34, 28); + final handleRectGlobal = SuperEditorInspector.findMobileCaretDragHandle().globalRect; - await tester.dragFrom(handleRectGlobal.center, dragDelta); + // Calculate the center of the visual handle, accounting for addition of invisible + // touch area expansion. The touch area only expands below the handle, not above + // the handle, so using the "center" of the widget would product a point that's + // too far down. The invisible height below the handle is about the same height as + // the handle, so we'll use the 25% y-value of the whole widget, which is roughly + // at the 50% y-value of the visible handle. + final centerOfVisualHandle = + Offset(handleRectGlobal.center.dx, handleRectGlobal.top + handleRectGlobal.height / 4); + + await tester.dragFrom(centerOfVisualHandle, dragDelta); // Update the drag line for debug purposes - dragLine.value = _Line(handleRectGlobal.center, handleRectGlobal.center + dragDelta); + dragLine.value = _Line(handleRectGlobal.center, centerOfVisualHandle + dragDelta); // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection.collapsed( position: DocumentPosition( nodeId: "1", @@ -74,37 +141,21 @@ void main() { ), ); }, + maxPixelMismatchCount: 51, ); - testParagraphSelection( + _testParagraphSelection( 'drag collapsed handle downstream', DocumentGestureMode.android, "mobile-selection_android_drag-collapsed-downstream", - (tester, composer, docKey, dragLine) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBoxStart = docLayout.getRectForPosition( + (tester, docKey, dragLine) async { + await tester.tapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); - final characterBoxEnd = docLayout.getRectForPosition( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 39)), - ); - final dragDelta = characterBoxEnd!.center - characterBoxStart!.center; - - await tester.tapAt( - docBox.localToGlobal(characterBoxStart.center), - ); await tester.pumpAndSettle(); - final handleFinder = find.byType(AndroidSelectionHandle); - final handleBox = handleFinder.evaluate().first.renderObject as RenderBox; - final handleRectGlobal = Rect.fromPoints( - handleBox.localToGlobal(Offset.zero), - handleBox.localToGlobal( - Offset(handleBox.size.width, handleBox.size.height), - ), - ); - + final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 34, 39); + final handleRectGlobal = SuperEditorInspector.findMobileCaretDragHandle().globalRect; await tester.dragFrom(handleRectGlobal.center, dragDelta); // Update the drag line for debug purposes @@ -113,7 +164,7 @@ void main() { // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection.collapsed( position: DocumentPosition( nodeId: "1", @@ -122,28 +173,23 @@ void main() { ), ); }, + maxPixelMismatchCount: 51, ); - testParagraphSelection( + _testParagraphSelection( 'double tap text', DocumentGestureMode.android, "mobile-selection_android_double-tap-text", - (tester, composer, docKey, rootWidget) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBox = docLayout.getRectForPosition( + (tester, docKey, rootWidget) async { + await tester.doubleTapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); - - await tester.doubleTapAt( - docBox.localToGlobal(characterBox!.center), - ); await tester.pumpAndSettle(); // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -158,26 +204,20 @@ void main() { }, ); - testParagraphSelection( + _testParagraphSelection( 'triple tap text', DocumentGestureMode.android, "mobile-selection_android_trip-tap-text", - (tester, composer, docKey, _) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBox = docLayout.getRectForPosition( + (tester, docKey, _) async { + await tester.tripleTapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); - - await tester.tripleTapAt( - docBox.localToGlobal(characterBox!.center), - ); await tester.pumpAndSettle(); // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -192,36 +232,18 @@ void main() { }, ); - testParagraphSelection( + _testParagraphSelection( 'drag base handle upstream', DocumentGestureMode.android, "mobile-selection_android_drag-base-upstream", - (tester, composer, docKey, dragLine) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBoxStart = docLayout.getRectForPosition( + (tester, docKey, dragLine) async { + await tester.doubleTapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 28)), ); - final characterBoxEnd = docLayout.getRectForPosition( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 22)), - ); - final dragDelta = characterBoxEnd!.center - characterBoxStart!.center; - - await tester.doubleTapAt( - docBox.localToGlobal(characterBoxStart.center), - ); - await tester.pumpAndSettle(); - final handleFinder = find.byType(AndroidSelectionHandle); - final handleBox = handleFinder.evaluate().first.renderObject as RenderBox; - final handleRectGlobal = Rect.fromPoints( - handleBox.localToGlobal(Offset.zero), - handleBox.localToGlobal( - Offset(handleBox.size.width, handleBox.size.height), - ), - ); - + final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 28, 22); + final handleRectGlobal = SuperEditorInspector.findMobileBaseDragHandle().globalRect; await tester.dragFrom(handleRectGlobal.center, dragDelta); // Update the drag line for debug purposes @@ -230,7 +252,7 @@ void main() { // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -245,35 +267,18 @@ void main() { }, ); - testParagraphSelection( + _testParagraphSelection( 'drag extent handle upstream', DocumentGestureMode.android, "mobile-selection_android_drag-extent-upstream", - (tester, composer, docKey, dragLine) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBoxStart = docLayout.getRectForPosition( + (tester, docKey, dragLine) async { + await tester.doubleTapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 38)), ); - final characterBoxEnd = docLayout.getRectForPosition( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 30)), - ); - final dragDelta = characterBoxEnd!.center - characterBoxStart!.center; - - await tester.doubleTapAt( - docBox.localToGlobal(characterBoxStart.center), - ); await tester.pumpAndSettle(); - final handleFinder = find.byType(AndroidSelectionHandle); - final handleBox = handleFinder.evaluate().elementAt(1).renderObject as RenderBox; - final handleRectGlobal = Rect.fromPoints( - handleBox.localToGlobal(Offset.zero), - handleBox.localToGlobal( - Offset(handleBox.size.width, handleBox.size.height), - ), - ); - + final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 38, 30); + final handleRectGlobal = SuperEditorInspector.findMobileExtentDragHandle().globalRect; await tester.dragFrom(handleRectGlobal.center, dragDelta); // Update the drag line for debug purposes @@ -282,7 +287,7 @@ void main() { // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -297,35 +302,18 @@ void main() { }, ); - testParagraphSelection( + _testParagraphSelection( 'drag extent handle downstream', DocumentGestureMode.android, "mobile-selection_android_drag-extent-downstream", - (tester, composer, docKey, dragLine) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBoxStart = docLayout.getRectForPosition( + (tester, docKey, dragLine) async { + await tester.doubleTapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 38)), ); - final characterBoxEnd = docLayout.getRectForPosition( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 44)), - ); - final dragDelta = characterBoxEnd!.center - characterBoxStart!.center; - - await tester.doubleTapAt( - docBox.localToGlobal(characterBoxStart.center), - ); await tester.pumpAndSettle(); - final handleFinder = find.byType(AndroidSelectionHandle); - final handleBox = handleFinder.evaluate().elementAt(1).renderObject as RenderBox; - final handleRectGlobal = Rect.fromPoints( - handleBox.localToGlobal(Offset.zero), - handleBox.localToGlobal( - Offset(handleBox.size.width, handleBox.size.height), - ), - ); - + final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 38, 44); + final handleRectGlobal = SuperEditorInspector.findMobileExtentDragHandle().globalRect; await tester.dragFrom(handleRectGlobal.center, dragDelta); // Update the drag line for debug purposes @@ -334,7 +322,7 @@ void main() { // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -342,7 +330,11 @@ void main() { ), extent: DocumentPosition( nodeId: "1", - nodePosition: TextNodePosition(offset: 45), + // We are dragging until the middle of a word, but since Android + // selects by word instead of by character, the selection expands + // to the end of the word. The drag line in the golden won't match + // the drag handle position. + nodePosition: TextNodePosition(offset: 50), ), ), ); @@ -351,26 +343,20 @@ void main() { }); group('iOS', () { - testParagraphSelection( + _testParagraphSelection( 'single tap text', DocumentGestureMode.iOS, "mobile-selection_ios_single-tap-text", - (tester, composer, docKey, _) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBox = docLayout.getRectForPosition( + (tester, docKey, _) async { + await tester.tapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); - - await tester.tapAt( - docBox.localToGlobal(characterBox!.center), - ); await tester.pumpAndSettle(); // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection.collapsed( position: DocumentPosition( nodeId: "1", @@ -381,38 +367,21 @@ void main() { }, ); - testParagraphSelection( + _testParagraphSelection( 'drag collapsed handle upstream', DocumentGestureMode.iOS, "mobile-selection_ios_drag-collapsed-upstream", - (tester, composer, docKey, dragLine) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBoxStart = docLayout.getRectForPosition( + (tester, docKey, dragLine) async { + await tester.tapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); - final characterBoxEnd = docLayout.getRectForPosition( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 28)), - ); - final dragDelta = characterBoxEnd!.center - characterBoxStart!.center; - - await tester.tapAt( - docBox.localToGlobal(characterBoxStart.center), - ); - await tester.pumpAndSettle(); - - final handleFinder = find.byType(IOSCollapsedHandle); - final handleBox = handleFinder.evaluate().first.renderObject as RenderBox; - final handleRectGlobal = Rect.fromPoints( - handleBox.localToGlobal(Offset.zero), - handleBox.localToGlobal( - Offset(handleBox.size.width, handleBox.size.height), - ), - ); - - // Pump and settle so that the drag isn't perceived as a 2nd tap. await tester.pumpAndSettle(); + // Wait a bit to ensure that the interactor recognizer doesn't consider this a double + // tap. + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); + final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 34, 28); + final handleRectGlobal = SuperEditorInspector.findMobileCaret().globalRect; await tester.dragFrom(handleRectGlobal.center, dragDelta); // Update the drag line for debug purposes @@ -421,49 +390,32 @@ void main() { // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection.collapsed( position: DocumentPosition( nodeId: "1", - nodePosition: TextNodePosition(offset: 28, affinity: TextAffinity.upstream), + nodePosition: TextNodePosition(offset: 28), ), ), ); }, ); - testParagraphSelection( + _testParagraphSelection( 'drag collapsed handle downstream', DocumentGestureMode.iOS, "mobile-selection_ios_drag-collapsed-downstream", - (tester, composer, docKey, dragLine) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBoxStart = docLayout.getRectForPosition( + (tester, docKey, dragLine) async { + await tester.tapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); - final characterBoxEnd = docLayout.getRectForPosition( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 39)), - ); - final dragDelta = characterBoxEnd!.center - characterBoxStart!.center; - - await tester.tapAt( - docBox.localToGlobal(characterBoxStart.center), - ); - await tester.pumpAndSettle(); - - final handleFinder = find.byType(IOSCollapsedHandle); - final handleBox = handleFinder.evaluate().first.renderObject as RenderBox; - final handleRectGlobal = Rect.fromPoints( - handleBox.localToGlobal(Offset.zero), - handleBox.localToGlobal( - Offset(handleBox.size.width, handleBox.size.height), - ), - ); - - // Pump and settle so that the drag isn't perceived as a 2nd tap. await tester.pumpAndSettle(); + // Wait a bit to ensure that the interactor recognizer doesn't consider this a double + // tap. + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); + final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 34, 39); + final handleRectGlobal = SuperEditorInspector.findMobileCaret().globalRect; await tester.dragFrom(handleRectGlobal.center, dragDelta); // Update the drag line for debug purposes @@ -472,37 +424,31 @@ void main() { // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection.collapsed( position: DocumentPosition( nodeId: "1", - nodePosition: TextNodePosition(offset: 39, affinity: TextAffinity.upstream), + nodePosition: TextNodePosition(offset: 39), ), ), ); }, ); - testParagraphSelection( + _testParagraphSelection( 'double tap text', DocumentGestureMode.iOS, "mobile-selection_ios_double-tap-text", - (tester, composer, docKey, rootWidget) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBox = docLayout.getRectForPosition( + (tester, docKey, rootWidget) async { + await tester.doubleTapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); - - await tester.doubleTapAt( - docBox.localToGlobal(characterBox!.center), - ); await tester.pumpAndSettle(); // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -517,26 +463,20 @@ void main() { }, ); - testParagraphSelection( + _testParagraphSelection( 'triple tap text', DocumentGestureMode.iOS, "mobile-selection_ios_trip-tap-text", - (tester, composer, docKey, _) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBox = docLayout.getRectForPosition( + (tester, docKey, _) async { + await tester.tripleTapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 34)), ); - - await tester.tripleTapAt( - docBox.localToGlobal(characterBox!.center), - ); await tester.pumpAndSettle(); // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -551,36 +491,19 @@ void main() { }, ); - testParagraphSelection( + _testParagraphSelection( 'drag base handle upstream', DocumentGestureMode.iOS, "mobile-selection_ios_drag-base-upstream", - (tester, composer, docKey, dragLine) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBoxStart = docLayout.getRectForPosition( + maxPixelMismatchCount: 1, + (tester, docKey, dragLine) async { + await tester.doubleTapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 28)), ); - final characterBoxEnd = docLayout.getRectForPosition( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 22)), - ); - final dragDelta = characterBoxEnd!.center - characterBoxStart!.center; - - await tester.doubleTapAt( - docBox.localToGlobal(characterBoxStart.center), - ); - await tester.pumpAndSettle(); - final handleFinder = find.byType(IOSSelectionHandle); - final handleBox = handleFinder.evaluate().first.renderObject as RenderBox; - final handleRectGlobal = Rect.fromPoints( - handleBox.localToGlobal(Offset.zero), - handleBox.localToGlobal( - Offset(handleBox.size.width, handleBox.size.height), - ), - ); - + final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 28, 22); + final handleRectGlobal = SuperEditorInspector.findMobileBaseDragHandle().globalRect; await tester.dragFrom(handleRectGlobal.center, dragDelta); // Update the drag line for debug purposes @@ -589,7 +512,7 @@ void main() { // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -604,35 +527,18 @@ void main() { }, ); - testParagraphSelection( + _testParagraphSelection( 'drag extent handle upstream', DocumentGestureMode.iOS, "mobile-selection_ios_drag-extent-upstream", - (tester, composer, docKey, dragLine) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBoxStart = docLayout.getRectForPosition( + (tester, docKey, dragLine) async { + await tester.doubleTapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 38)), ); - final characterBoxEnd = docLayout.getRectForPosition( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 30)), - ); - final dragDelta = characterBoxEnd!.center - characterBoxStart!.center; - - await tester.doubleTapAt( - docBox.localToGlobal(characterBoxStart.center), - ); await tester.pumpAndSettle(); - final handleFinder = find.byType(IOSSelectionHandle); - final handleBox = handleFinder.evaluate().elementAt(1).renderObject as RenderBox; - final handleRectGlobal = Rect.fromPoints( - handleBox.localToGlobal(Offset.zero), - handleBox.localToGlobal( - Offset(handleBox.size.width, handleBox.size.height), - ), - ); - + final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 38, 30); + final handleRectGlobal = SuperEditorInspector.findMobileExtentDragHandle().globalRect; await tester.dragFrom(handleRectGlobal.center, dragDelta); // Update the drag line for debug purposes @@ -641,7 +547,7 @@ void main() { // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -656,35 +562,18 @@ void main() { }, ); - testParagraphSelection( + _testParagraphSelection( 'drag extent handle downstream', DocumentGestureMode.iOS, "mobile-selection_ios_drag-extent-downstream", - (tester, composer, docKey, dragLine) async { - final docBox = docKey.currentContext!.findRenderObject() as RenderBox; - final docLayout = docKey.currentState as DocumentLayout; - final characterBoxStart = docLayout.getRectForPosition( + (tester, docKey, dragLine) async { + await tester.doubleTapAtDocumentPosition( const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 38)), ); - final characterBoxEnd = docLayout.getRectForPosition( - const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 44)), - ); - final dragDelta = characterBoxEnd!.center - characterBoxStart!.center; - - await tester.doubleTapAt( - docBox.localToGlobal(characterBoxStart.center), - ); await tester.pumpAndSettle(); - final handleFinder = find.byType(IOSSelectionHandle); - final handleBox = handleFinder.evaluate().elementAt(1).renderObject as RenderBox; - final handleRectGlobal = Rect.fromPoints( - handleBox.localToGlobal(Offset.zero), - handleBox.localToGlobal( - Offset(handleBox.size.width, handleBox.size.height), - ), - ); - + final dragDelta = SuperEditorInspector.findDeltaBetweenCharactersInTextNode("1", 38, 44); + final handleRectGlobal = SuperEditorInspector.findMobileExtentDragHandle().globalRect; await tester.dragFrom(handleRectGlobal.center, dragDelta); // Update the drag line for debug purposes @@ -693,7 +582,7 @@ void main() { // Even though this is a golden test, we verify the final selection // to make it easier to spot rendering problems vs selection problems. expect( - composer.selection, + SuperEditorInspector.findDocumentSelection(), const DocumentSelection( base: DocumentPosition( nodeId: "1", @@ -714,66 +603,112 @@ void main() { /// Pumps a single-paragraph document into the WidgetTester and then hands control /// to the given [test] method. -void testParagraphSelection( +void _testParagraphSelection( String description, DocumentGestureMode platform, String goldenName, - Future Function(WidgetTester, DocumentComposer, GlobalKey docKey, ValueNotifier<_Line?> dragLine) test, -) { - final docKey = GlobalKey(); - - testGoldens(description, (tester) async { - tester.binding.window - ..physicalSizeTestValue = const Size(800, 200) - ..devicePixelRatioTestValue = 1.0; - tester.binding.platformDispatcher.textScaleFactorTestValue = 1.0; - - final dragLine = ValueNotifier<_Line?>(null); - - final composer = DocumentComposer(); - - final content = _buildScaffold( - dragLine: dragLine, - child: SuperEditor( - documentLayoutKey: docKey, - editor: _createSingleParagraphEditor(), - composer: composer, - gestureMode: platform, - stylesheet: Stylesheet( - documentPadding: const EdgeInsets.all(16), - rules: defaultStylesheet.rules, - inlineTextStyler: (attributions, style) => _textStyleBuilder(attributions), + Future Function(WidgetTester, GlobalKey docKey, ValueNotifier<_Line?> dragLine) test, { + int maxPixelMismatchCount = 0, +}) { + switch (platform) { + case DocumentGestureMode.mouse: + testGoldensOnMac( + description, + (tester) => _runParagraphSelectionTest( + tester, + platform, + goldenName, + test, + maxPixelMismatchCount: maxPixelMismatchCount, ), - ), - ); - - // Display the content - await tester.pumpWidget( - content, - ); + ); + case DocumentGestureMode.android: + testGoldensOnAndroid( + description, + (tester) => _runParagraphSelectionTest( + tester, + platform, + goldenName, + test, + maxPixelMismatchCount: maxPixelMismatchCount, + ), + ); + case DocumentGestureMode.iOS: + testGoldensOniOS( + description, + (tester) => _runParagraphSelectionTest( + tester, + platform, + goldenName, + test, + maxPixelMismatchCount: maxPixelMismatchCount, + ), + ); + } +} - // Run the test - await test(tester, composer, docKey, dragLine); +Future _runParagraphSelectionTest( + WidgetTester tester, + DocumentGestureMode platform, + String goldenName, + Future Function(WidgetTester, GlobalKey docKey, ValueNotifier<_Line?> dragLine) test, { + int maxPixelMismatchCount = 0, +}) async { + final docKey = GlobalKey(); - // Compare the golden - await screenMatchesGolden(tester, goldenName); + tester.view + ..physicalSize = const Size(800, 200) + ..devicePixelRatio = 1.0; + tester.binding.platformDispatcher.textScaleFactorTestValue = 1.0; + + final dragLine = ValueNotifier<_Line?>(null); + + await tester // + .createDocument() + .withCustomContent(_createSingleParagraphDoc()) + .withLayoutKey(docKey) + .withGestureMode(platform) + .useStylesheet(Stylesheet( + documentPadding: const EdgeInsets.all(16), + rules: defaultStylesheet.rules, + inlineTextStyler: (attributions, style) => _textStyleBuilder(attributions), + )) + // Don't build a floating toolbar. It's a distraction for the details we care to verify. + .withiOSToolbarBuilder((context, mobileToolbarKey, focalPoint) => const SizedBox()) + .withAndroidToolbarBuilder((context, mobileToolbarKey, focalPoint) => const SizedBox()) + .withCustomWidgetTreeBuilder( + (superEditor) { + return _buildScaffold( + dragLine: dragLine, + child: superEditor, + ); + }, + ) // + .pump(); + + // Run the test + await test(tester, docKey, dragLine); + + // Compare the golden + await tester.pumpAndSettle(); + await expectLater( + find.byType(_DragLinePaint), + matchesGoldenFileWithPixelAllowance("goldens/$goldenName.png", maxPixelMismatchCount), + ); - tester.binding.window.clearPhysicalSizeTestValue(); - }); + tester.view.resetPhysicalSize(); } Widget _buildScaffold({ required ValueNotifier<_Line?> dragLine, required Widget child, }) { - return DragLinePaint( + return _DragLinePaint( line: dragLine, child: MaterialApp( home: Scaffold( body: Center( - child: IntrinsicHeight( - child: child, - ), + child: child, ), ), debugShowCheckedModeBanner: false, @@ -790,26 +725,21 @@ TextStyle _textStyleBuilder(attributions) { ); } -DocumentEditor _createSingleParagraphEditor() { - return DocumentEditor(document: _createSingleParagraphDoc()); -} - MutableDocument _createSingleParagraphDoc() { return MutableDocument( nodes: [ ParagraphNode( id: "1", text: AttributedText( - text: - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", ), ), ], ); } -class DragLinePaint extends StatelessWidget { - const DragLinePaint({ +class _DragLinePaint extends StatelessWidget { + const _DragLinePaint({ Key? key, required this.line, required this.child, @@ -824,7 +754,7 @@ class DragLinePaint extends StatelessWidget { valueListenable: line, builder: (context, line, child) { return CustomPaint( - foregroundPainter: line != null ? DragLinePainter(line: line) : null, + foregroundPainter: line != null ? _DragLinePainter(line: line) : null, child: child, ); }, @@ -833,8 +763,8 @@ class DragLinePaint extends StatelessWidget { } } -class DragLinePainter extends CustomPainter { - DragLinePainter({ +class _DragLinePainter extends CustomPainter { + _DragLinePainter({ required _Line line, }) : _line = line, _paint = Paint(); @@ -862,7 +792,7 @@ class DragLinePainter extends CustomPainter { } @override - bool shouldRepaint(DragLinePainter oldDelegate) { + bool shouldRepaint(_DragLinePainter oldDelegate) { return _line != oldDelegate._line; } } @@ -881,19 +811,3 @@ class _Line { @override int get hashCode => from.hashCode ^ to.hashCode; } - -extension on WidgetTester { - Future doubleTapAt(Offset offset) async { - await tapAt(offset); - await pump(kDoubleTapMinTime); - await tapAt(offset); - } - - Future tripleTapAt(Offset offset) async { - await tapAt(offset); - await pump(kDoubleTapMinTime); - await tapAt(offset); - await pump(kDoubleTapMinTime); - await tapAt(offset); - } -} diff --git a/super_editor/test_goldens/editor/mobile/supereditor_android_overlay_controls_test.dart b/super_editor/test_goldens/editor/mobile/supereditor_android_overlay_controls_test.dart new file mode 100644 index 0000000000..3481088a5c --- /dev/null +++ b/super_editor/test_goldens/editor/mobile/supereditor_android_overlay_controls_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group("SuperEditor > Android > overlay controls >", () { + testGoldensOnAndroid("confines magnifier within screen bounds", (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(400.0, 500.0); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + await tester // + .createDocument() + .withSingleParagraph() + .useStylesheet(Stylesheet( + rules: defaultStylesheet.rules, + inlineTextStyler: (attributions, style) => _textStyleBuilder(attributions), + )) + .pump(); + + // Place the caret at "Duis aute|" (line 6). + await tester.tapInParagraph("1", 241); + + // // Press and drag the caret to the beginning of the line. + final gesture = await tester.pressDownOnCollapsedMobileHandle(); + for (int i = 1; i < 7; i++) { + await gesture.moveBy(const Offset(-12, 0)); + await tester.pump(); + } + + await tester.pump(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/supereditor_android_magnifier_screen_edges.png", 52), + ); + + // Release the gesture. + await gesture.up(); + }); + }); +} + +TextStyle _textStyleBuilder(Set attributions) { + return const TextStyle( + color: Colors.black, + fontFamily: 'Roboto', + fontSize: 16, + height: 1.4, + ); +} diff --git a/super_editor/test_goldens/editor/mobile/supereditor_ios_overlay_controls_test.dart b/super_editor/test_goldens/editor/mobile/supereditor_ios_overlay_controls_test.dart new file mode 100644 index 0000000000..d46212c710 --- /dev/null +++ b/super_editor/test_goldens/editor/mobile/supereditor_ios_overlay_controls_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group("SuperEditor > iOS > overlay controls >", () { + testGoldensOniOS("confines magnifier within screen bounds", (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(400.0, 500.0); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + await tester // + .createDocument() + .withSingleParagraph() + .useStylesheet(Stylesheet( + rules: defaultStylesheet.rules, + inlineTextStyler: (attributions, style) => _textStyleBuilder(attributions), + )) + .pump(); + + // Place the caret at "Duis aute|" (line 6). + await tester.tapInParagraph("1", 241); + + // Press and drag the caret to the beginning of the line. + final gesture = await tester.tapDownInParagraph("1", 241); + for (int i = 1; i < 7; i++) { + await gesture.moveBy(const Offset(-12, 0)); + await tester.pump(); + } + + await screenMatchesGolden(tester, 'supereditor_ios_magnifier_screen_edges'); + + // Resolve the gesture so that we don't have pending gesture timers. + await gesture.up(); + await tester.pump(const Duration(milliseconds: 100)); + }); + }); +} + +TextStyle _textStyleBuilder(Set attributions) { + return const TextStyle( + color: Colors.black, + fontFamily: 'Roboto', + fontSize: 16, + height: 1.4, + ); +} diff --git a/super_editor/test_goldens/editor/supereditor_caret_test.dart b/super_editor/test_goldens/editor/supereditor_caret_test.dart new file mode 100644 index 0000000000..a959bc1ebe --- /dev/null +++ b/super_editor/test_goldens/editor/supereditor_caret_test.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_bricks/golden_bricks.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group('SuperEditor > caret rendering >', () { + testGoldensOnMac('shows caret at right side of an image', (tester) async { + await _pumpCaretTestApp(tester); + + // Tap close to the right edge of the editor to place the caret + // downstream on the image. + await tester.tapAt( + tester.getTopRight(find.byType(SuperEditor)) + const Offset(-20, 20), + ); + await tester.pump(); + + await screenMatchesGolden(tester, 'super-editor-image-caret-downstream-mac'); + }); + + testGoldensOniOS('shows caret at right side of an image', (tester) async { + await _pumpCaretTestApp(tester); + + // Tap close to the right edge of the editor to place the caret + // downstream on the image. + await tester.tapAt( + tester.getTopRight(find.byType(SuperEditor)) + const Offset(-20, 20), + ); + await tester.pump(); + + await screenMatchesGolden(tester, 'super-editor-image-caret-downstream-ios'); + }); + + testGoldensOnAndroid( + 'shows caret at right side of an image', + (tester) async { + await _pumpCaretTestApp(tester); + + // Tap close to the right edge of the editor to place the caret + // downstream on the image. + await tester.tapAt( + tester.getTopRight(find.byType(SuperEditor)) + const Offset(-20, 20), + ); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'super-editor-image-caret-downstream-android'); + }, + // TODO: find out why this test fails on CI only. + skip: true, + ); + + testGoldensOnMac('shows caret at left side of an image', (tester) async { + await _pumpCaretTestApp(tester); + + // Tap close to the left edge of the editor to place the caret upstream + // on the image. + await tester.tapAt( + tester.getTopLeft(find.byType(SuperEditor)) + const Offset(20, 20), + ); + await tester.pump(); + + await screenMatchesGolden(tester, 'super-editor-image-caret-upstream-mac'); + }); + + testGoldensOniOS('shows caret at left side of an image', (tester) async { + await _pumpCaretTestApp(tester); + + // Tap close to the left edge of the editor to place the caret upstream + // on the image. + await tester.tapAt( + tester.getTopLeft(find.byType(SuperEditor)) + const Offset(20, 20), + ); + await tester.pump(); + + await screenMatchesGolden(tester, 'super-editor-image-caret-upstream-ios'); + }); + + testGoldensOnAndroid( + 'shows caret at left side of an image', + (tester) async { + await _pumpCaretTestApp(tester); + + // Tap close to the left edge of the editor to place the caret upstream + // on the image. + await tester.tapAt( + tester.getTopLeft(find.byType(SuperEditor)) + const Offset(20, 20), + ); + await tester.pump(); + + await screenMatchesGolden(tester, 'super-editor-image-caret-upstream-android'); + }, + // TODO: find out why this test fails on CI only. + skip: true, + ); + + testGoldensOniOS('allows customizing the caret width', (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .withIosCaretStyle(width: 4.0) + .pump(); + + // Place caret at "Lorem ip|sum" + await tester.placeCaretInParagraph('1', 8); + + await screenMatchesGolden(tester, 'super-editor-ios-custom-caret-width'); + }); + + testGoldensOniOS('allows customizing the expanded handle width', (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .withIosCaretStyle(width: 4.0) + .pump(); + + // Double tap to select the word ipsum. + await tester.doubleTapInParagraph('1', 8); + + await screenMatchesGolden(tester, 'super-editor-ios-custom-handle-width'); + }); + + testGoldensOniOS('allows customizing the expanded handle ball diameter', (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .withIosCaretStyle(handleBallDiameter: 16.0) + .pump(); + + // Double tap to select the word ipsum. + await tester.doubleTapInParagraph('1', 8); + + await screenMatchesGolden(tester, 'super-editor-ios-custom-handle-ball-diameter'); + }); + + testGoldensOnAndroid('allows customizing the caret width', (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .withAndroidCaretStyle(width: 4) + .pump(); + + // Place caret at "Lorem ip|sum" + await tester.placeCaretInParagraph('1', 8); + + await screenMatchesGolden(tester, 'super-editor-android-custom-caret-width'); + }); + + group('phone rotation updates caret position', () { + const screenSizePortrait = Size(400.0, 800.0); + const screenSizeLandscape = Size(800.0, 400); + + testGoldensOniOS('from portrait to landscape', (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = screenSizePortrait; + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + final context = await _pumpTestAppWithGoldenBricksFont(tester); + + // Place caret at "adipiscing elit|.". In portrait mode, this character + // is displayed on the second line. In landscape mode, it's displayed + // on the first line. + await tester.placeCaretInParagraph(context.document.first.id, 54); + + await screenMatchesGolden(tester, 'super-editor-caret-rotation-portrait-landscape-before-ios'); + + // Make the window wider, pushing the caret text position up a line. + tester.view.physicalSize = screenSizeLandscape; + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'super-editor-caret-rotation-portrait-landscape-after-ios'); + }); + + testGoldensOnAndroid('from portrait to landscape', (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = screenSizePortrait; + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + final context = await _pumpTestAppWithGoldenBricksFont(tester); + + // Place caret at "adipiscing elit|.". In portrait mode, this character + // is displayed on the second line. In landscape mode, it's displayed + // on the first line. + await tester.placeCaretInParagraph(context.document.first.id, 54); + + await screenMatchesGolden(tester, 'super-editor-caret-rotation-portrait-landscape-before-android'); + + // Make the window wider, pushing the caret text position up a line. + tester.view.physicalSize = screenSizeLandscape; + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'super-editor-caret-rotation-portrait-landscape-after-android'); + }, skip: true); + + testGoldensOniOS('from landscape to portrait', (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = screenSizeLandscape; + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + final context = await _pumpTestAppWithGoldenBricksFont(tester); + + // Place caret at "adipiscing elit|.". In portrait mode, this character + // is displayed on the second line. In landscape mode, it's displayed + // on the first line. + await tester.placeCaretInParagraph(context.document.first.id, 54); + + await screenMatchesGolden(tester, 'super-editor-caret-rotation-landscape-portrait-before-ios'); + + // Make the window thiner, pushing the caret text position down a line. + tester.view.physicalSize = screenSizePortrait; + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'super-editor-caret-rotation-landscape-portrait-after-ios'); + }); + + testGoldensOnAndroid('from landscape to portrait', (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = screenSizeLandscape; + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + final context = await _pumpTestAppWithGoldenBricksFont(tester); + + // Place caret at "adipiscing elit|.". In portrait mode, this character + // is displayed on the second line. In landscape mode, it's displayed + // on the first line. + await tester.placeCaretInParagraph(context.document.first.id, 54); + + await screenMatchesGolden(tester, 'super-editor-caret-rotation-landscape-portrait-before-android'); + + // Make the window thiner, pushing the caret text position down a line. + tester.view.physicalSize = screenSizePortrait; + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'super-editor-caret-rotation-landscape-portrait-after-android'); + }, skip: true); + }); + }); +} + +// Pumps an editor with a single image that takes all the available width. +Future _pumpCaretTestApp(WidgetTester tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ImageNode( + id: '1', + imageUrl: 'https://this.is.a.fake.image', + metadata: const SingleColumnLayoutComponentStyles( + width: double.infinity, + ).toMetadata(), + ), + ], + ), + ) + .withCaretStyle( + caretStyle: const CaretStyle(color: Colors.red), + ) + .useStylesheet( + defaultStylesheet.copyWith(addRulesAfter: [ + StyleRule( + BlockSelector.all, + (doc, docNode) => { + // Zeroes the padding so the component takes all + // the editor width. + Styles.padding: const CascadingPadding.all(0.0), + }, + ) + ]), + ) + .withAddedComponents( + [ + const FakeImageComponentBuilder( + size: Size(double.infinity, 100), + fillColor: Colors.yellow, + ), + ], + ).pump(); +} + +/// Pumps a widget tree with a [SuperEditor] styled with the Golden Bricks font +/// for all kinds of nodes. +Future _pumpTestAppWithGoldenBricksFont(WidgetTester tester) async { + return await tester // + .createDocument() + .fromMarkdown('Lorem ipsum dolor sit amet, consectetur adipiscing elit.') + .useStylesheet( + defaultStylesheet.copyWith(addRulesAfter: [ + StyleRule( + BlockSelector.all, + (doc, docNode) => { + Styles.textStyle: const TextStyle( + fontFamily: goldenBricks, + ) + }, + ) + ]), + ) + .pump(); +} diff --git a/super_editor/test_goldens/editor/supereditor_rtl_test.dart b/super_editor/test_goldens/editor/supereditor_rtl_test.dart new file mode 100644 index 0000000000..690505f90e --- /dev/null +++ b/super_editor/test_goldens/editor/supereditor_rtl_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group('SuperEditor > RTL mode >', () { + testGoldensOnAllPlatforms( + 'inserts text and paints caret on the left side of paragraph for downstream position', + (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the paragraph. + await tester.placeCaretInParagraph('1', 0); + + // Type the text "Example". + await tester.ime.typeText( + 'مثال', + getter: imeClientGetter, + ); + + await screenMatchesGolden( + tester, 'super-editor-rtl-caret-at-leftmost-character-paragraph-${defaultTargetPlatform.name}'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnAllPlatforms( + 'inserts text and paints caret on the left side of unordered list item for downstream position', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ListItemNode.unordered(id: '1', text: AttributedText()), + ], + ), + ) + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the list item. + await tester.placeCaretInParagraph('1', 0); + + // Type the text "Example". + await tester.ime.typeText( + 'مثال', + getter: imeClientGetter, + ); + + await screenMatchesGolden( + tester, 'super-editor-rtl-caret-at-leftmost-character-unordered-list-item-${defaultTargetPlatform.name}'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnAllPlatforms( + 'inserts text and paints caret on the left side of ordered list item for downstream position', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + ListItemNode.ordered(id: '1', text: AttributedText()), + ], + ), + ) + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the list item. + await tester.placeCaretInParagraph('1', 0); + + // Type the text "Example". + await tester.ime.typeText( + 'مثال', + getter: imeClientGetter, + ); + + await screenMatchesGolden( + tester, 'super-editor-rtl-caret-at-leftmost-character-ordered-list-item-${defaultTargetPlatform.name}'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnAllPlatforms( + 'inserts text and paints caret on the left side of task for downstream position', + (tester) async { + await tester // + .createDocument() + .withCustomContent( + MutableDocument( + nodes: [ + TaskNode(id: '1', text: AttributedText(), isComplete: false), + ], + ), + ) + .withInputSource(TextInputSource.ime) + .pump(); + + // Place the caret at the beginning of the task. + await tester.placeCaretInParagraph('1', 0); + + // Type the text "Example". + await tester.ime.typeText( + 'مثال', + getter: imeClientGetter, + ); + + await screenMatchesGolden( + tester, 'super-editor-rtl-caret-at-leftmost-character-task-${defaultTargetPlatform.name}'); + }, + windowSize: goldenSizeSmall, + ); + }); +} diff --git a/super_editor/test_goldens/editor/supereditor_selection_test.dart b/super_editor/test_goldens/editor/supereditor_selection_test.dart new file mode 100644 index 0000000000..783a4686ee --- /dev/null +++ b/super_editor/test_goldens/editor/supereditor_selection_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group("SuperEditor selection >", () { + group("color >", () { + testGoldensOnMac("default selection color", (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .pump(); + + // Select the whole paragraph so that the selection color is clearly visible. + await tester.tripleTapInParagraph("1", 0); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/super-editor_selection-color_default.png", 6), + ); + }); + + testGoldensOnMac("custom selection color", (tester) async { + await tester // + .createDocument() + .withSingleParagraph() + .withSelectionStyles(const SelectionStyles(selectionColor: Colors.deepPurple)) + .useStylesheet(defaultStylesheet.copyWith( + selectedTextColorStrategy: ({ + required Color originalTextColor, + required Color selectionHighlightColor, + }) => + Colors.white, + )) + .pump(); + + // Select the whole paragraph so that the selection color is clearly visible. + await tester.tripleTapInParagraph("1", 0); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/super-editor_selection-color_custom.png", 6), + ); + }); + }); + }); +} diff --git a/super_editor/test_goldens/editor/supereditor_text_layout_test.dart b/super_editor/test_goldens/editor/supereditor_text_layout_test.dart new file mode 100644 index 0000000000..0a54058e5a --- /dev/null +++ b/super_editor/test_goldens/editor/supereditor_text_layout_test.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group('SuperEditor', () { + group('applies textScaleFactor', () { + testGoldensOnAndroid('for paragraph', (tester) async { + await _buildTextScaleScaffold( + tester, + regularEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'regular-editor', + markdown: 'This is a paragraph', + ), + scaledEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'scaled-editor', + markdown: 'This is a paragraph', + ), + ); + + await screenMatchesGolden(tester, 'text-scaling-paragraph'); + }); + + testGoldensOnLinux('for paragraph with collapsed selection', (tester) async { + final regularEditorKey = GlobalKey(); + final scaledEditorKey = GlobalKey(); + + await _buildTextScaleScaffold( + tester, + regularEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'regular-editor', + markdown: 'This is a paragraph', + key: regularEditorKey, + ), + scaledEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'scaled-editor', + markdown: 'This is a paragraph', + key: scaledEditorKey, + ), + ); + + // Place caret at "This is| a paragraph" in the regular edditor. + await _placeCaretAtFirstNode( + tester, + offset: 7, + editorKey: regularEditorKey, + ); + + // Place caret at "This is| a paragraph" in the regular edditor. + await _placeCaretAtFirstNode( + tester, + offset: 7, + editorKey: scaledEditorKey, + ); + + await screenMatchesGolden(tester, 'text-scaling-paragraph-collapsed-selection'); + }); + + testGoldensOnLinux('for paragraph with expanded selection', (tester) async { + final regularEditorKey = GlobalKey(); + final scaledEditorKey = GlobalKey(); + + await _buildTextScaleScaffold( + tester, + regularEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'regular-editor', + markdown: 'This is a paragraph', + key: regularEditorKey, + ), + scaledEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'scaled-editor', + markdown: 'This is a paragraph', + key: scaledEditorKey, + ), + ); + + // Double tap at "This is a p|aragraph" to select the word "paragraph" in the regular editor. + await _doubleTapAtFirstNode( + tester, + offset: 11, + editorKey: regularEditorKey, + ); + + // Double tap at "This is a p|aragraph" to select the word "paragraph" in the scaled editor. + await _doubleTapAtFirstNode( + tester, + offset: 11, + editorKey: scaledEditorKey, + ); + + await expectLater( + find.byType(MaterialApp).first, + matchesGoldenFileWithPixelAllowance( + 'goldens/text-scaling-paragraph-expanded-selection.png', + 21, + ), + ); + }); + + testGoldensOnAndroid('for unordered list item', (tester) async { + await _buildTextScaleScaffold( + tester, + regularEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'regular-editor', + markdown: '- List item 1\n- List item 2', + ), + scaledEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'scaled-editor', + markdown: '- List item 1\n- List item 2', + ), + ); + + await screenMatchesGolden(tester, 'text-scaling-unordered-list'); + }); + + testGoldensOnAndroid('for ordered list item', (tester) async { + await _buildTextScaleScaffold( + tester, + regularEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'regular-editor', + markdown: '1. List item 1\n2. List item 2', + ), + scaledEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'scaled-editor', + markdown: '1. List item 1\n2. List item 2', + ), + ); + + await screenMatchesGolden(tester, 'text-scaling-ordered-list'); + }); + + testGoldensOnAndroid('for header', (tester) async { + await _buildTextScaleScaffold( + tester, + regularEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'regular-editor', + markdown: '# This is a header', + ), + scaledEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'scaled-editor', + markdown: '# This is a header', + ), + ); + + await expectLater( + find.byType(MaterialApp).first, + matchesGoldenFileWithPixelAllowance("goldens/text-scaling-header.png", 125), + ); + }); + + testGoldensOnAndroid('for blockquote', (tester) async { + await _buildTextScaleScaffold( + tester, + regularEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'regular-editor', + markdown: '> This is a blockquote', + ), + scaledEditor: _buildSuperEditorFromMarkdown( + tester, + inputRole: 'scaled-editor', + markdown: '> This is a blockquote', + ), + ); + + await expectLater( + find.byType(MaterialApp).first, + matchesGoldenFileWithPixelAllowance("goldens/text-scaling-blockquote.png", 40), + ); + }); + }); + }); +} + +/// Places the caret at [offset] in the editor with the given [editorKey]. +Future _placeCaretAtFirstNode( + WidgetTester tester, { + required int offset, + required Key editorKey, +}) async { + final regularEditorFinder = find.byKey(editorKey); + final regularDoc = SuperEditorInspector.findDocument(regularEditorFinder)!; + await tester.placeCaretInParagraph( + regularDoc.first.id, + offset, + superEditorFinder: regularEditorFinder, + ); +} + +/// Double taps at [offset] in the editor with the given [editorKey]. +Future _doubleTapAtFirstNode( + WidgetTester tester, { + required int offset, + required Key editorKey, +}) async { + final regularEditorFinder = find.byKey(editorKey); + final regularDoc = SuperEditorInspector.findDocument(regularEditorFinder)!; + await tester.doubleTapInParagraph( + regularDoc.first.id, + offset, + superEditorFinder: regularEditorFinder, + ); +} + +/// Builds a [SuperEditor] for desktop from the given [markdown]. +/// +/// This editor uses [_stylesheet] and doesn't clear selection when loses focus. +Widget _buildSuperEditorFromMarkdown( + WidgetTester tester, { + required String inputRole, + required String markdown, + Key? key, +}) { + return tester // + .createDocument() + .fromMarkdown(markdown) + .forDesktop() + .withKey(key) + .withInputRole(inputRole) + .withSelectionPolicies( + const SuperEditorSelectionPolicies( + clearSelectionWhenEditorLosesFocus: false, + clearSelectionWhenImeConnectionCloses: false, + ), + ) + .useStylesheet(_stylesheet) + .build() + .widget; +} + +/// A [StyleSheet] which applies the Roboto font for all nodes. +/// +/// This is needed to use real font glyphs in the golden tests. +final _stylesheet = defaultStylesheet.copyWith( + addRulesAfter: [ + StyleRule(BlockSelector.all, (doc, node) { + return { + Styles.textStyle: const TextStyle( + fontFamily: 'Roboto', + ), + }; + }) + ], +); + +TextStyle inlineTextStyler(Set attributions, TextStyle base) { + return base; +} + +/// Pumps a widget tree containing two editors side by side. +/// +/// The left editor has the default `textScaleFactor`. +/// +/// The right editor has `textScaleFactor` set to `2.0`. +Future _buildTextScaleScaffold( + WidgetTester tester, { + required Widget regularEditor, + required Widget scaledEditor, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + Expanded( + child: regularEditor, + ), + Expanded( + child: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2.0)), + child: scaledEditor, + ), + ), + ], + ), + ), + debugShowCheckedModeBanner: false, + ), + ); +} diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_blockquote_linux.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_blockquote_linux.png new file mode 100644 index 0000000000..f30bb7ec20 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_blockquote_linux.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_blockquote_windows.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_blockquote_windows.png new file mode 100644 index 0000000000..f30bb7ec20 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_blockquote_windows.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_paragraph_linux.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_paragraph_linux.png new file mode 100644 index 0000000000..132bdc899a Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_paragraph_linux.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_paragraph_windows.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_paragraph_windows.png new file mode 100644 index 0000000000..132bdc899a Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-showing-nothing_paragraph_windows.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_list-item_linux.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_list-item_linux.png new file mode 100644 index 0000000000..d449f7ca47 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_list-item_linux.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_list-item_windows.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_list-item_windows.png new file mode 100644 index 0000000000..d449f7ca47 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_list-item_windows.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_task_linux.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_task_linux.png new file mode 100644 index 0000000000..d398b22985 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_task_linux.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_task_windows.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_task_windows.png new file mode 100644 index 0000000000..d398b22985 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-nothing_task_windows.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_android_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_android_1.png new file mode 100644 index 0000000000..9d7638b265 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_android_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_android_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_android_2.png new file mode 100644 index 0000000000..6a71ec5256 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_android_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_iOS_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_iOS_1.png new file mode 100644 index 0000000000..9d7638b265 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_iOS_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_iOS_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_iOS_2.png new file mode 100644 index 0000000000..6a71ec5256 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_iOS_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_macOS_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_macOS_1.png new file mode 100644 index 0000000000..568266ba60 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_macOS_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_macOS_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_macOS_2.png new file mode 100644 index 0000000000..f30bb7ec20 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_macOS_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_android_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_android_1.png new file mode 100644 index 0000000000..85650f741b Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_android_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_android_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_android_2.png new file mode 100644 index 0000000000..b508c7c476 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_android_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_iOS_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_iOS_1.png new file mode 100644 index 0000000000..85650f741b Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_iOS_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_iOS_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_iOS_2.png new file mode 100644 index 0000000000..b508c7c476 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_iOS_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_macOS_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_macOS_1.png new file mode 100644 index 0000000000..f34d472622 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_macOS_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_macOS_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_macOS_2.png new file mode 100644 index 0000000000..d449f7ca47 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_list-item_macOS_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_android_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_android_1.png new file mode 100644 index 0000000000..9b08fa7455 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_android_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_android_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_android_2.png new file mode 100644 index 0000000000..e5a9142d0e Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_android_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_iOS_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_iOS_1.png new file mode 100644 index 0000000000..9b08fa7455 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_iOS_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_iOS_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_iOS_2.png new file mode 100644 index 0000000000..e5a9142d0e Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_iOS_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_macOS_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_macOS_1.png new file mode 100644 index 0000000000..adedd67a70 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_macOS_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_macOS_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_macOS_2.png new file mode 100644 index 0000000000..132bdc899a Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_macOS_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_android_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_android_1.png new file mode 100644 index 0000000000..8f1c395650 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_android_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_android_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_android_2.png new file mode 100644 index 0000000000..64b661d8d5 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_android_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_iOS_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_iOS_1.png new file mode 100644 index 0000000000..8f1c395650 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_iOS_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_iOS_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_iOS_2.png new file mode 100644 index 0000000000..64b661d8d5 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_iOS_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_macOS_1.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_macOS_1.png new file mode 100644 index 0000000000..f7965a11cd Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_macOS_1.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_macOS_2.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_macOS_2.png new file mode 100644 index 0000000000..d398b22985 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_composing-region-shows-underline_task_macOS_2.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_android_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_android_no-stylesheet.png new file mode 100644 index 0000000000..e556c7875c Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_android_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_android_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_android_with-stylesheet.png new file mode 100644 index 0000000000..0b9e6abb38 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_android_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_iOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_iOS_no-stylesheet.png new file mode 100644 index 0000000000..e556c7875c Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_iOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_iOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_iOS_with-stylesheet.png new file mode 100644 index 0000000000..0b9e6abb38 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_iOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_macOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_macOS_no-stylesheet.png new file mode 100644 index 0000000000..e556c7875c Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_macOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_macOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_macOS_with-stylesheet.png new file mode 100644 index 0000000000..0b9e6abb38 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_blockquote_macOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_android_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_android_no-stylesheet.png new file mode 100644 index 0000000000..16aa4a939f Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_android_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_android_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_android_with-stylesheet.png new file mode 100644 index 0000000000..d3814e4584 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_android_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_iOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_iOS_no-stylesheet.png new file mode 100644 index 0000000000..16aa4a939f Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_iOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_iOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_iOS_with-stylesheet.png new file mode 100644 index 0000000000..d3814e4584 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_iOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_macOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_macOS_no-stylesheet.png new file mode 100644 index 0000000000..16aa4a939f Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_macOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_macOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_macOS_with-stylesheet.png new file mode 100644 index 0000000000..d3814e4584 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_list-item_macOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_android_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_android_no-stylesheet.png new file mode 100644 index 0000000000..3758f10c50 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_android_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_android_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_android_with-stylesheet.png new file mode 100644 index 0000000000..4177c993a8 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_android_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_iOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_iOS_no-stylesheet.png new file mode 100644 index 0000000000..3758f10c50 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_iOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_iOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_iOS_with-stylesheet.png new file mode 100644 index 0000000000..4177c993a8 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_iOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_macOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_macOS_no-stylesheet.png new file mode 100644 index 0000000000..3758f10c50 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_macOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_macOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_macOS_with-stylesheet.png new file mode 100644 index 0000000000..4177c993a8 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_paragraph_macOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_android_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_android_no-stylesheet.png new file mode 100644 index 0000000000..4776e1dff3 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_android_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_android_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_android_with-stylesheet.png new file mode 100644 index 0000000000..f092cd141a Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_android_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_iOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_iOS_no-stylesheet.png new file mode 100644 index 0000000000..4776e1dff3 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_iOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_iOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_iOS_with-stylesheet.png new file mode 100644 index 0000000000..f092cd141a Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_iOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_macOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_macOS_no-stylesheet.png new file mode 100644 index 0000000000..bc43bbcd8e Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_macOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_macOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_macOS_with-stylesheet.png new file mode 100644 index 0000000000..435a4d17d2 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_grammar-error-shows-underline_task_macOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_android_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_android_no-stylesheet.png new file mode 100644 index 0000000000..d29846e499 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_android_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_android_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_android_with-stylesheet.png new file mode 100644 index 0000000000..95a9271d6d Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_android_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_iOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_iOS_no-stylesheet.png new file mode 100644 index 0000000000..d29846e499 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_iOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_iOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_iOS_with-stylesheet.png new file mode 100644 index 0000000000..95a9271d6d Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_iOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_macOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_macOS_no-stylesheet.png new file mode 100644 index 0000000000..d29846e499 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_macOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_macOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_macOS_with-stylesheet.png new file mode 100644 index 0000000000..95a9271d6d Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_blockquote_macOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_android_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_android_no-stylesheet.png new file mode 100644 index 0000000000..82e636e12b Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_android_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_android_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_android_with-stylesheet.png new file mode 100644 index 0000000000..26c5d73676 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_android_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_iOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_iOS_no-stylesheet.png new file mode 100644 index 0000000000..82e636e12b Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_iOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_iOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_iOS_with-stylesheet.png new file mode 100644 index 0000000000..26c5d73676 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_iOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_macOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_macOS_no-stylesheet.png new file mode 100644 index 0000000000..82e636e12b Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_macOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_macOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_macOS_with-stylesheet.png new file mode 100644 index 0000000000..26c5d73676 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_list-item_macOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_android_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_android_no-stylesheet.png new file mode 100644 index 0000000000..073ed05362 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_android_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_android_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_android_with-stylesheet.png new file mode 100644 index 0000000000..3d71814dbd Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_android_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_iOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_iOS_no-stylesheet.png new file mode 100644 index 0000000000..073ed05362 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_iOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_iOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_iOS_with-stylesheet.png new file mode 100644 index 0000000000..3d71814dbd Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_iOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_macOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_macOS_no-stylesheet.png new file mode 100644 index 0000000000..073ed05362 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_macOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_macOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_macOS_with-stylesheet.png new file mode 100644 index 0000000000..3d71814dbd Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_paragraph_macOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_android_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_android_no-stylesheet.png new file mode 100644 index 0000000000..8ee4f0c8d5 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_android_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_android_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_android_with-stylesheet.png new file mode 100644 index 0000000000..beb563d432 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_android_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_iOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_iOS_no-stylesheet.png new file mode 100644 index 0000000000..8ee4f0c8d5 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_iOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_iOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_iOS_with-stylesheet.png new file mode 100644 index 0000000000..beb563d432 Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_iOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_macOS_no-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_macOS_no-stylesheet.png new file mode 100644 index 0000000000..6f590e1e0c Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_macOS_no-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_macOS_with-stylesheet.png b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_macOS_with-stylesheet.png new file mode 100644 index 0000000000..b7d77758cf Binary files /dev/null and b/super_editor/test_goldens/editor/text_entry/goldens/super-editor_text-entry_spelling-error-shows-underline_task_macOS_with-stylesheet.png differ diff --git a/super_editor/test_goldens/editor/text_entry/super_editor_composing_region_underline_test.dart b/super_editor/test_goldens/editor/text_entry/super_editor_composing_region_underline_test.dart new file mode 100644 index 0000000000..10c6540737 --- /dev/null +++ b/super_editor/test_goldens/editor/text_entry/super_editor_composing_region_underline_test.dart @@ -0,0 +1,293 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group("SuperEditor > text entry > composing region >", () { + testGoldensOnAndroid("is underlined in paragraph", _showsUnderlineInParagraph, windowSize: goldenSizeLongStrip); + testGoldensOnAndroid("is underlined in blockquote", _showsUnderlineInBlockquote, windowSize: goldenSizeLongStrip); + testGoldensOnAndroid("is underlined in list item", _showsUnderlineInListItem, windowSize: goldenSizeLongStrip); + testGoldensOnAndroid("is underlined in task", _showsUnderlineInTask, windowSize: goldenSizeLongStrip); + + testGoldensOniOS("is underlined in paragraph", _showsUnderlineInParagraph, windowSize: goldenSizeLongStrip); + testGoldensOniOS("is underlined in blockquote", _showsUnderlineInBlockquote, windowSize: goldenSizeLongStrip); + testGoldensOniOS("is underlined in list item", _showsUnderlineInListItem, windowSize: goldenSizeLongStrip); + testGoldensOniOS("is underlined in task", _showsUnderlineInTask, windowSize: goldenSizeLongStrip); + + testGoldensOnMac("is underlined in paragraph", _showsUnderlineInParagraph, windowSize: goldenSizeLongStrip); + testGoldensOnMac("is underlined in blockquote", _showsUnderlineInBlockquote, windowSize: goldenSizeLongStrip); + testGoldensOnMac("is underlined in list item", _showsUnderlineInListItem, windowSize: goldenSizeLongStrip); + testGoldensOnMac("is underlined in task", _showsUnderlineInTask, windowSize: goldenSizeLongStrip); + }); + + group("SuperEditor > text entry > composing region >", () { + testGoldensOnWindows("shows nothing in paragraph", _showsNothingInParagraph, windowSize: goldenSizeLongStrip); + testGoldensOnWindows("shows nothing in blockquote", _showsNothingInBlockquote, windowSize: goldenSizeLongStrip); + testGoldensOnWindows("shows nothing in list item", _showsNothingInListItem, windowSize: goldenSizeLongStrip); + testGoldensOnWindows("shows nothing in task", _showsNothingInTask, windowSize: goldenSizeLongStrip); + + testGoldensOnLinux("shows nothing in paragraph", _showsNothingInParagraph, windowSize: goldenSizeLongStrip); + testGoldensOnLinux("shows nothing in blockquote", _showsNothingInBlockquote, windowSize: goldenSizeLongStrip); + testGoldensOnLinux("shows nothing in list item", _showsNothingInListItem, windowSize: goldenSizeLongStrip); + testGoldensOnLinux("shows nothing in task", _showsNothingInTask, windowSize: goldenSizeLongStrip); + }); +} + +Future _showsUnderlineInParagraph(WidgetTester tester) async { + final (editor, document) = await _pumpScaffold(tester, _paragraphMarkdown); + + await _simulateComposingRegion(tester, editor, document); + + // Ensure the composing region is underlined. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_${defaultTargetPlatform.name}_1.png", + 7, + ), + ); + + await _clearComposingRegion(tester, editor, document); + + // Ensure the underline disappeared now that the composing region is null. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-shows-underline_paragraph_${defaultTargetPlatform.name}_2.png", + 7, + ), + ); +} + +Future _showsUnderlineInBlockquote(WidgetTester tester) async { + final (editor, document) = await _pumpScaffold(tester, _blockquoteMarkdown); + + await _simulateComposingRegion(tester, editor, document); + + // Ensure the composing region is underlined. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_${defaultTargetPlatform.name}_1.png", + 7, + ), + ); + + await _clearComposingRegion(tester, editor, document); + + // Ensure the underline disappeared now that the composing region is null. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-shows-underline_blockquote_${defaultTargetPlatform.name}_2.png", + 7, + ), + ); +} + +Future _showsNothingInParagraph(WidgetTester tester) async { + final (editor, document) = await _pumpScaffold(tester, _paragraphMarkdown); + + await _simulateComposingRegion(tester, editor, document); + + // Ensure the composing region is underlined. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-showing-nothing_paragraph_${defaultTargetPlatform.name}.png", + 7, + ), + ); +} + +Future _showsNothingInBlockquote(WidgetTester tester) async { + final (editor, document) = await _pumpScaffold(tester, _blockquoteMarkdown); + + await _simulateComposingRegion(tester, editor, document); + + // Ensure the composing region is underlined. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-showing-nothing_blockquote_${defaultTargetPlatform.name}.png", + 7, + ), + ); +} + +Future _showsUnderlineInListItem(WidgetTester tester) async { + final (editor, document) = await _pumpScaffold(tester, _listItemMarkdown); + + await _simulateComposingRegion(tester, editor, document); + + // Ensure the composing region is underlined. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-shows-underline_list-item_${defaultTargetPlatform.name}_1.png", + 7, + ), + ); + + await _clearComposingRegion(tester, editor, document); + + // Ensure the underline disappeared now that the composing region is null. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-shows-underline_list-item_${defaultTargetPlatform.name}_2.png", + 7, + ), + ); +} + +Future _showsNothingInListItem(WidgetTester tester) async { + final (editor, document) = await _pumpScaffold(tester, _listItemMarkdown); + + await _simulateComposingRegion(tester, editor, document); + + // Ensure the composing region is underlined. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-shows-nothing_list-item_${defaultTargetPlatform.name}.png", + 7, + ), + ); +} + +Future _showsUnderlineInTask(WidgetTester tester) async { + final (editor, document) = await _pumpScaffold(tester, _taskMarkdown); + + await _simulateComposingRegion(tester, editor, document); + + // Ensure the composing region is underlined. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-shows-underline_task_${defaultTargetPlatform.name}_1.png", + 7, + ), + ); + + await _clearComposingRegion(tester, editor, document); + + // Ensure the underline disappeared now that the composing region is null. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-shows-underline_task_${defaultTargetPlatform.name}_2.png", + 7, + ), + ); +} + +Future _showsNothingInTask(WidgetTester tester) async { + final (editor, document) = await _pumpScaffold(tester, _taskMarkdown); + + await _simulateComposingRegion(tester, editor, document); + + // Ensure the composing region is underlined. + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-editor_text-entry_composing-region-shows-nothing_task_${defaultTargetPlatform.name}.png", + 7, + ), + ); +} + +Future<(Editor, Document)> _pumpScaffold(WidgetTester tester, String contentMarkdown) async { + // TODO: Whenever we're able to create a TaskComponentBuilder without passing the Editor, refactor + // this setup to look like a normal SuperEditor test. + final document = deserializeMarkdownToDocument(contentMarkdown); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + stylesheet: _stylesheet, + ), + ), + ), + debugShowCheckedModeBanner: false, + )); + + return (editor, document); +} + +Future _simulateComposingRegion(WidgetTester tester, Editor editor, Document document) async { + final nodeId = document.first.id; + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 23), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ChangeComposingRegionRequest( + DocumentRange( + start: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 22), + ), + end: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 23), + ), + ), + ), + ]); + await tester.pumpAndSettle(); +} + +Future _clearComposingRegion(WidgetTester tester, Editor editor, Document document) async { + final nodeId = document.first.id; + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeId, + nodePosition: const TextNodePosition(offset: 23), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + const ClearComposingRegionRequest(), + ]); + await tester.pump(); +} + +const _paragraphMarkdown = "Typing with composing a"; +const _blockquoteMarkdown = "> Typing with composing a"; +const _listItemMarkdown = " * Typing with composing a"; +const _taskMarkdown = "- [ ] Typing with composing a"; + +/// A [StyleSheet] which applies the Roboto font for all nodes. +/// +/// This is needed to use real font glyphs in the golden tests. +final _stylesheet = defaultStylesheet.copyWith( + addRulesAfter: [ + StyleRule(BlockSelector.all, (doc, node) { + return { + Styles.textStyle: const TextStyle( + fontFamily: 'Roboto', + ), + }; + }) + ], +); diff --git a/super_editor/test_goldens/editor/text_entry/super_editor_spelling_error_underline_test.dart b/super_editor/test_goldens/editor/text_entry/super_editor_spelling_error_underline_test.dart new file mode 100644 index 0000000000..b84426c20e --- /dev/null +++ b/super_editor/test_goldens/editor/text_entry/super_editor_spelling_error_underline_test.dart @@ -0,0 +1,345 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group("SuperEditor > text entry > text errors >", () { + group("direct styling >", () { + group("spelling >", () { + _createDirectStylingTests(_ErrorType.spelling); + }); + + group("grammar >", () { + _createDirectStylingTests(_ErrorType.grammar); + }); + }); + + group("stylesheet styling >", () { + group("spelling >", () { + _createStylesheetStylingTests(_ErrorType.spelling); + }); + + group("grammar >", () { + _createStylesheetStylingTests(_ErrorType.grammar); + }); + }); + }); +} + +void _createDirectStylingTests(_ErrorType type) { + testGoldensOnMobile( + "is underlined in paragraph", + _createWidgetTest( + contentTypeName: "paragraph", + testNameQualifier: "no-stylesheet", + stylesheet: _stylesheetWithNoSpellingErrorStyles, + content: _paragraphMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMobile( + "is underlined in blockquote", + _createWidgetTest( + contentTypeName: "blockquote", + testNameQualifier: "no-stylesheet", + stylesheet: _stylesheetWithNoSpellingErrorStyles, + content: _blockquoteMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMobile( + "is underlined in list item", + _createWidgetTest( + contentTypeName: "list-item", + testNameQualifier: "no-stylesheet", + stylesheet: _stylesheetWithNoSpellingErrorStyles, + content: _listItemMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMobile( + "is underlined in task", + _createWidgetTest( + contentTypeName: "task", + testNameQualifier: "no-stylesheet", + stylesheet: _stylesheetWithNoSpellingErrorStyles, + content: _taskMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + + testGoldensOnMac( + "is underlined in paragraph", + _createWidgetTest( + contentTypeName: "paragraph", + testNameQualifier: "no-stylesheet", + stylesheet: _stylesheetWithNoSpellingErrorStyles, + content: _paragraphMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMac( + "is underlined in blockquote", + _createWidgetTest( + contentTypeName: "blockquote", + testNameQualifier: "no-stylesheet", + stylesheet: _stylesheetWithNoSpellingErrorStyles, + content: _blockquoteMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMac( + "is underlined in list item", + _createWidgetTest( + contentTypeName: "list-item", + testNameQualifier: "no-stylesheet", + stylesheet: _stylesheetWithNoSpellingErrorStyles, + content: _listItemMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMac( + "is underlined in task", + _createWidgetTest( + contentTypeName: "task", + testNameQualifier: "no-stylesheet", + stylesheet: _stylesheetWithNoSpellingErrorStyles, + content: _taskMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); +} + +void _createStylesheetStylingTests(_ErrorType type) { + testGoldensOnMobile( + "is underlined in paragraph", + _createWidgetTest( + contentTypeName: "paragraph", + testNameQualifier: "with-stylesheet", + stylesheet: _stylesheetWithSpellingErrorStyles, + content: _paragraphMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMobile( + "is underlined in blockquote", + _createWidgetTest( + contentTypeName: "blockquote", + testNameQualifier: "with-stylesheet", + stylesheet: _stylesheetWithSpellingErrorStyles, + content: _blockquoteMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMobile( + "is underlined in list item", + _createWidgetTest( + contentTypeName: "list-item", + testNameQualifier: "with-stylesheet", + stylesheet: _stylesheetWithSpellingErrorStyles, + content: _listItemMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMobile( + "is underlined in task", + _createWidgetTest( + contentTypeName: "task", + testNameQualifier: "with-stylesheet", + stylesheet: _stylesheetWithSpellingErrorStyles, + content: _taskMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + + testGoldensOnMac( + "is underlined in paragraph", + _createWidgetTest( + contentTypeName: "paragraph", + testNameQualifier: "with-stylesheet", + stylesheet: _stylesheetWithSpellingErrorStyles, + content: _paragraphMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMac( + "is underlined in blockquote", + _createWidgetTest( + contentTypeName: "blockquote", + testNameQualifier: "with-stylesheet", + stylesheet: _stylesheetWithSpellingErrorStyles, + content: _blockquoteMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMac( + "is underlined in list item", + _createWidgetTest( + contentTypeName: "list-item", + testNameQualifier: "with-stylesheet", + stylesheet: _stylesheetWithSpellingErrorStyles, + content: _listItemMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); + testGoldensOnMac( + "is underlined in task", + _createWidgetTest( + contentTypeName: "task", + testNameQualifier: "with-stylesheet", + stylesheet: _stylesheetWithSpellingErrorStyles, + content: _taskMarkdown, + errorType: type, + ), + windowSize: goldenSizeLongStrip, + ); +} + +Future Function(WidgetTester) _createWidgetTest({ + required String contentTypeName, + required String testNameQualifier, + required Stylesheet stylesheet, + required String content, + required _ErrorType errorType, +}) { + return (WidgetTester tester) async { + final document = deserializeMarkdownToDocument(content); + + final (_, _) = await _pumpScaffold( + tester, + stylesheet, + document, + spellingError: errorType == _ErrorType.spelling + ? TextError( + nodeId: document.first.id, + range: const TextRange(start: 22, end: 23), + type: TextErrorType.spelling, + value: "a", + ) + : null, + grammarError: errorType == _ErrorType.grammar + ? TextError( + nodeId: document.first.id, + range: const TextRange(start: 7, end: 11), + type: TextErrorType.grammar, + value: "with", + ) + : null, + ); + + late final String goldenName; + switch (errorType) { + case _ErrorType.spelling: + goldenName = + "super-editor_text-entry_spelling-error-shows-underline_${contentTypeName}_${defaultTargetPlatform.name}_$testNameQualifier"; + case _ErrorType.grammar: + goldenName = + "super-editor_text-entry_grammar-error-shows-underline_${contentTypeName}_${defaultTargetPlatform.name}_$testNameQualifier"; + } + + await screenMatchesGolden(tester, goldenName); + }; +} + +enum _ErrorType { + spelling, + grammar; +} + +Future<(Editor, Document)> _pumpScaffold( + WidgetTester tester, + Stylesheet stylesheet, + MutableDocument document, { + TextError? spellingError, + TextError? grammarError, +}) async { + // TODO: Whenever we're able to create a TaskComponentBuilder without passing the Editor, refactor + // this setup to look like a normal SuperEditor test. + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + + final spellingAndGrammarStyler = SpellingAndGrammarStyler() + ..addErrors(document.first.id, { + if (spellingError != null) spellingError, + if (grammarError != null) grammarError, + }); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SuperEditor( + editor: editor, + componentBuilders: [ + TaskComponentBuilder(editor), + ...defaultComponentBuilders, + ], + stylesheet: stylesheet, + customStylePhases: [ + spellingAndGrammarStyler, + ], + ), + ), + ), + debugShowCheckedModeBanner: false, + )); + + return (editor, document); +} + +const _paragraphMarkdown = "Typing with composing a"; +const _blockquoteMarkdown = "> Typing with composing a"; +const _listItemMarkdown = " * Typing with composing a"; +const _taskMarkdown = "- [ ] Typing with composing a"; + +/// A [StyleSheet] which applies the Roboto font for all nodes. +/// +/// This is needed to use real font glyphs in the golden tests. +final _stylesheetWithNoSpellingErrorStyles = defaultStylesheet.copyWith( + addRulesAfter: [ + StyleRule(BlockSelector.all, (doc, node) { + return { + Styles.textStyle: const TextStyle( + fontFamily: 'Roboto', + ), + }; + }) + ], +); + +/// The same as [_stylesheetWithNoSpellingErrorStyles] but with an explicit style +/// for spelling errors. +final _stylesheetWithSpellingErrorStyles = defaultStylesheet.copyWith( + addRulesAfter: [ + StyleRule(BlockSelector.all, (doc, node) { + return { + Styles.textStyle: const TextStyle( + fontFamily: 'Roboto', + ), + Styles.spellingErrorUnderlineStyle: const SquiggleUnderlineStyle( + color: Colors.orange, + ), + Styles.grammarErrorUnderlineStyle: const SquiggleUnderlineStyle( + color: Colors.green, + ), + }; + }) + ], +); diff --git a/super_editor/test_goldens/super_message/goldens/supermessage_re-render-on-style-change.png b/super_editor/test_goldens/super_message/goldens/supermessage_re-render-on-style-change.png new file mode 100644 index 0000000000..963a06294b Binary files /dev/null and b/super_editor/test_goldens/super_message/goldens/supermessage_re-render-on-style-change.png differ diff --git a/super_editor/test_goldens/super_message/mobile/goldens/supermessage_android_long-press-selection.png b/super_editor/test_goldens/super_message/mobile/goldens/supermessage_android_long-press-selection.png new file mode 100644 index 0000000000..f54c56e9e3 Binary files /dev/null and b/super_editor/test_goldens/super_message/mobile/goldens/supermessage_android_long-press-selection.png differ diff --git a/super_editor/test_goldens/super_message/mobile/goldens/supermessage_ios_long-press-selection.png b/super_editor/test_goldens/super_message/mobile/goldens/supermessage_ios_long-press-selection.png new file mode 100644 index 0000000000..62fe2bfc7a Binary files /dev/null and b/super_editor/test_goldens/super_message/mobile/goldens/supermessage_ios_long-press-selection.png differ diff --git a/super_editor/test_goldens/super_message/mobile/super_message_selection_test.dart b/super_editor/test_goldens/super_message/mobile/super_message_selection_test.dart new file mode 100644 index 0000000000..9f1a015fda --- /dev/null +++ b/super_editor/test_goldens/super_message/mobile/super_message_selection_test.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/flutter_test_goldens.dart'; +import 'package:flutter_test_goldens/golden_bricks.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group("Super Message > selection >", () { + testGoldenSceneOnAndroid("long press selection", (tester) async { + final timeline = Timeline( + "Android - Long Press Selection", + fileName: 'supermessage_android_long-press-selection', + windowSize: const Size(1179, 2556) / 3.0, + layout: ColumnSceneLayout( + // background: GoldenSceneBackground.widget(ShadcnBackground()), + // itemDecorator: shadcnItemDecorator, + ), + // TODO: Document how to create an item scaffold, including the need for GoldenImageBounds + itemScaffold: _chatItemScaffold, + ); + + timeline.setupWithWidget( + _buildSuperMessage(), + ); + + await _runLongPressTimeline(tester, timeline); + }); + + testGoldenSceneOnIOS("long press selection", (tester) async { + final timeline = Timeline( + "iOS - Long Press Selection", + fileName: 'supermessage_ios_long-press-selection', + windowSize: const Size(1179, 2556) / 3.0, + layout: ColumnSceneLayout( + // background: GoldenSceneBackground.widget(ShadcnBackground()), + // itemDecorator: shadcnItemDecorator, + ), + // TODO: Document how to create an item scaffold, including the need for GoldenImageBounds + itemScaffold: _chatItemScaffold, + ); + + timeline.setupWithWidget( + _buildSuperMessage(), + ); + + await _runLongPressTimeline(tester, timeline); + }); + }); +} + +Future _runLongPressTimeline(WidgetTester tester, Timeline timeline) async { + await timeline + .takePhoto("Idle") + // Long press on word + .modifyScene((tester, testContext) async { + final longPress = await tester.longPressDownInParagraph("1", 62); + testContext.activeGesture = longPress; + + await tester.pump(); + }) + .takePhoto("Long Press") + // Drag to left + .modifyScene((tester, testContext) async { + final longPress = testContext.activeGesture!; + await longPress.moveBy(const Offset(-75, 0)); + await tester.pump(); + + // For some reason (on iOS) we need one extra pump to fully update dirty paint status. + await tester.pump(); + }) + .takePhoto("Drag Left") + // Drag up a line + .modifyScene((tester, testContext) async { + final longPress = testContext.activeGesture!; + await longPress.moveBy(const Offset(0, -20)); + await tester.pump(); + + // For some reason (on iOS) we need one extra pump to fully update dirty paint status. + await tester.pump(); + }) + .takePhoto("Drag Up") + // Drag back to the original word, then to the right. + .modifyScene((tester, testContext) async { + // Back to starting point. + final longPress = testContext.activeGesture!; + await longPress.moveBy(const Offset(75, 20)); + await tester.pump(); + + // Drag to the right. + await longPress.moveBy(const Offset(50, 0)); + await tester.pump(); + + // For some reason (on iOS) we need one extra pump to fully update dirty paint status. + await tester.pump(); + }) + .takePhoto("Drag Up") + // Drag down a line + .modifyScene((tester, testContext) async { + final longPress = testContext.activeGesture!; + await longPress.moveBy(const Offset(0, 20)); + await tester.pump(); + + // For some reason we need one extra pump to fully update dirty paint status. + await tester.pump(); + }) + .takePhoto("Drag Down") + // Release the drag and show the handles and toolbar. + .modifyScene((tester, testContext) async { + final longPress = testContext.activeGesture!; + await longPress.up(); + await tester.pump(); + + // For some reason we need one extra pump to fully update dirty paint status. + await tester.pump(); + }) + .takePhoto("Release") + .run(tester); +} + +Widget _chatItemScaffold(tester, content) { + return GoldenSceneBounds( + child: MaterialApp( + theme: ThemeData( + fontFamily: goldenBricks, + ), + home: Scaffold( + body: Center( + child: GoldenImageBounds( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), + child: content, + ), + ), + ), + ), + ), + ); +} + +Widget _buildSuperMessage() { + return SuperMessage( + editor: createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "This is a SuperMessage widget. It's used for chat use-cases. This message if fairly long so that we can have three lines of height.", + ), + ), + ], + ), + ), + styles: SuperMessageStyles( + stylesheet: defaultLightChatStylesheet.copyWith( + addRulesAfter: [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + fontFamily: goldenBricks, + ), + }; + }, + ), + ], + ), + selectionStyles: const SelectionStyles( + selectionColor: Color(0xFFACCEF7), + ), + ), + ); +} diff --git a/super_editor/test_goldens/super_message/super_message_styles_test.dart b/super_editor/test_goldens/super_message/super_message_styles_test.dart new file mode 100644 index 0000000000..b1bbd119e7 --- /dev/null +++ b/super_editor/test_goldens/super_message/super_message_styles_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/flutter_test_goldens.dart'; +import 'package:flutter_test_goldens/golden_bricks.dart'; +import 'package:super_editor/src/chat/super_message.dart'; +import 'package:super_editor/src/default_editor/default_document_editor.dart'; +import 'package:super_editor/src/test/flutter_extensions/test_documents.dart'; +import 'package:super_editor/src/test/super_reader_test/super_reader_robot.dart'; + +void main() { + group("Super Message > styles >", () { + testGoldenSceneOnIOS("re-renders when styles change", (tester) async { + final editor = createDefaultDocumentEditor( + document: singleParagraphDocShortText(), + ); + final lightStyles = SuperMessageStyles( + stylesheet: defaultLightChatStylesheet, + selectionStyles: defaultLightChatSelectionStyles, + ); + final darkStyles = SuperMessageStyles( + stylesheet: defaultDarkChatStylesheet, + selectionStyles: defaultDarkChatSelectionStyles, + ); + + final timeline = Timeline( + "Re-Render on Style Change", + fileName: 'supermessage_re-render-on-style-change', + windowSize: const Size(1179, 2556) / 3.0, + layout: const ColumnSceneLayout(), + // TODO: Document how to create an item scaffold, including the need for GoldenImageBounds + itemScaffold: _chatItemScaffold, + ); + + final brightness = ValueNotifier(Brightness.light); + timeline.setupWithWidget( + ValueListenableBuilder( + valueListenable: brightness, + builder: (context, value, child) { + return ColoredBox( + color: switch (value) { + Brightness.light => Colors.white, + Brightness.dark => Colors.grey.shade900, + }, + child: SuperMessage( + editor: editor, + styles: switch (value) { + Brightness.light => lightStyles, + Brightness.dark => darkStyles, + }, + ), + ); + }, + ), + ); + + await timeline + .modifyScene((tester, testContext) async { + await tester.doubleTapInParagraph("1", 24); + }) + .takePhoto("Light") + // Switch to dark. + .modifyScene((tester, testContext) async { + brightness.value = Brightness.dark; + await tester.pump(); + }) + .takePhoto("Dark") + .run(tester); + }); + }); +} + +Widget _chatItemScaffold(tester, content) { + return GoldenSceneBounds( + child: MaterialApp( + theme: ThemeData( + fontFamily: goldenBricks, + ), + home: Scaffold( + body: Center( + child: GoldenImageBounds( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), + child: content, + ), + ), + ), + ), + ), + ); +} diff --git a/super_editor/test_goldens/super_reader/goldens/super-reader_selection-color_custom.png b/super_editor/test_goldens/super_reader/goldens/super-reader_selection-color_custom.png new file mode 100644 index 0000000000..2033b0c579 Binary files /dev/null and b/super_editor/test_goldens/super_reader/goldens/super-reader_selection-color_custom.png differ diff --git a/super_editor/test_goldens/super_reader/goldens/super-reader_selection-color_default.png b/super_editor/test_goldens/super_reader/goldens/super-reader_selection-color_default.png new file mode 100644 index 0000000000..1d8ca2b02f Binary files /dev/null and b/super_editor/test_goldens/super_reader/goldens/super-reader_selection-color_default.png differ diff --git a/super_editor/test_goldens/super_reader/super_reader_selection_test.dart b/super_editor/test_goldens/super_reader/super_reader_selection_test.dart new file mode 100644 index 0000000000..fa7773d6da --- /dev/null +++ b/super_editor/test_goldens/super_reader/super_reader_selection_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_reader_test.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group("SuperReader selection >", () { + group("color >", () { + testGoldensOnMac("default selection color", (tester) async { + await tester // + .createDocument() + .withSingleParagraphShort() + .pump(); + + // Select the whole paragraph so that the selection color is clearly visible. + await tester.tripleTapInParagraph("1", 0); + + await screenMatchesGolden(tester, "super-reader_selection-color_default"); + }, windowSize: goldenSizeLarge); + + testGoldensOnMac("custom selection color", (tester) async { + await tester // + .createDocument() + .withSingleParagraphShort() + .withSelectionStyles(const SelectionStyles(selectionColor: Colors.deepPurple)) + .useStylesheet(defaultStylesheet.copyWith( + selectedTextColorStrategy: ({ + required Color originalTextColor, + required Color selectionHighlightColor, + }) => + Colors.white, + )) + .pump(); + + // Select the whole paragraph so that the selection color is clearly visible. + await tester.tripleTapInParagraph("1", 0); + + await screenMatchesGolden(tester, "super-reader_selection-color_custom"); + }, windowSize: goldenSizeLarge); + }); + }); +} diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_android_1.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_android_1.png new file mode 100644 index 0000000000..7b157df518 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_android_1.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_android_2.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_android_2.png new file mode 100644 index 0000000000..4a66ae2aea Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_android_2.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_iOS_1.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_iOS_1.png new file mode 100644 index 0000000000..7b157df518 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_iOS_1.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_iOS_2.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_iOS_2.png new file mode 100644 index 0000000000..4a66ae2aea Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_iOS_2.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_macOS_1.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_macOS_1.png new file mode 100644 index 0000000000..8b33d3cc8d Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_macOS_1.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_macOS_2.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_macOS_2.png new file mode 100644 index 0000000000..48985d3c11 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-shows-underline_macOS_2.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-underline-shows-nothing_linux.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-underline-shows-nothing_linux.png new file mode 100644 index 0000000000..48985d3c11 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-underline-shows-nothing_linux.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-underline-shows-nothing_windows.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-underline-shows-nothing_windows.png new file mode 100644 index 0000000000..48985d3c11 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_composing-region-underline-shows-nothing_windows.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_downstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_downstream.png new file mode 100644 index 0000000000..44cc35cd00 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_downstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_upstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_upstream.png new file mode 100644 index 0000000000..218ed68a3c Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_caret_upstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_downstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_downstream.png new file mode 100644 index 0000000000..509ff0bbfd Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_downstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_over.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_over.png new file mode 100644 index 0000000000..b0d3cf4f5c Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_over.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_single.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_single.png new file mode 100644 index 0000000000..e1493f67d4 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_single.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_upstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_upstream.png new file mode 100644 index 0000000000..966f7e82a9 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_multi_line_selection_box_upstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_downstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_downstream.png new file mode 100644 index 0000000000..89b6690972 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_downstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_upstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_upstream.png new file mode 100644 index 0000000000..b7694d7881 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_caret_upstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_downstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_downstream.png new file mode 100644 index 0000000000..7af715cadf Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_downstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_over.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_over.png new file mode 100644 index 0000000000..08380e0f23 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_over.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_single.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_single.png new file mode 100644 index 0000000000..337825a5d3 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_single.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_upstream.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_upstream.png new file mode 100644 index 0000000000..606e546605 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_inline_widgets_single_line_selection_box_upstream.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-android.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-android.png new file mode 100644 index 0000000000..ece7a4aa64 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-android.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-iOS.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-iOS.png new file mode 100644 index 0000000000..767c5c9a36 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-iOS.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-linux.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-linux.png new file mode 100644 index 0000000000..59f46310cc Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-linux.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-macOS.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-macOS.png new file mode 100644 index 0000000000..59f46310cc Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-macOS.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-windows.png b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-windows.png new file mode 100644 index 0000000000..59f46310cc Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super-text-field_rtl-caret-at-leftmost-character-windows.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_multiline_android.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_multiline_android.png new file mode 100644 index 0000000000..75d2f5a253 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_multiline_android.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_multiline_desktop.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_multiline_desktop.png new file mode 100644 index 0000000000..75d2f5a253 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_multiline_desktop.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_multiline_ios.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_multiline_ios.png new file mode 100644 index 0000000000..75d2f5a253 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_multiline_ios.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_singleline_android.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_singleline_android.png new file mode 100644 index 0000000000..e9700babfd Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_singleline_android.png differ diff --git a/super_editor/test/super_textfield/goldens/super_textfield_alignments_singleline_desktop.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_singleline_desktop.png similarity index 69% rename from super_editor/test/super_textfield/goldens/super_textfield_alignments_singleline_desktop.png rename to super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_singleline_desktop.png index 1eec8b2213..e9700babfd 100644 Binary files a/super_editor/test/super_textfield/goldens/super_textfield_alignments_singleline_desktop.png and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_singleline_desktop.png differ diff --git a/super_editor/test/super_textfield/goldens/super_textfield_alignments_singleline_ios.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_singleline_ios.png similarity index 69% rename from super_editor/test/super_textfield/goldens/super_textfield_alignments_singleline_ios.png rename to super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_singleline_ios.png index 1eec8b2213..e9700babfd 100644 Binary files a/super_editor/test/super_textfield/goldens/super_textfield_alignments_singleline_ios.png and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_alignments_singleline_ios.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_android_magnifier_screen_edges.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_android_magnifier_screen_edges.png new file mode 100644 index 0000000000..f97625f138 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_android_magnifier_screen_edges.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_empty_hint_padding.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_empty_hint_padding.png new file mode 100644 index 0000000000..aeb94e9cbc Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_empty_hint_padding.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_font_height.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_font_height.png new file mode 100644 index 0000000000..be1ce97dc4 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_font_height.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_magnifier_screen_edges.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_magnifier_screen_edges.png new file mode 100644 index 0000000000..8ae6ab59c3 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_magnifier_screen_edges.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_down_collapsed.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_down_collapsed.png new file mode 100644 index 0000000000..5ad6a396b3 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_down_collapsed.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_down_expanded.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_down_expanded.png new file mode 100644 index 0000000000..979805b538 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_down_expanded.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_up_collapsed.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_up_collapsed.png new file mode 100644 index 0000000000..acab89a569 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_up_collapsed.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_up_expanded.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_up_expanded.png new file mode 100644 index 0000000000..3736948c34 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_ios_toolbar_pointing_up_expanded.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_scrolled_down.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_scrolled_down.png new file mode 100644 index 0000000000..999a2ed3b5 Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_scrolled_down.png differ diff --git a/super_editor/test_goldens/super_textfield/goldens/super_textfield_scrolled_up.png b/super_editor/test_goldens/super_textfield/goldens/super_textfield_scrolled_up.png new file mode 100644 index 0000000000..3c836170db Binary files /dev/null and b/super_editor/test_goldens/super_textfield/goldens/super_textfield_scrolled_up.png differ diff --git a/super_editor/test_goldens/super_textfield/super_textfield_android_overlay_controls_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_android_overlay_controls_test.dart new file mode 100644 index 0000000000..581dba4ce5 --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_android_overlay_controls_test.dart @@ -0,0 +1,60 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_test.dart'; +import 'package:super_editor/super_text_field.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group("SuperTextField > Android > overlay controls >", () { + testGoldensOnAndroid("confines magnifier within screen bounds", (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(400.0, 500.0); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + final controller = AttributedTextEditingController( + text: AttributedText('Lorem ipsum dolor sit amet'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: double.infinity, + child: SuperTextField( + textController: controller, + padding: const EdgeInsets.all(20), + textStyleBuilder: (_) => const TextStyle( + color: Colors.black, + // Use Roboto so that goldens show real text + fontFamily: 'Roboto', + ), + ), + ), + ), + ), + debugShowCheckedModeBanner: false, + ), + ); + + // Place the caret at the end of the textfield. + await tester.placeCaretInSuperTextField(30); + + // Press and drag the caret to the beginning of the line. + final gesture = await tester.dragCaretByDistanceInSuperTextField(const Offset(-200, 0)); + await tester.pump(); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/super_textfield_android_magnifier_screen_edges.png", 38), + ); + + // Release the gesture. + await gesture.up(); + }); + }); +} diff --git a/super_editor/test_goldens/super_textfield/super_textfield_composing_region_underline_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_composing_region_underline_test.dart new file mode 100644 index 0000000000..5f97ee200d --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_composing_region_underline_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group("SuperTextField > composing region >", () { + testGoldensOnAndroid("is underlined", _composingRegionIsUnderlined, windowSize: goldenSizeSmall); + testGoldensOniOS("is underlined", _composingRegionIsUnderlined, windowSize: goldenSizeSmall); + testGoldensOnMac("is underlined", _composingRegionIsUnderlined, windowSize: goldenSizeSmall); + }); + + group("SuperTextField > composing region >", () { + testGoldensOnWindows("shows nothing", _composingRegionShowsNothing, windowSize: goldenSizeSmall); + testGoldensOnLinux("shows nothing", _composingRegionShowsNothing, windowSize: goldenSizeSmall); + }); +} + +Future _composingRegionIsUnderlined(WidgetTester tester) async { + final textController = AttributedTextEditingController( + text: AttributedText("Typing with composing a"), + ); + await _pumpScaffold(tester, textController); + + textController + ..selection = const TextSelection.collapsed(offset: 23) + ..composingRegion = const TextRange(start: 22, end: 23); + await tester.pumpAndSettle(); + + // Ensure the composing region is underlined. + // TODO: bring back the following golden matcher when we figure out why these tests are failing in CI but not in Docker. + // await screenMatchesGolden( + // tester, "super-text-field_composing-region-shows-underline_${defaultTargetPlatform.name}_1"); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance( + "goldens/super-text-field_composing-region-shows-underline_${defaultTargetPlatform.name}_1.png", 1)); + + textController.composingRegion = const TextRange.collapsed(-1); + await tester.pump(); + + // Ensure the underline disappeared now that the composing region is null. + await screenMatchesGolden( + tester, "super-text-field_composing-region-shows-underline_${defaultTargetPlatform.name}_2"); +} + +Future _composingRegionShowsNothing(WidgetTester tester) async { + final textController = AttributedTextEditingController( + text: AttributedText("Typing with composing a"), + ); + await _pumpScaffold(tester, textController); + + textController + ..selection = const TextSelection.collapsed(offset: 23) + ..composingRegion = const TextRange(start: 22, end: 23); + await tester.pumpAndSettle(); + + // Ensure that no underline is shown. + await screenMatchesGolden( + tester, "super-text-field_composing-region-underline-shows-nothing_${defaultTargetPlatform.name}"); +} + +Future _pumpScaffold( + WidgetTester tester, + AttributedTextEditingController textController, +) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: IntrinsicWidth( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: SuperTextField( + textController: textController, + textStyleBuilder: (_) => const TextStyle( + color: Colors.black, + // Use Roboto so that goldens show real text + fontFamily: 'Roboto', + ), + ), + ), + ), + ), + ), + debugShowCheckedModeBanner: false, + ), + ); +} diff --git a/super_editor/test_goldens/super_textfield/super_textfield_empty_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_empty_test.dart new file mode 100644 index 0000000000..a66926435b --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_empty_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_test.dart'; +import 'package:super_editor/super_text_field.dart'; + +void main() { + group("SuperTextField > empty >", () { + // This desktop test is run on Android because it seems that the golden toolkit + // only renders real fonts on Android. To account for this, we explicitly configure + // the SuperTextField to present as a desktop textfield. + testGoldensOnAndroid("displays hint text with padding", (tester) async { + // Use a Row as a wrapper to fill the available width. + final builder = GoldenBuilder.grid( + columns: 2, + widthToHeightRatio: 3, + ) + ..addScenario( + 'Desktop - No Padding', + _buildEmptySingleLineTextField( + padding: EdgeInsets.zero, + platform: SuperTextFieldPlatformConfiguration.desktop, + ), + ) + ..addScenario( + 'Desktop - Small Padding', + _buildEmptySingleLineTextField( + padding: const EdgeInsets.all(10.0), + platform: SuperTextFieldPlatformConfiguration.desktop, + ), + ) + ..addScenario( + 'Desktop - Large Padding', + _buildEmptySingleLineTextField( + padding: const EdgeInsets.all(25.0), + platform: SuperTextFieldPlatformConfiguration.desktop, + ), + ) + ..addScenario( + 'iOS - No Padding', + _buildEmptySingleLineTextField( + padding: EdgeInsets.zero, + platform: SuperTextFieldPlatformConfiguration.iOS, + ), + ) + ..addScenario( + 'iOS - Small Padding', + _buildEmptySingleLineTextField( + padding: const EdgeInsets.all(10.0), + platform: SuperTextFieldPlatformConfiguration.iOS, + ), + ) + ..addScenario( + 'iOS - Large Padding', + _buildEmptySingleLineTextField( + padding: const EdgeInsets.all(25.0), + platform: SuperTextFieldPlatformConfiguration.iOS, + ), + ) + ..addScenario( + 'Android - No Padding', + _buildEmptySingleLineTextField( + padding: EdgeInsets.zero, + platform: SuperTextFieldPlatformConfiguration.android, + ), + ) + ..addScenario( + 'Android - Small Padding', + _buildEmptySingleLineTextField( + padding: const EdgeInsets.all(10.0), + platform: SuperTextFieldPlatformConfiguration.android, + ), + ) + ..addScenario( + 'Android - Large Padding', + _buildEmptySingleLineTextField( + padding: const EdgeInsets.all(25.0), + platform: SuperTextFieldPlatformConfiguration.android, + ), + ); + + await tester.pumpWidgetBuilder(builder.build()); + + await screenMatchesGolden(tester, 'super_textfield_empty_hint_padding'); + }); + }); +} + +Widget _buildEmptySingleLineTextField({ + EdgeInsets? padding, + required SuperTextFieldPlatformConfiguration platform, +}) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 250, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.yellow, + border: Border.all(), + ), + child: SuperTextField( + textController: AttributedTextEditingController(), + textStyleBuilder: (_) => const TextStyle(fontSize: 20, color: Colors.black, fontFamily: 'Roboto'), + hintBuilder: (_) => Text( + "Hint text...", + style: defaultHintStyleBuilder({}), + ), + maxLines: 1, + padding: padding, + lineHeight: 20, + configuration: platform, + ), + ), + ); +} diff --git a/super_editor/test_goldens/super_textfield/super_textfield_font_height_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_font_height_test.dart new file mode 100644 index 0000000000..2a683959c2 --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_font_height_test.dart @@ -0,0 +1,57 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/src/super_textfield/desktop/desktop_textfield.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/attributed_text_editing_controller.dart'; +import 'package:super_editor/super_test.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group('SuperTextField > single line > with custom font height', () { + testGoldensOnMac('vertically centers text in viewport', (tester) async { + final textFieldController = AttributedTextEditingController( + text: AttributedText('Text with custom font height'), + ); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: SuperDesktopTextField( + textController: textFieldController, + decorationBuilder: (context, child) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.blue, + width: 1, + ), + ), + child: child, + ); + }, + textStyleBuilder: (attributions) => const TextStyle( + color: Colors.black, + fontSize: 14, + leadingDistribution: TextLeadingDistribution.even, + height: 6.0, + ), + minLines: 1, + maxLines: 1, + ), + ), + ), + ), + ), + ); + + await tester.placeCaretInSuperTextField(0, find.byType(SuperDesktopTextField)); + await screenMatchesGolden(tester, 'super_textfield_font_height'); + }); + }); +} diff --git a/super_editor/test_goldens/super_textfield/super_textfield_inline_widgets_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_inline_widgets_test.dart new file mode 100644 index 0000000000..11594f04db --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_inline_widgets_test.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_test.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +Future main() async { + await loadAppFonts(); + + group('SuperTextField > inline widgets >', () { + group('single line >', () { + testGoldensOnMac( + 'displays caret at upstream side of inline widget', + (tester) async { + await _pumpSingleLineTestApp(tester); + + // Place the caret at the upstream side of the inline widget. + await tester.placeCaretInSuperTextField(7); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_caret_upstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays caret at downstream side of inline widget', + (tester) async { + await _pumpSingleLineTestApp(tester); + + // Place the caret at the downstream side of the inline widget. + await tester.placeCaretInSuperTextField(8); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_caret_downstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box when selecting inline widget', + (tester) async { + await _pumpSingleLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 7, extentOffset: 8), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_selection_box_single'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box upstream near inline widget', + (tester) async { + await _pumpSingleLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 0, extentOffset: 7), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_selection_box_upstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box downstream near inline widget', + (tester) async { + await _pumpSingleLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 8, extentOffset: 14), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_selection_box_downstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box when selecting over inline widget', + (tester) async { + await _pumpSingleLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 0, extentOffset: 14), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_single_line_selection_box_over'); + }, + windowSize: goldenSizeSmall, + ); + }); + + group('multi line >', () { + testGoldensOnMac( + 'displays caret at upstream side of inline widget', + (tester) async { + await _pumpMultiLineTestApp(tester); + + // Place the caret at the upstream side of the inline widget. + await tester.placeCaretInSuperTextField(27); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_caret_upstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays caret at downstream side of inline widget', + (tester) async { + await _pumpMultiLineTestApp(tester); + + // Place the caret at the downstream side of the inline widget. + await tester.placeCaretInSuperTextField(28); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_caret_downstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box when selecting inline widget', + (tester) async { + await _pumpMultiLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 27, extentOffset: 28), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_selection_box_single'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box upstream near inline widget', + (tester) async { + await _pumpMultiLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 0, extentOffset: 27), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_selection_box_upstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box downstream near inline widget', + (tester) async { + await _pumpMultiLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 28, extentOffset: 53), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_selection_box_downstream'); + }, + windowSize: goldenSizeSmall, + ); + + testGoldensOnMac( + 'displays selection box when selecting over inline widget', + (tester) async { + await _pumpMultiLineTestApp( + tester, + initialSelection: const TextSelection(baseOffset: 0, extentOffset: 53), + ); + + await screenMatchesGolden(tester, 'super-text-field_inline_widgets_multi_line_selection_box_over'); + }, + windowSize: goldenSizeSmall, + ); + }); + }); +} + +/// Pump a test app with a [SuperTextField] that renders a [ColoredBox] for each +/// [_NamedPlaceHolder] in the text, with an inline widget at offset 7. +Future _pumpSingleLineTestApp( + WidgetTester tester, { + TextSelection? initialSelection, +}) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'before after', + null, + { + 7: const _NamedPlaceHolder('1'), + }, + ), + selection: initialSelection, + ); + await _pumpTestApp(tester, controller: controller); +} + +/// Pump a test app with a [SuperTextField] that renders a [ColoredBox] for each +/// [_NamedPlaceHolder] in the text, with an inline widget at offset 27. +Future _pumpMultiLineTestApp( + WidgetTester tester, { + TextSelection? initialSelection, +}) async { + final controller = AttributedTextEditingController( + text: AttributedText( + 'first line of text \nbefore after\nthird line of text', + null, + { + 27: const _NamedPlaceHolder('1'), + }, + ), + selection: initialSelection, + ); + await _pumpTestApp(tester, controller: controller); +} + +/// Pump a test app with a [SuperTextField] that renders a [ColoredBox] for each +/// [_NamedPlaceHolder] in the text. +Future _pumpTestApp( + WidgetTester tester, { + required AttributedTextEditingController controller, +}) async { + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(4.0), + child: SizedBox( + height: 300, + child: SuperTextField( + textController: controller, + textStyleBuilder: (attributions) => const TextStyle( + // Use Roboto so that goldens show real text. + fontFamily: 'Roboto', + fontSize: 18, + color: Colors.black, + ), + inlineWidgetBuilders: const [ + _boxPlaceHolderBuilder, + ], + ), + ), + ), + ), + ), + ), + ); +} + +/// A builder that renders a [ColoredBox] for a [_NamedPlaceHolder]. +Widget? _boxPlaceHolderBuilder(BuildContext context, TextStyle textStyle, Object placeholder) { + if (placeholder is! _NamedPlaceHolder) { + return null; + } + + return KeyedSubtree( + key: ValueKey('placeholder-${placeholder.name}'), + child: LineHeight( + style: textStyle, + child: const SizedBox( + width: 24, + child: ColoredBox( + color: Colors.yellow, + ), + ), + ), + ); +} + +// A placeholder that is identified by a name. +class _NamedPlaceHolder { + const _NamedPlaceHolder(this.name); + + final String name; + + @override + bool operator ==(Object other) => + identical(this, other) || other is _NamedPlaceHolder && runtimeType == other.runtimeType && name == other.name; + + @override + int get hashCode => name.hashCode; +} diff --git a/super_editor/test_goldens/super_textfield/super_textfield_ios_overlay_controls_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_ios_overlay_controls_test.dart new file mode 100644 index 0000000000..36374f3254 --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_ios_overlay_controls_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_test.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group("SuperTextField > iOS > overlay controls >", () { + testGoldensOniOS("confines magnifier within screen bounds", (tester) async { + tester.view + ..devicePixelRatio = 1.0 + ..platformDispatcher.textScaleFactorTestValue = 1.0 + ..physicalSize = const Size(400.0, 500.0); + + addTearDown(() => tester.platformDispatcher.clearAllTestValues()); + + final controller = AttributedTextEditingController( + text: AttributedText('Lorem ipsum dolor sit amet'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: double.infinity, + child: SuperTextField( + textController: controller, + padding: const EdgeInsets.all(20), + textStyleBuilder: (_) => const TextStyle( + color: Colors.black, + // Use Roboto so that goldens show real text + fontFamily: 'Roboto', + ), + ), + ), + ), + ), + debugShowCheckedModeBanner: false, + ), + ); + + // Place the caret at the end of the textfield. + await tester.placeCaretInSuperTextField(30); + + // Press and drag the caret to the beginning of the line. + final gesture = await tester.dragCaretByDistanceInSuperTextField(const Offset(-200, 0)); + await tester.pump(); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/super_textfield_ios_magnifier_screen_edges.png", 4), + ); + + // Release the gesture. + await gesture.up(); + }); + }); +} diff --git a/super_editor/test_goldens/super_textfield/super_textfield_rtl_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_rtl_test.dart new file mode 100644 index 0000000000..caa67c2fd4 --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_rtl_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_reader_test.dart'; +import 'package:super_editor/super_test.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group('SuperTextfield > RTL mode >', () { + testGoldensOnAllPlatforms( + 'inserts text and paints caret on the left side for downstream position', + (tester) async { + await _pumpTestApp(tester); + + // Place the caret at the beginning of the text field. + await tester.placeCaretInSuperTextField(0); + + // Type the text "Example". + await tester.ime.typeText( + 'مثال', + getter: imeClientGetter, + ); + await tester.pumpAndSettle(); + + await screenMatchesGolden( + tester, 'super-text-field_rtl-caret-at-leftmost-character-${defaultTargetPlatform.name}'); + }, + windowSize: const Size(600, 600), + ); + }); +} + +/// Pump a widget tree with a centered multiline textfield with +/// a yellow background, so we can clearly see the bounds of the textfield. +Future _pumpTestApp(WidgetTester tester) async { + final controller = ImeAttributedTextEditingController(); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: ColoredBox( + color: Colors.yellow, + child: SuperTextField( + textController: controller, + maxLines: 10, + lineHeight: 16, + ), + ), + ), + ), + ), + ), + ); +} diff --git a/super_editor/test_goldens/super_textfield/super_textfield_scroll_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_scroll_test.dart new file mode 100644 index 0000000000..012ec87e1a --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_scroll_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + group('SuperTextField', () { + testGoldensOnAndroid("multi-line accounts for padding when jumping scroll position down", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("First line\nSecond Line\nThird Line\nFourth Line"), + ); + + const description = + 'A SuperTextField scrolled to the end should have the last line fully visible with space below it'; + + // Use a Row as a wrapper to fill the available width. + final builder = GoldenBuilder.column( + wrap: (child) => Row( + children: [child], + ), + ) + ..addScenario( + '$description (on Android)', + _buildTextField( + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 50, + maxWidth: 200, + padding: const EdgeInsets.all(10.0), + configuration: SuperTextFieldPlatformConfiguration.android, + ), + ) + ..addScenario( + '$description (on iOS)', + _buildTextField( + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 50, + maxWidth: 200, + padding: const EdgeInsets.all(10.0), + configuration: SuperTextFieldPlatformConfiguration.iOS, + ), + ) + ..addScenario( + '$description (on Desktop)', + _buildTextField( + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 50, + maxWidth: 200, + padding: const EdgeInsets.all(10.0), + configuration: SuperTextFieldPlatformConfiguration.desktop, + ), + ); + await tester.pumpWidgetBuilder(builder.build()); + + // Move selection to the end of the text. + // This will scroll the text field to the end. + controller.selection = const TextSelection.collapsed(offset: 45); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'super_textfield_scrolled_down'); + }); + + testGoldensOnAndroid("multi-line accounts for padding when jumping scroll position up", (tester) async { + final controller = AttributedTextEditingController( + text: AttributedText("First line\nSecond Line\nThird Line\nFourth Line"), + ); + + const description = + 'A SuperTextField scrolled to the beginning should have the first line fully visible with space above it'; + + // Use a Row as a wrapper to fill the available width. + final builder = GoldenBuilder.column( + wrap: (child) => Row( + children: [child], + ), + ) + ..addScenario( + '$description (on Android)', + _buildTextField( + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 50, + maxWidth: 200, + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 24), + configuration: SuperTextFieldPlatformConfiguration.android, + ), + ) + ..addScenario( + '$description (on iOS)', + _buildTextField( + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 50, + maxWidth: 200, + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 24), + configuration: SuperTextFieldPlatformConfiguration.iOS, + ), + ) + ..addScenario( + '$description (on Desktop)', + _buildTextField( + textController: controller, + minLines: 1, + maxLines: 2, + maxHeight: 50, + maxWidth: 200, + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 24), + configuration: SuperTextFieldPlatformConfiguration.desktop, + ), + ); + await tester.pumpWidgetBuilder(builder.build()); + + // Move selection to the end of the text. + // This will scroll the text field to the end. + controller.selection = const TextSelection.collapsed(offset: 45); + await tester.pumpAndSettle(); + + // Place the caret at the beginning of the text. + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'super_textfield_scrolled_up'); + }); + }); +} + +Widget _buildTextField({ + required AttributedTextEditingController textController, + required int minLines, + required int maxLines, + double? maxWidth, + double? maxHeight, + EdgeInsets? padding, + SuperTextFieldPlatformConfiguration? configuration, +}) { + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth ?? double.infinity, + maxHeight: maxHeight ?? double.infinity, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.yellow, + border: Border.all(), + ), + child: SuperTextField( + textController: textController, + lineHeight: 20, + textStyleBuilder: (_) => const TextStyle(fontSize: 20, color: Colors.black, fontFamily: 'Roboto'), + minLines: minLines, + maxLines: maxLines, + padding: padding, + configuration: configuration, + ), + ), + ); +} diff --git a/super_editor/test_goldens/super_textfield/super_textfield_text_alignment_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_text_alignment_test.dart new file mode 100644 index 0000000000..7c477a8558 --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_text_alignment_test.dart @@ -0,0 +1,254 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_test.dart'; + +void main() { + // These golden tests are being skipped on macOS because the text seems to be + // a bit bigger in this platform, causing the tests to fail. + group('SuperTextField', () { + group('single line', () { + group('displays different alignments', () { + testGoldensOnAndroid('(on Android)', (tester) async { + await _pumpScaffold( + tester, + children: [ + _buildSuperTextField( + text: "Left", + textAlign: TextAlign.left, + maxLines: 1, + configuration: SuperTextFieldPlatformConfiguration.android, + ), + _buildSuperTextField( + text: "Center", + textAlign: TextAlign.center, + maxLines: 1, + configuration: SuperTextFieldPlatformConfiguration.android, + ), + _buildSuperTextField( + text: "Right", + textAlign: TextAlign.right, + maxLines: 1, + configuration: SuperTextFieldPlatformConfiguration.android, + ), + ], + ); + + await screenMatchesGolden(tester, 'super_textfield_alignments_singleline_android'); + }, skip: Platform.isMacOS); + + testGoldensOnAndroid('(on iOS)', (tester) async { + await _pumpScaffold( + tester, + children: [ + _buildSuperTextField( + text: "Left", + textAlign: TextAlign.left, + maxLines: 1, + configuration: SuperTextFieldPlatformConfiguration.iOS, + ), + _buildSuperTextField( + text: "Center", + textAlign: TextAlign.center, + maxLines: 1, + configuration: SuperTextFieldPlatformConfiguration.iOS, + ), + _buildSuperTextField( + text: "Right", + textAlign: TextAlign.right, + maxLines: 1, + configuration: SuperTextFieldPlatformConfiguration.iOS, + ), + ], + ); + + await screenMatchesGolden(tester, 'super_textfield_alignments_singleline_ios'); + }, skip: Platform.isMacOS); + + testGoldensOnAndroid('(on Desktop)', (tester) async { + await _pumpScaffold( + tester, + children: [ + _buildSuperTextField( + text: "Left", + textAlign: TextAlign.left, + maxLines: 1, + configuration: SuperTextFieldPlatformConfiguration.desktop, + ), + _buildSuperTextField( + text: "Center", + textAlign: TextAlign.center, + maxLines: 1, + configuration: SuperTextFieldPlatformConfiguration.desktop, + ), + _buildSuperTextField( + text: "Right", + textAlign: TextAlign.right, + maxLines: 1, + configuration: SuperTextFieldPlatformConfiguration.desktop, + ), + ], + ); + + await screenMatchesGolden(tester, 'super_textfield_alignments_singleline_desktop'); + }, skip: Platform.isMacOS); + }); + }); + + group('multi line', () { + const multilineText = 'First Line\nSecond Line\nThird Line\nFourth Line'; + group('displays different alignments', () { + testGoldensOnAndroid('(on Android)', (tester) async { + await _pumpScaffold( + tester, + children: [ + _buildSuperTextField( + text: multilineText, + textAlign: TextAlign.left, + maxLines: 4, + configuration: SuperTextFieldPlatformConfiguration.android, + ), + _buildSuperTextField( + text: multilineText, + textAlign: TextAlign.center, + maxLines: 4, + configuration: SuperTextFieldPlatformConfiguration.android, + ), + _buildSuperTextField( + text: multilineText, + textAlign: TextAlign.right, + maxLines: 4, + configuration: SuperTextFieldPlatformConfiguration.android, + ), + ], + ); + + await screenMatchesGolden(tester, 'super_textfield_alignments_multiline_android'); + }, skip: Platform.isMacOS); + + testGoldensOnAndroid('(on iOS)', (tester) async { + await _pumpScaffold( + tester, + children: [ + _buildSuperTextField( + text: multilineText, + textAlign: TextAlign.left, + maxLines: 4, + configuration: SuperTextFieldPlatformConfiguration.iOS, + ), + _buildSuperTextField( + text: multilineText, + textAlign: TextAlign.center, + maxLines: 4, + configuration: SuperTextFieldPlatformConfiguration.iOS, + ), + _buildSuperTextField( + text: multilineText, + textAlign: TextAlign.right, + maxLines: 4, + configuration: SuperTextFieldPlatformConfiguration.iOS, + ), + ], + ); + + await screenMatchesGolden(tester, 'super_textfield_alignments_multiline_ios'); + }, skip: Platform.isMacOS); + + testGoldensOnAndroid('(on Desktop)', (tester) async { + await _pumpScaffold( + tester, + children: [ + _buildSuperTextField( + text: multilineText, + textAlign: TextAlign.left, + maxLines: 4, + configuration: SuperTextFieldPlatformConfiguration.desktop, + ), + _buildSuperTextField( + text: multilineText, + textAlign: TextAlign.center, + maxLines: 4, + configuration: SuperTextFieldPlatformConfiguration.desktop, + ), + _buildSuperTextField( + text: multilineText, + textAlign: TextAlign.right, + maxLines: 4, + configuration: SuperTextFieldPlatformConfiguration.desktop, + ), + ], + ); + + await screenMatchesGolden(tester, 'super_textfield_alignments_multiline_desktop'); + }); + }, skip: Platform.isMacOS); + + testWidgetsOnAllPlatforms('makes scrollview fill all the field width', (tester) async { + await _pumpScaffold( + tester, + children: [ + _buildSuperTextField( + text: multilineText, + textAlign: TextAlign.center, + maxLines: 4, + ), + ], + ); + await tester.pump(); + + final textfieldWidth = tester.getSize(find.byType(SuperTextField)).width; + final scrollViewWidth = tester.getSize(find.byType(SingleChildScrollView)).width; + + // Ensure the scrollview occupies all the available width rathen than + // just width of the text. + expect(scrollViewWidth, equals(textfieldWidth)); + }); + }); + }); +} + +Widget _buildSuperTextField({ + required String text, + required TextAlign textAlign, + SuperTextFieldPlatformConfiguration? configuration, + int? maxLines, +}) { + final controller = AttributedTextEditingController( + text: AttributedText(text), + ); + + return SizedBox( + width: double.infinity, + child: SuperTextField( + configuration: configuration, + textController: controller, + textAlign: textAlign, + maxLines: maxLines, + minLines: 1, + lineHeight: 20, + textStyleBuilder: (_) { + return const TextStyle( + color: Colors.black, + fontSize: 20, + ); + }, + ), + ); +} + +Future _pumpScaffold( + WidgetTester tester, { + required List children, +}) async { + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Column(children: children), + ), + ), + ); +} diff --git a/super_editor/test_goldens/super_textfield/super_textfield_toolbar_test.dart b/super_editor/test_goldens/super_textfield/super_textfield_toolbar_test.dart new file mode 100644 index 0000000000..8015a06de9 --- /dev/null +++ b/super_editor/test_goldens/super_textfield/super_textfield_toolbar_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_test.dart'; +import 'package:super_editor/super_text_field_test.dart'; + +void main() { + group('SuperTextField', () { + testGoldensOnAndroid('displays toolbar pointing down for expanded selection', (tester) async { + // Pumps a widget tree with a SuperTextField at the bottom of the screen. + await _pumpSuperTextfieldToolbarTestApp( + tester, + child: Positioned( + bottom: 50, + child: _buildSuperTextField( + text: 'Arrow pointing down', + configuration: SuperTextFieldPlatformConfiguration.iOS, + ), + ), + ); + + // Select a word so that the popover toolbar appears. + await tester.doubleTapAtSuperTextField(6); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/super_textfield_ios_toolbar_pointing_down_expanded.png", 2), + ); + }); + + testGoldensOniOS('displays toolbar pointing down for collapsed selection', (tester) async { + // Pumps a widget tree with a SuperTextField at the bottom of the screen. + await _pumpSuperTextfieldToolbarTestApp( + tester, + child: Positioned( + bottom: 50, + child: _buildSuperTextField( + text: 'Arrow pointing down', + ), + ), + ); + + // Place the caret at "|pointing". + await tester.placeCaretInSuperTextField(6); + + // Wait to avoid a double tap. + await tester.pump(kDoubleTapTimeout); + + // Tap again to show the toolbar. + await tester.placeCaretInSuperTextField(6); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/super_textfield_ios_toolbar_pointing_down_collapsed.png", 1), + ); + }); + + testGoldensOnAndroid('displays toolbar pointing up for expanded selection', (tester) async { + // Pumps a widget tree with a SuperTextField at the top of the screen. + await _pumpSuperTextfieldToolbarTestApp( + tester, + child: _buildSuperTextField( + text: 'Arrow pointing up', + configuration: SuperTextFieldPlatformConfiguration.iOS, + ), + ); + + // Select a word so that the popover toolbar appears. + await tester.doubleTapAtSuperTextField(6); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/super_textfield_ios_toolbar_pointing_up_expanded.png", 3), + ); + }); + + testGoldensOniOS('displays toolbar pointing up for collapsed selection', (tester) async { + // Pumps a widget tree with a SuperTextField at the top of the screen. + await _pumpSuperTextfieldToolbarTestApp( + tester, + child: _buildSuperTextField( + text: 'Arrow pointing up', + ), + ); + + // Place the caret at "|pointing". + await tester.placeCaretInSuperTextField(6); + + // Wait to avoid a double tap. + await tester.pump(kDoubleTapTimeout); + + // Tap again to show the toolbar. + await tester.placeCaretInSuperTextField(6); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFileWithPixelAllowance("goldens/super_textfield_ios_toolbar_pointing_up_collapsed.png", 3), + ); + }); + }); +} + +/// Pumps a widget tree which displays the [child] inside a [Stack]. +Future _pumpSuperTextfieldToolbarTestApp( + WidgetTester tester, { + required Widget child, +}) async { + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Stack( + children: [child], + ), + ), + ), + ); +} + +Widget _buildSuperTextField({ + required String text, + SuperTextFieldPlatformConfiguration? configuration, +}) { + final controller = AttributedTextEditingController( + text: AttributedText(text), + ); + + return Container( + width: 300, + decoration: BoxDecoration( + border: Border.all(color: Colors.green), + ), + child: SuperTextField( + configuration: configuration, + textController: controller, + maxLines: 1, + minLines: 1, + lineHeight: 20, + textStyleBuilder: (_) { + return const TextStyle( + color: Colors.black, + fontSize: 20, + ); + }, + ), + ); +} diff --git a/super_editor_clipboard/.gitignore b/super_editor_clipboard/.gitignore new file mode 100644 index 0000000000..eb6c05cd3d --- /dev/null +++ b/super_editor_clipboard/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# 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/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/super_editor_clipboard/.metadata b/super_editor_clipboard/.metadata new file mode 100644 index 0000000000..9ca518cbdc --- /dev/null +++ b/super_editor_clipboard/.metadata @@ -0,0 +1,30 @@ +# 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: "19074d12f7eaf6a8180cd4036a430c1d76de904e" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + - platform: ios + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_editor_clipboard/.run/Example.run.xml b/super_editor_clipboard/.run/Example.run.xml new file mode 100644 index 0000000000..e90f8cd96c --- /dev/null +++ b/super_editor_clipboard/.run/Example.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor_clipboard/CHANGELOG.md b/super_editor_clipboard/CHANGELOG.md new file mode 100644 index 0000000000..6ca2e4726b --- /dev/null +++ b/super_editor_clipboard/CHANGELOG.md @@ -0,0 +1,12 @@ +## [0.2.3] +### Jan 19, 2026 +* **CHANGED PACKAGE TO A PLUGIN** +* iOS: Created plugin that swizzles Flutter's paste behavior and lets your app handle native toolbar pasting. + +## [0.2.2] +### Dec 13, 2025 +* FEATURE: Rich text paste (from HTML) +* DEPENDENCY CHANGES: + * Upgraded `super_editor` to `0.3.0-dev.46` + +* TODO: Describe initial release. diff --git a/super_editor_clipboard/LICENSE b/super_editor_clipboard/LICENSE new file mode 100644 index 0000000000..4e586cd1cd --- /dev/null +++ b/super_editor_clipboard/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2025 Declarative, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/super_editor_clipboard/README.md b/super_editor_clipboard/README.md new file mode 100644 index 0000000000..171e23cc3c --- /dev/null +++ b/super_editor_clipboard/README.md @@ -0,0 +1,6 @@ +# Super Editor Clipboard +Rich text copy/paste extensions on `super_editor`. + +This behavior is delivered as its own package so that `super_editor` users +aren't forced to depend upon another package (`super_clipboard`), which auto +downloads rust binaries. \ No newline at end of file diff --git a/super_editor_clipboard/analysis_options.yaml b/super_editor_clipboard/analysis_options.yaml new file mode 100644 index 0000000000..20106f2123 --- /dev/null +++ b/super_editor_clipboard/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +linter: + rules: + always_use_package_imports: true \ No newline at end of file diff --git a/super_editor_clipboard/example/.gitignore b/super_editor_clipboard/example/.gitignore new file mode 100644 index 0000000000..3820a95c65 --- /dev/null +++ b/super_editor_clipboard/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# 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 diff --git a/super_editor_clipboard/example/.metadata b/super_editor_clipboard/example/.metadata new file mode 100644 index 0000000000..792284aafc --- /dev/null +++ b/super_editor_clipboard/example/.metadata @@ -0,0 +1,30 @@ +# 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: "19074d12f7eaf6a8180cd4036a430c1d76de904e" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + - platform: android + create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_editor_clipboard/example/README.md b/super_editor_clipboard/example/README.md new file mode 100644 index 0000000000..cc63355494 --- /dev/null +++ b/super_editor_clipboard/example/README.md @@ -0,0 +1,16 @@ +# super_editor_clipboard_example + +Demonstrates how to use the super_editor_clipboard 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://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/super_editor_clipboard/example/analysis_options.yaml b/super_editor_clipboard/example/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_editor_clipboard/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_editor_clipboard/example/android/.gitignore b/super_editor_clipboard/example/android/.gitignore new file mode 100644 index 0000000000..be3943c96d --- /dev/null +++ b/super_editor_clipboard/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/super_editor_clipboard/example/android/app/build.gradle.kts b/super_editor_clipboard/example/android/app/build.gradle.kts new file mode 100644 index 0000000000..b7f6781c23 --- /dev/null +++ b/super_editor_clipboard/example/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.flutterbountyhunters.supereditorclipboard.super_editor_clipboard_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.flutterbountyhunters.supereditorclipboard.super_editor_clipboard_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/super_editor_clipboard/example/android/app/src/debug/AndroidManifest.xml b/super_editor_clipboard/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_editor_clipboard/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_editor_clipboard/example/android/app/src/main/AndroidManifest.xml b/super_editor_clipboard/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..22a13f72d5 --- /dev/null +++ b/super_editor_clipboard/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor_clipboard/example/android/app/src/main/kotlin/com/flutterbountyhunters/supereditorclipboard/super_editor_clipboard_example/MainActivity.kt b/super_editor_clipboard/example/android/app/src/main/kotlin/com/flutterbountyhunters/supereditorclipboard/super_editor_clipboard_example/MainActivity.kt new file mode 100644 index 0000000000..e0605a1584 --- /dev/null +++ b/super_editor_clipboard/example/android/app/src/main/kotlin/com/flutterbountyhunters/supereditorclipboard/super_editor_clipboard_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.flutterbountyhunters.supereditorclipboard.super_editor_clipboard_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/super_editor_clipboard/example/android/app/src/main/res/drawable-v21/launch_background.xml b/super_editor_clipboard/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/super_editor_clipboard/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_editor_clipboard/example/android/app/src/main/res/drawable/launch_background.xml b/super_editor_clipboard/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..304732f884 --- /dev/null +++ b/super_editor_clipboard/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_editor_clipboard/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/super_editor_clipboard/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..db77bb4b7b Binary files /dev/null and b/super_editor_clipboard/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/super_editor_clipboard/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/super_editor_clipboard/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..17987b79bb Binary files /dev/null and b/super_editor_clipboard/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/super_editor_clipboard/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/super_editor_clipboard/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..09d4391482 Binary files /dev/null and b/super_editor_clipboard/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/super_editor_clipboard/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/super_editor_clipboard/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d5f1c8d34e Binary files /dev/null and b/super_editor_clipboard/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/super_editor_clipboard/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/super_editor_clipboard/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..4d6372eebd Binary files /dev/null and b/super_editor_clipboard/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/super_editor_clipboard/example/android/app/src/main/res/values-night/styles.xml b/super_editor_clipboard/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/super_editor_clipboard/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_editor_clipboard/example/android/app/src/main/res/values/styles.xml b/super_editor_clipboard/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..cb1ef88056 --- /dev/null +++ b/super_editor_clipboard/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_editor_clipboard/example/android/app/src/profile/AndroidManifest.xml b/super_editor_clipboard/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_editor_clipboard/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_editor_clipboard/example/android/build.gradle.kts b/super_editor_clipboard/example/android/build.gradle.kts new file mode 100644 index 0000000000..dbee657bb5 --- /dev/null +++ b/super_editor_clipboard/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/super_editor_clipboard/example/android/gradle.properties b/super_editor_clipboard/example/android/gradle.properties new file mode 100644 index 0000000000..fbee1d8cda --- /dev/null +++ b/super_editor_clipboard/example/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/super_editor_clipboard/example/android/gradle/wrapper/gradle-wrapper.properties b/super_editor_clipboard/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..e4ef43fb98 --- /dev/null +++ b/super_editor_clipboard/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/super_editor_clipboard/example/android/settings.gradle.kts b/super_editor_clipboard/example/android/settings.gradle.kts new file mode 100644 index 0000000000..ca7fe065c1 --- /dev/null +++ b/super_editor_clipboard/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/super_editor_clipboard/example/integration_test/plugin_integration_test.dart b/super_editor_clipboard/example/integration_test/plugin_integration_test.dart new file mode 100644 index 0000000000..cd87c06168 --- /dev/null +++ b/super_editor_clipboard/example/integration_test/plugin_integration_test.dart @@ -0,0 +1,22 @@ +// This is a basic Flutter integration test. +// +// Since integration tests run in a full Flutter application, they can interact +// with the host side of a plugin implementation, unlike Dart unit tests. +// +// For more information about Flutter integration tests, please see +// https://flutter.dev/to/integration-testing + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('getPlatformVersion test', (WidgetTester tester) async { + // final SuperEditorClipboard plugin = SuperEditorClipboard(); + // final String? version = await plugin.getPlatformVersion(); + // // The version string depends on the host platform running the test, so + // // just assert that some non-empty string is returned. + // expect(version?.isNotEmpty, true); + }); +} diff --git a/super_editor_clipboard/example/ios/.gitignore b/super_editor_clipboard/example/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/super_editor_clipboard/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/super_editor_clipboard/example/ios/Flutter/AppFrameworkInfo.plist b/super_editor_clipboard/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..1dc6cf7652 --- /dev/null +++ b/super_editor_clipboard/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/super_editor_clipboard/example/ios/Flutter/Debug.xcconfig b/super_editor_clipboard/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..ec97fc6f30 --- /dev/null +++ b/super_editor_clipboard/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/super_editor_clipboard/example/ios/Flutter/Release.xcconfig b/super_editor_clipboard/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..c4855bfe20 --- /dev/null +++ b/super_editor_clipboard/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/super_editor_clipboard/example/ios/Podfile b/super_editor_clipboard/example/ios/Podfile new file mode 100644 index 0000000000..620e46eba6 --- /dev/null +++ b/super_editor_clipboard/example/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# 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', '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 Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/super_editor_clipboard/example/ios/Podfile.lock b/super_editor_clipboard/example/ios/Podfile.lock new file mode 100644 index 0000000000..9f31f84ce4 --- /dev/null +++ b/super_editor_clipboard/example/ios/Podfile.lock @@ -0,0 +1,58 @@ +PODS: + - device_info_plus (0.0.1): + - Flutter + - Flutter (1.0.0) + - integration_test (0.0.1): + - Flutter + - irondash_engine_context (0.0.1): + - Flutter + - super_editor_clipboard (0.0.1): + - Flutter + - super_keyboard (0.0.1): + - Flutter + - super_native_extensions (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - Flutter (from `Flutter`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) + - super_editor_clipboard (from `.symlinks/plugins/super_editor_clipboard/ios`) + - super_keyboard (from `.symlinks/plugins/super_keyboard/ios`) + - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + Flutter: + :path: Flutter + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + irondash_engine_context: + :path: ".symlinks/plugins/irondash_engine_context/ios" + super_editor_clipboard: + :path: ".symlinks/plugins/super_editor_clipboard/ios" + super_keyboard: + :path: ".symlinks/plugins/super_keyboard/ios" + super_native_extensions: + :path: ".symlinks/plugins/super_native_extensions/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 + super_editor_clipboard: b50bb56d64f8e168a6d25982793ebe1d3f44d2e5 + super_keyboard: 016de6ce9ab826f9a0b185608209d6a3b556d577 + super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/super_editor_clipboard/example/ios/Runner.xcodeproj/project.pbxproj b/super_editor_clipboard/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..49034a0a2d --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,731 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 00E7CCEDA5BF47EC152D3A55 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDC30EDE35CC9E044759E6CA /* Pods_RunnerTests.framework */; }; + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 49E7A71AA2A4AE6DECDF6E04 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77BCAF42A0DED9FE93F36DA9 /* Pods_Runner.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 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 */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3430EE1A9CBA575316BEED84 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 77BCAF42A0DED9FE93F36DA9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 95047C58E1931DA81A45DA32 /* 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 = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B344DAD81AA32EBC0ABDEB3F /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + B6596CAFC1F331D1A16578A2 /* 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 = ""; }; + BC2C0C74DF03AE8780C43D4A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + DDC30EDE35CC9E044759E6CA /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F25C0C592F29789BBF93993B /* 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 49E7A71AA2A4AE6DECDF6E04 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A60BE4215837E5951E2F0D6A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 00E7CCEDA5BF47EC152D3A55 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + AB573FCC45A42B5794F5EEDF /* Pods */, + B24651ECE2278347160496BA /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + AB573FCC45A42B5794F5EEDF /* Pods */ = { + isa = PBXGroup; + children = ( + B6596CAFC1F331D1A16578A2 /* Pods-Runner.debug.xcconfig */, + 95047C58E1931DA81A45DA32 /* Pods-Runner.release.xcconfig */, + F25C0C592F29789BBF93993B /* Pods-Runner.profile.xcconfig */, + B344DAD81AA32EBC0ABDEB3F /* Pods-RunnerTests.debug.xcconfig */, + 3430EE1A9CBA575316BEED84 /* Pods-RunnerTests.release.xcconfig */, + BC2C0C74DF03AE8780C43D4A /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + B24651ECE2278347160496BA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 77BCAF42A0DED9FE93F36DA9 /* Pods_Runner.framework */, + DDC30EDE35CC9E044759E6CA /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + C987B80B1464FD2A8F386EF2 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + A60BE4215837E5951E2F0D6A /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 939C5EC3115FA4F0784297F1 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 96AC0B4DA0A9C28503896F65 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 939C5EC3115FA4F0784297F1 /* [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; + }; + 96AC0B4DA0A9C28503896F65 /* [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; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + C987B80B1464FD2A8F386EF2 /* [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-RunnerTests-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 */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 5RJWFAUGXQ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.supereditorclipboard.superEditorClipboardExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B344DAD81AA32EBC0ABDEB3F /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.supereditorclipboard.superEditorClipboardExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3430EE1A9CBA575316BEED84 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.supereditorclipboard.superEditorClipboardExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BC2C0C74DF03AE8780C43D4A /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.supereditorclipboard.superEditorClipboardExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 5RJWFAUGXQ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.supereditorclipboard.superEditorClipboardExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 5RJWFAUGXQ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.supereditorclipboard.superEditorClipboardExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/super_editor_clipboard/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/super_editor_clipboard/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_editor_clipboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor_clipboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor_clipboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_editor_clipboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_editor_clipboard/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_editor_clipboard/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..e3773d42e2 --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor_clipboard/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/super_editor_clipboard/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_editor_clipboard/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor_clipboard/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor_clipboard/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_editor_clipboard/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_editor_clipboard/example/ios/Runner/AppDelegate.swift b/super_editor_clipboard/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..1bd5f81ac4 --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,14 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + print("Example app AppDelegate - registering plugins") + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000..dc9ada4725 Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..7353c41ecf Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..6ed2d933e1 Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..4cd7b0099c Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..fe730945a0 Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..321773cd85 Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..502f463a9b Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..e9f5fea27c Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..84ac32ae7d Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..8953cba090 Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..0467bf12aa Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/super_editor_clipboard/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/super_editor_clipboard/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor_clipboard/example/ios/Runner/Base.lproj/Main.storyboard b/super_editor_clipboard/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor_clipboard/example/ios/Runner/Info.plist b/super_editor_clipboard/example/ios/Runner/Info.plist new file mode 100644 index 0000000000..d3d3fd6896 --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Super Editor Clipboard + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + super_editor_clipboard_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/super_editor_clipboard/example/ios/Runner/Runner-Bridging-Header.h b/super_editor_clipboard/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/super_editor_clipboard/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/super_editor_clipboard/example/ios/RunnerTests/RunnerTests.swift b/super_editor_clipboard/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..ef47598594 --- /dev/null +++ b/super_editor_clipboard/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,27 @@ +import Flutter +import UIKit +import XCTest + + +@testable import super_editor_clipboard + +// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. +// +// See https://developer.apple.com/documentation/xctest for more information about using XCTest. + +class RunnerTests: XCTestCase { + + func testGetPlatformVersion() { + let plugin = SuperEditorClipboardPlugin() + + let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + +} diff --git a/super_editor_clipboard/example/lib/main.dart b/super_editor_clipboard/example/lib/main.dart new file mode 100644 index 0000000000..2cad091067 --- /dev/null +++ b/super_editor_clipboard/example/lib/main.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_clipboard/super_editor_clipboard.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + late final Editor _editor; + + final _documentLayoutKey = GlobalKey(debugLabel: 'super-editor_document-layout'); + late final SuperEditorIosControlsController _iosControlsController; + late final SuperEditorAndroidControlsController _androidControlsController; + + @override + void initState() { + super.initState(); + + _editor = createDefaultDocumentEditor(); + + _iosControlsController = SuperEditorIosControlsControllerWithNativePaste( + editor: _editor, + documentLayoutResolver: () => _documentLayoutKey.currentState! as DocumentLayout, + ); + _androidControlsController = SuperEditorAndroidControlsController(toolbarBuilder: _buildAndroidToolbar); + } + + @override + void dispose() { + _iosControlsController.dispose(); + _androidControlsController.dispose(); + + _editor.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Native paste example app')), + body: SuperEditorIosControlsScope( + controller: _iosControlsController, + child: SuperEditorAndroidControlsScope( + controller: _androidControlsController, + child: SuperEditor(editor: _editor, documentLayoutKey: _documentLayoutKey), + ), + ), + ), + ); + } + + Widget _buildAndroidToolbar(BuildContext context, Key mobileToolbarKey, LeaderLink focalPoint) { + return AndroidTextEditingFloatingToolbar( + floatingToolbarKey: mobileToolbarKey, + focalPoint: focalPoint, + onSelectAllPressed: () {}, + onPastePressed: () { + pasteIntoEditorFromNativeClipboard(_editor); + }, + ); + } +} diff --git a/super_editor_clipboard/example/pubspec.lock b/super_editor_clipboard/example/pubspec.lock new file mode 100644 index 0000000000..c4b067c980 --- /dev/null +++ b/super_editor_clipboard/example/pubspec.lock @@ -0,0 +1,919 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + attributed_text: + dependency: transitive + description: + name: attributed_text + sha256: "177ea01f58a8d8df279f4066834375a2009bdd304d559c084bb06f784b258477" + url: "https://pub.dev" + source: hosted + version: "0.4.5" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + url: "https://pub.dev" + source: hosted + version: "8.12.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_quill_delta: + dependency: transitive + description: + name: dart_quill_delta + sha256: "6aa89f0903ca3e70f5ceeb1d75d722f6ca583e87a2a8893c7b9f42f7a947f6e5" + url: "https://pub.dev" + source: hosted + version: "9.6.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + url: "https://pub.dev" + source: hosted + version: "11.5.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_test_robots: + dependency: transitive + description: + name: flutter_test_robots + sha256: "3b00f2081148bde55190997c2772f934ad2f4529cbcfc4ccfa593f8ddc117a28" + url: "https://pub.dev" + source: hosted + version: "0.0.24" + flutter_test_runners: + dependency: transitive + description: + name: flutter_test_runners + sha256: cc575117ed66a79185a26995399d7048341517a1bd21188cb43753739627832d + url: "https://pub.dev" + source: hosted + version: "0.0.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + follow_the_leader: + dependency: "direct main" + description: + name: follow_the_leader + sha256: "5cc5676008b786e8a34725874c750489c778431d2f9c423f0cc3c18527535236" + url: "https://pub.dev" + source: hosted + version: "0.5.3" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + golden_toolkit: + dependency: transitive + description: + name: golden_toolkit + sha256: "8f74adab33154fe7b731395782797021f97d2edc52f7bfb85ff4f1b5c4a215f0" + url: "https://pub.dev" + source: hosted + version: "0.15.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + html2md: + dependency: transitive + description: + name: html2md + sha256: "465cf8ffa1b510fe0e97941579bf5b22e2d575f2cecb500a9c0254efe33a8036" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + irondash_engine_context: + dependency: transitive + description: + name: irondash_engine_context + sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7" + url: "https://pub.dev" + source: hosted + version: "0.5.5" + irondash_message_channel: + dependency: transitive + description: + name: irondash_message_channel + sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 + url: "https://pub.dev" + source: hosted + version: "0.7.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: transitive + description: + name: mockito + sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e + url: "https://pub.dev" + source: hosted + version: "5.6.1" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlord: + dependency: transitive + description: + name: overlord + sha256: "532f5685ac09ee805d97ce89794a4eeda41672c32955b4a835bdfce93e720a05" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pixel_snap: + dependency: transitive + description: + name: pixel_snap + sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" + url: "https://pub.dev" + source: hosted + version: "0.1.5" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + super_clipboard: + dependency: "direct main" + description: + name: super_clipboard + sha256: e73f3bb7e66cc9260efa1dc507f979138e7e106c3521e2dda2d0311f6d728a16 + url: "https://pub.dev" + source: hosted + version: "0.9.1" + super_editor: + dependency: "direct main" + description: + path: "../../super_editor" + relative: true + source: path + version: "0.3.0-dev.50" + super_editor_clipboard: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.2.9" + super_keyboard: + dependency: transitive + description: + name: super_keyboard + sha256: b5381ca46cf2cbc8cae79f8304e848f83363a8f47c1851125133c8af87d0f488 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + super_native_extensions: + dependency: transitive + description: + name: super_native_extensions + sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569 + url: "https://pub.dev" + source: hosted + version: "0.9.1" + super_text_layout: + dependency: transitive + description: + name: super_text_layout + sha256: "316e446bb7e7ec21da77011c5880532d163259416de6997f83377154f8e0ae71" + url: "https://pub.dev" + source: hosted + version: "0.1.20" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" + text_table: + dependency: transitive + description: + name: text_table + sha256: a42b35675be614274b884ee482d4bdf4bdf707bc65de18cb8f1ad288c1beb1f4 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.1 <4.0.0" + flutter: ">=3.35.0" diff --git a/super_editor_clipboard/example/pubspec.yaml b/super_editor_clipboard/example/pubspec.yaml new file mode 100644 index 0000000000..1c88d9195b --- /dev/null +++ b/super_editor_clipboard/example/pubspec.yaml @@ -0,0 +1,61 @@ +name: super_editor_clipboard_example +description: "Example app for super_editor_clipboard" +publish_to: 'none' + +environment: + sdk: ^3.10.1 + +dependencies: + flutter: + sdk: flutter + + follow_the_leader: ^0.5.2 + super_clipboard: ^0.9.1 + super_editor: ^0.3.0-dev.47 + super_editor_clipboard: + path: ../ + +dependency_overrides: + super_editor: + path: ../../super_editor + +dev_dependencies: + integration_test: + sdk: flutter + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + 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/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # 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/to/font-from-package diff --git a/super_editor_clipboard/example/test/widget_test.dart b/super_editor_clipboard/example/test/widget_test.dart new file mode 100644 index 0000000000..d7d05e94fa --- /dev/null +++ b/super_editor_clipboard/example/test/widget_test.dart @@ -0,0 +1,27 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. 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'; + +import 'package:super_editor_clipboard_example/main.dart'; + +void main() { + testWidgets('Verify Platform version', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that platform version is retrieved. + expect( + find.byWidgetPredicate( + (Widget widget) => widget is Text && + widget.data!.startsWith('Running on:'), + ), + findsOneWidget, + ); + }); +} diff --git a/super_editor_clipboard/ios/.gitignore b/super_editor_clipboard/ios/.gitignore new file mode 100644 index 0000000000..034771fc9c --- /dev/null +++ b/super_editor_clipboard/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/super_editor_clipboard/ios/Assets/.gitkeep b/super_editor_clipboard/ios/Assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/super_editor_clipboard/ios/Classes/SuperEditorClipboardPlugin.swift b/super_editor_clipboard/ios/Classes/SuperEditorClipboardPlugin.swift new file mode 100644 index 0000000000..b07adf63aa --- /dev/null +++ b/super_editor_clipboard/ios/Classes/SuperEditorClipboardPlugin.swift @@ -0,0 +1,161 @@ +import Flutter +import UIKit + +public class SuperEditorClipboardPlugin: NSObject, FlutterPlugin { + static var channel: FlutterMethodChannel? + + // `true` to run a custom paste implementation, or `false` to defer to the + // standard Flutter paste behavior. + static var doCustomPaste = false + + public static func register(with registrar: FlutterPluginRegistrar) { + log("Registering SuperEditorClipboardPlugin") + let channel = FlutterMethodChannel(name: "super_editor_clipboard.ios", binaryMessenger: registrar.messenger()) + self.channel = channel + + let instance = SuperEditorClipboardPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + + // Swizzle both the action execution (paste) and the validation (canPerformAction) + swizzleFlutterPaste() + swizzleCanPerformAction() + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + SuperEditorClipboardPlugin.log("Received call on iOS side: \(call.method)") + switch call.method { + case "enableCustomPaste": + SuperEditorClipboardPlugin.log("iOS platform - enabling custom paste") + SuperEditorClipboardPlugin.doCustomPaste = true + case "disableCustomPaste": + SuperEditorClipboardPlugin.log("iOS platform - disabling custom paste") + SuperEditorClipboardPlugin.doCustomPaste = false + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - Swizzling Logic + + private static func swizzleFlutterPaste() { + swizzle( + clsName: "FlutterTextInputView", + originalSelector: #selector(UIResponder.paste(_:)), + customSelector: #selector(customPaste(_:)) + ) + } + + private static func swizzleCanPerformAction() { + swizzle( + clsName: "FlutterTextInputView", + originalSelector: #selector(UIResponder.canPerformAction(_:withSender:)), + customSelector: #selector(customCanPerformAction(_:withSender:)) + ) + } + + private static func swizzle(clsName: String, originalSelector: Selector, customSelector: Selector) { + guard let flutterClass = NSClassFromString(clsName) else { + log("Could not find \(clsName)") + return + } + + guard let originalMethod = class_getInstanceMethod(flutterClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(SuperEditorClipboardPlugin.self, customSelector) else { + log("Could not find methods to swizzle for \(clsName)") + return + } + + // Add the custom method to the Flutter class + let didAddMethod = class_addMethod( + flutterClass, + customSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod) + ) + + if didAddMethod { + // Exchange implementations so 'originalSelector' calls our custom code, + // and 'customSelector' calls the original code. + let newMethod = class_getInstanceMethod(flutterClass, customSelector)! + method_exchangeImplementations(originalMethod, newMethod) + log("Successfully swizzled \(originalSelector) in \(clsName)") + } else { + log("Failed to add method \(customSelector) to \(clsName)") + } + } + + // MARK: - Custom Implementations + + /// This method replaces `paste(_:)` at runtime. + @objc func customPaste(_ sender: Any?) { + if (!SuperEditorClipboardPlugin.doCustomPaste) { + SuperEditorClipboardPlugin.log("Running regular Flutter paste") + // FALLBACK: Call original implementation (which is now mapped to customPaste) + if self.responds(to: #selector(customPaste(_:))) { + self.perform(#selector(customPaste(_:)), with: sender) + } + return + } + + SuperEditorClipboardPlugin.log("Running custom paste") + SuperEditorClipboardPlugin.channel?.invokeMethod("paste", arguments: nil) + } + + /// This method replaces `canPerformAction(_:withSender:)` at runtime. + @objc func customCanPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + let isPasteAction = action == #selector(UIResponderStandardEditActions.paste(_:)) + + // 1. If it is the PASTE action AND we are in custom mode, check our broader conditions. + if isPasteAction && SuperEditorClipboardPlugin.doCustomPaste { + // Check for ANY pasteable content (Images, Colors, URLs, Strings) + // Note: Flutter only checks `hasStrings`. + if UIPasteboard.general.hasStrings || + UIPasteboard.general.hasImages || + UIPasteboard.general.hasURLs || + UIPasteboard.general.hasColors { + return true + } + } + + // 2. Otherwise (or if the custom check failed), fall back to the ORIGINAL logic. + // Because we exchanged implementations, calling 'customCanPerformAction' here + // actually invokes the original Flutter engine logic. + + // We cannot use 'perform' for Bool return types, so we use IMP casting. + return SuperEditorClipboardPlugin.callOriginalCanPerformAction( + instance: self, + selector: #selector(customCanPerformAction(_:withSender:)), + action: action, + sender: sender + ) + } + + // MARK: - Helpers + + /// Safely invokes the original implementation of `canPerformAction` (which is now swapped). + private static func callOriginalCanPerformAction(instance: Any, selector: Selector, action: Selector, sender: Any?) -> Bool { + guard let method = class_getInstanceMethod(object_getClass(instance), selector) else { + return false + } + + let imp = method_getImplementation(method) + + // Define the C function signature for (BOOL)objc_msgSend(id, SEL, SEL, id) + typealias CanPerformActionFunction = @convention(c) (AnyObject, Selector, Selector, Any?) -> Bool + + let originalFunction = unsafeBitCast(imp, to: CanPerformActionFunction.self) + + // 'instance' is 'self' (FlutterTextInputView) + // 'selector' is the selector triggering this IMP (customCanPerformAction) + // 'action' is the argument (e.g., paste:) + return originalFunction(instance as AnyObject, selector, action, sender) + } + + public static let isLoggingEnabled = false + + internal static func log(_ message: String) { + if isLoggingEnabled { + print("[SuperEditorClipboardPlugin] \(message)") + } + } +} \ No newline at end of file diff --git a/super_editor_clipboard/ios/Resources/PrivacyInfo.xcprivacy b/super_editor_clipboard/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..a34b7e2e60 --- /dev/null +++ b/super_editor_clipboard/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/super_editor_clipboard/ios/super_editor_clipboard.podspec b/super_editor_clipboard/ios/super_editor_clipboard.podspec new file mode 100644 index 0000000000..d1872a6208 --- /dev/null +++ b/super_editor_clipboard/ios/super_editor_clipboard.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint super_editor_clipboard.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'super_editor_clipboard' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '13.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'super_editor_clipboard_privacy' => ['Resources/PrivacyInfo.xcprivacy']} +end diff --git a/super_editor_clipboard/lib/src/document_copy.dart b/super_editor_clipboard/lib/src/document_copy.dart new file mode 100644 index 0000000000..0b0e047e15 --- /dev/null +++ b/super_editor_clipboard/lib/src/document_copy.dart @@ -0,0 +1,70 @@ +import 'package:super_clipboard/super_clipboard.dart'; +import 'package:super_editor/super_editor.dart'; + +extension RichTextCopy on Document { + Future copyAsRichTextWithPlainTextFallback({ + DocumentSelection? selection, + }) async { + final clipboard = SystemClipboard.instance; + if (clipboard == null) { + return; // Clipboard API is not supported on this platform. + } + + final item = DataWriterItem(); + + // Serialize to HTML as the most common representation of rich text + // across apps. + item.add(Formats.htmlText(toHtml( + selection: selection, + nodeSerializers: SuperEditorClipboardConfig.nodeHtmlSerializers, + inlineSerializers: SuperEditorClipboardConfig.inlineHtmlSerializers, + ))); + + // Serialize a backup copy in plain text so that this clipboard content + // can be pasted into plain-text apps, too. + item.add(Formats.plainText(toPlainText(selection: selection))); + + // Write the document to the clipboard. + await clipboard.write([item]); + } + + Future copyAsRichTextWithMarkdownFallback({ + DocumentSelection? selection, + }) async { + final clipboard = SystemClipboard.instance; + if (clipboard == null) { + return; // Clipboard API is not supported on this platform. + } + + final item = DataWriterItem(); + + // Serialize to HTML as the most common representation of rich text + // across apps. + item.add(Formats.htmlText(toHtml( + selection: selection, + nodeSerializers: SuperEditorClipboardConfig.nodeHtmlSerializers, + inlineSerializers: SuperEditorClipboardConfig.inlineHtmlSerializers, + ))); + + // Serialize to Markdown as a plain text representation of rich text. + item.add(Formats.plainText( + serializeDocumentToMarkdown(this, selection: selection), + )); + + // Write the document to the clipboard. + await clipboard.write([item]); + } +} + +/// A global configuration for rich text serializers, which can be globally customized +/// within an app to add or change the serializers used by [Document.copyAsRichText]. +abstract class SuperEditorClipboardConfig { + static NodeHtmlSerializerChain get nodeHtmlSerializers => _nodeHtmlSerializers; + static NodeHtmlSerializerChain _nodeHtmlSerializers = defaultNodeHtmlSerializerChain; + static void setNodeHtmlSerializers(NodeHtmlSerializerChain nodeSerializers) => _nodeHtmlSerializers = nodeSerializers; + + static InlineHtmlSerializerChain get inlineHtmlSerializers => _inlineHtmlSerializers; + static InlineHtmlSerializerChain _inlineHtmlSerializers = defaultInlineHtmlSerializers; + static void setInlineHtmlSerializers(InlineHtmlSerializerChain inlineSerializers) => + _inlineHtmlSerializers = inlineSerializers; +} diff --git a/super_editor_clipboard/lib/src/editor_paste.dart b/super_editor_clipboard/lib/src/editor_paste.dart new file mode 100644 index 0000000000..b5311e866b --- /dev/null +++ b/super_editor_clipboard/lib/src/editor_paste.dart @@ -0,0 +1,59 @@ +import 'package:html2md/html2md.dart' as html2md; +import 'package:super_editor/super_editor.dart'; + +extension RichTextPaste on Editor { + static const defaultIgnoredHtmlTags = {"style", "script"}; + + void pasteHtml( + Editor editor, + String html, { + Set ignoredTags = defaultIgnoredHtmlTags, + }) { + final markdown = html2md.convert( + html, + ignore: ignoredTags.toList(growable: false), + styleOptions: { + // Use "#" for headers instead of "=======" + 'headingStyle': 'atx', + }, + ); + pasteMarkdown(editor, markdown); + } + + void pasteMarkdown(Editor editor, String markdown) { + final contentToPaste = deserializeMarkdownToDocument(markdown); + + final composer = editor.composer; + DocumentPosition? pastePosition = composer.selection!.extent; + + // Delete all currently selected content. + if (!composer.selection!.isCollapsed) { + pastePosition = CommonEditorOperations.getDocumentPositionAfterExpandedDeletion( + document: editor.document, + selection: composer.selection!, + ); + + if (pastePosition == null) { + // There are no deletable nodes in the selection. Do nothing. + return; + } + + // Delete the selected content. + editor.execute([ + DeleteContentRequest(documentRange: composer.selection!), + ChangeSelectionRequest( + DocumentSelection.collapsed(position: pastePosition), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ]); + } + + editor.execute([ + PasteStructuredContentEditorRequest( + content: contentToPaste, + pastePosition: pastePosition, + ), + ]); + } +} diff --git a/super_editor_clipboard/lib/src/logging.dart b/super_editor_clipboard/lib/src/logging.dart new file mode 100644 index 0000000000..1710a028d5 --- /dev/null +++ b/super_editor_clipboard/lib/src/logging.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; + +/// Loggers for Super Editor Clipboard, which can be activated by log level and by focal +/// area, and can also print to a given [SECLogPrinter]. +abstract class SECLog { + static final superEditorClipboard = Logger("super_editor_clipboard"); + static final paste = Logger("super_editor_clipboard.paste"); + static final pasteIOS = Logger("super_editor_clipboard.paste.ios"); + static final pasteAndroid = Logger("super_editor_clipboard.paste.android"); + + static StreamSubscription? _logRecordSubscription; + + static void startLogging([Level level = Level.ALL, SECLogPrinter? printer]) { + if (_logRecordSubscription != null) { + _logRecordSubscription!.cancel(); + _logRecordSubscription = null; + } + + hierarchicalLoggingEnabled = true; + superEditorClipboard.level = level; + _logRecordSubscription = superEditorClipboard.onRecord.listen(printer ?? defaultLogPrinter); + } + + static void stopLogging() { + superEditorClipboard.level = Level.OFF; + + if (_logRecordSubscription != null) { + _logRecordSubscription!.cancel(); + _logRecordSubscription = null; + } + } + + static void defaultLogPrinter(LogRecord record) { + // ignore: avoid_print + print('${record.level.name}: ${record.time.toLogTime()}: ${record.message}'); + } +} + +typedef SECLogPrinter = void Function(LogRecord); + +extension on DateTime { + String toLogTime() { + String h = _twoDigits(hour); + String min = _twoDigits(minute); + String sec = _twoDigits(second); + String ms = _threeDigits(millisecond); + if (isUtc) { + return "$h:$min:$sec.$ms"; + } else { + return "$h:$min:$sec.$ms"; + } + } + + String _threeDigits(int n) { + if (n >= 100) return "$n"; + if (n >= 10) return "0$n"; + return "00$n"; + } + + String _twoDigits(int n) { + if (n >= 10) return "$n"; + return "0$n"; + } +} diff --git a/super_editor_clipboard/lib/src/plugin/ios/super_editor_clipboard_ios_plugin.dart b/super_editor_clipboard/lib/src/plugin/ios/super_editor_clipboard_ios_plugin.dart new file mode 100644 index 0000000000..3daf14e48e --- /dev/null +++ b/super_editor_clipboard/lib/src/plugin/ios/super_editor_clipboard_ios_plugin.dart @@ -0,0 +1,108 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class SuperEditorClipboardIosPlugin { + @visibleForTesting + static final methodChannel = const MethodChannel('super_editor_clipboard.ios'); + + @visibleForTesting + static const messageToPlatformEnableCustomPaste = "enableCustomPaste"; + @visibleForTesting + static const messageToPlatformDisableCustomPaste = "disableCustomPaste"; + + @visibleForTesting + static const messageFromPlatformPaste = "paste"; + + /// Returns `true` if paste functionality currently has an owner, or `false` if nothing + /// currently owns the native paste functionality. + static bool get isPasteOwned => _pasteOwner != null; + + /// Returns `true` if the given [owner] is currently the owner of the native paste functionality, + /// or `false` if it's not. + static bool isPasteOwner(Object owner) => _pasteOwner == owner; + + /// The object that owns control over this plugin. + /// + /// Only the owner can enable or disable this plugin. The concept of an owner exists to help + /// multiple text fields and editors co-exist, without interfering with each other's claim over + /// pasting behavior. + /// + /// A text field or editor should only make itself the owner when it believes that it has + /// focus. Following this policy should minimize the possibility that paste happens in one field + /// but ends up being handled by another. + static Object? _pasteOwner; + + /// Makes the [newOwner] the owner of native paste functionality, which can then + /// call [enableCustomPaste] and [disableCustomPaste]. + static void takePasteOwnership(Object newOwner) { + _pasteOwner = newOwner; + } + + /// Releases ownership of native paste, if [owner] is currently the native paste owner. + static void releasePasteOwnership(Object owner) { + if (owner != _pasteOwner) { + return; + } + + // Paste ownership was released, so there's currently no paste owner. + // Therefore, disable custom native paste. + disableCustomPaste(_pasteOwner!); + + _pasteOwner = null; + } + + static CustomPasteDelegate? _customPasteDelegate; + + /// Overrides Flutter's built-in iOS paste behavior by instead calling a method + /// on the [delegate] when the user presses "paste". + /// + /// Does nothing if the given [pasteOwner] is currently the owner of this plugin. + static void enableCustomPaste(Object pasteOwner, CustomPasteDelegate delegate) { + if (pasteOwner != _pasteOwner) { + return; + } + + if (_customPasteDelegate != null) { + // Custom paste is already enabled. Just replace the existing delegate + // with the new one. + _customPasteDelegate = delegate; + return; + } + + _customPasteDelegate = delegate; + + methodChannel.invokeMethod(messageToPlatformEnableCustomPaste); + methodChannel.setMethodCallHandler(_onMessageFromPlatform); + } + + static Future _onMessageFromPlatform(MethodCall call) async { + if (call.method == messageFromPlatformPaste) { + if (_customPasteDelegate == null) { + // TODO: Log a warning that we're missing a delegate + return; + } + + _customPasteDelegate!.onUserRequestedPaste(); + } + } + + /// Disables our override of Flutter's iOS paste behavior, returning to Flutter's original + /// paste behavior. + static void disableCustomPaste(Object owner) { + if (owner != _pasteOwner) { + return; + } + + _customPasteDelegate = null; + methodChannel.invokeMethod(messageToPlatformDisableCustomPaste); + } +} + +/// Delegate that implements the iOS paste behavior when the user taps on "paste" +/// on the native iOS popover toolbar. +/// +/// It's the delegate's responsibility to query the clipboard and decide what to do +/// with the content. +abstract class CustomPasteDelegate { + void onUserRequestedPaste(); +} diff --git a/super_editor_clipboard/lib/src/super_editor_copy.dart b/super_editor_clipboard/lib/src/super_editor_copy.dart new file mode 100644 index 0000000000..8e7e73a86c --- /dev/null +++ b/super_editor_clipboard/lib/src/super_editor_copy.dart @@ -0,0 +1,31 @@ +import 'package:flutter/services.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_clipboard/src/document_copy.dart'; + +/// [SuperEditor] shortcut to copy the document as rich text when +/// `CMD + C` (Mac) or `CTRL + C` (Windows/Linux) is pressed. +ExecutionInstruction copyAsRichTextWhenCmdCOrCtrlCIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.keyC) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection!.isCollapsed) { + // Nothing to copy, but we technically handled the task. + return ExecutionInstruction.haltExecution; + } + + editContext.document.copyAsRichTextWithPlainTextFallback( + selection: editContext.composer.selection!, + ); + + return ExecutionInstruction.haltExecution; +} diff --git a/super_editor_clipboard/lib/src/super_editor_paste.dart b/super_editor_clipboard/lib/src/super_editor_paste.dart new file mode 100644 index 0000000000..2cafaef858 --- /dev/null +++ b/super_editor_clipboard/lib/src/super_editor_paste.dart @@ -0,0 +1,445 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_clipboard/super_clipboard.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_clipboard/src/editor_paste.dart'; +import 'package:super_editor_clipboard/src/logging.dart'; +import 'package:super_editor_clipboard/src/plugin/ios/super_editor_clipboard_ios_plugin.dart'; + +/// Pastes rich text from the system clipboard when the user presses CMD+V on +/// Mac, or CTRL+V on Windows/Linux. +/// +/// This method expects to find rich text on the system clipboard as HTML, which +/// is then converted to Markdown, and then converted to a [Document]. +ExecutionInstruction pasteRichTextOnCmdCtrlV({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!HardwareKeyboard.instance.isMetaPressed && !HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.keyV) { + return ExecutionInstruction.continueExecution; + } + + // Cmd/Ctrl+V detected - Paste content from native clipboard. + pasteIntoEditorFromNativeClipboard(editContext.editor); + + return ExecutionInstruction.haltExecution; +} + +/// A [SuperEditorIosControlsController] which adds a custom implementation when the user +/// presses "paste" on the native iOS popover toolbar. +/// +/// As of writing, Jan 2026, Flutter directly implements what happens when the user presses "paste" on +/// the native iOS popover toolbar. The Flutter implementation only pastes plain text, which prevents +/// pasting images or HTML or Markdown. +/// +/// This controller uses the [SuperEditorClipboardIosPlugin] to intercept calls to "paste" +/// before they reach Flutter, and redirects those calls to this controller. This controller +/// then uses `super_clipboard` to inspect what's being pasted, and then take the appropriate +/// [Editor] action. +class SuperEditorIosControlsControllerWithNativePaste extends SuperEditorIosControlsController + implements CustomPasteDelegate { + SuperEditorIosControlsControllerWithNativePaste({ + required this.editor, + required this.documentLayoutResolver, + CustomPasteDataInserter? customPasteDataInserter, + Map Function(Editor, ClipboardReader)> customFileInserters = const {}, + Map, FutureOr Function(Editor, ClipboardReader)> customValueInserters = const {}, + Set ignoredHtmlTags = RichTextPaste.defaultIgnoredHtmlTags, + super.useIosSelectionHeuristics = true, + super.handleColor, + super.floatingCursorController, + super.magnifierBuilder, + super.createOverlayControlsClipper, + }) : _customFileInserters = customFileInserters, + _customValueInserters = customValueInserters, + _customPasteDataInserter = customPasteDataInserter, + _ignoredHtmlTags = ignoredHtmlTags { + shouldShowToolbar.addListener(_onToolbarVisibilityChange); + } + + @override + void dispose() { + // In case we enabled custom native paste, disable it on disposal. + if (SuperEditorClipboardIosPlugin.isPasteOwner(this)) { + SECLog.pasteIOS.fine("SuperEditorIosControlsControllerWithNativePaste is releasing paste"); + } + SuperEditorClipboardIosPlugin.disableCustomPaste(this); + SuperEditorClipboardIosPlugin.releasePasteOwnership(this); + + shouldShowToolbar.removeListener(_onToolbarVisibilityChange); + super.dispose(); + } + + final CustomPasteDataInserter? _customPasteDataInserter; + final Map _customFileInserters; + final Map _customValueInserters; + final Set _ignoredHtmlTags; + + @protected + final Editor editor; + + @protected + final DocumentLayoutResolver documentLayoutResolver; + + @override + DocumentFloatingToolbarBuilder? get toolbarBuilder => (context, mobileToolbarKey, focalPoint) { + if (editor.composer.selection == null) { + return const SizedBox(); + } + + return iOSSystemPopoverEditorToolbarWithFallbackBuilder( + context, + mobileToolbarKey, + focalPoint, + CommonEditorOperations( + document: editor.document, + editor: editor, + composer: editor.composer, + documentLayoutResolver: documentLayoutResolver, + ), + SuperEditorIosControlsScope.rootOf(context), + ); + }; + + void _onToolbarVisibilityChange() { + if (shouldShowToolbar.value) { + // The native iOS toolbar is visible. + SECLog.pasteIOS.fine("SuperEditorIosControlsControllerWithNativePaste is taking over paste on toolbar show"); + SuperEditorClipboardIosPlugin.takePasteOwnership(this); + SuperEditorClipboardIosPlugin.enableCustomPaste(this, this); + } else { + // The native iOS toolbar is no longer visible. + SECLog.pasteIOS.fine("SuperEditorIosControlsControllerWithNativePaste is releasing paste on toolbar hide"); + SuperEditorClipboardIosPlugin.releasePasteOwnership(this); + } + } + + @override + Future onUserRequestedPaste() async { + SECLog.pasteIOS.fine("User requested to paste - pasting from super_clipboard"); + pasteIntoEditorFromNativeClipboard( + editor, + customInserter: _customPasteDataInserter, + customFileInserters: _customFileInserters, + customValueInserters: _customValueInserters, + ignoredHtmlTags: _ignoredHtmlTags, + ); + } +} + +typedef CustomPasteDataInserter = FutureOr Function(Editor editor, ClipboardReader clipboardReader); + +/// Reads the native OS clipboard and pastes the content into the given [editor] at the +/// current selection. +/// +/// If the [editor] has no selection, this method does nothing. +/// +/// The supported clipboard data types is determined by the implementation of this method, and +/// available [EditRequest]s in the Super Editor API. I.e., there are probably a number of +/// unsupported content types. This implementation will evolve over time. +/// +/// To take an arbitrary custom action, such as handling a custom data type, provide +/// a [customInserter]. +/// +/// To take custom actions when pasting known file types, provide desired [customFileInserters]. +/// +/// To take custom actions when pasting known value types (HTML, URL's, plain text), +/// provide desired [customValueInserters]. +/// +/// In the case that HTML is found on the clipboard, [ignoredHtmlTags] specifies any HTML +/// tags that should be completely ignored when deserializing the HTML to a [Document]. +/// For example, it is probably never desirable to extract the text from a `

    Hello, World!

    "; + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, html); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("Hello, World!")), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 13), + ), + ), + ); + }); + + test("pastes multiple paragraphs in empty paragraph", () { + const html = "

    One

    Two

    Three

    "; + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, html); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect(editor.document.length, 3); + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("One")), + ParagraphNode( + id: editor.document.getNodeAt(1)!.id, + text: AttributedText("Two"), + metadata: {'textAlign': null}, + ), + ParagraphNode( + id: editor.document.getNodeAt(2)!.id, + text: AttributedText("Three"), + metadata: {'textAlign': null}, + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.getNodeAt(2)!.id, + nodePosition: TextNodePosition(offset: 5), + ), + ), + ); + }); + + test("pastes multiple paragraphs in middle of paragraph", () { + const html = "

    One

    Two

    Three

    "; + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefgh"))], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, html); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect(editor.document.length, 3); + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcdOne")), + ParagraphNode( + id: editor.document.getNodeAt(1)!.id, + text: AttributedText("Two"), + metadata: {'textAlign': null}, + ), + ParagraphNode( + id: editor.document.getNodeAt(2)!.id, + text: AttributedText("Threeefgh"), + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.getNodeAt(2)!.id, + nodePosition: TextNodePosition(offset: 5), + ), + ), + ); + }); + + test("pastes table in empty paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, '$_tableHtml'); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + TableBlockNode( + id: editor.document.first.id, + cells: _tableCells, + ), + ParagraphNode( + id: editor.document.last.id, + text: AttributedText(), + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }); + + test("pastes table in middle of paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefgh"))], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, '$_tableHtml'); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcd")), + TableBlockNode( + id: editor.document.getNodeAt(1)!.id, + cells: _tableCells, + ), + ParagraphNode( + id: editor.document.last.id, + text: AttributedText("efgh"), + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }); + + test("pastes mixed content in middle of paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefgh"))], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, _mixedContent); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcdBefore")), + TableBlockNode( + id: editor.document.getNodeAt(1)!.id, + cells: _tableCells, + ), + ParagraphNode( + id: editor.document.getNodeAt(2)!.id, + text: AttributedText("Afterefgh"), + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: TextNodePosition(offset: 5), + ), + ), + ); + }); + }); + + test("URLs", () async { + // Note: Clipboards have a dedicated URL/URI data type. For example, opening + // a webpage in mobile Safari and then using the browser's share feature + // saves a URI to the clipboard - not plain text, and not an HTML link. + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Setup fake clipboard with a URL in plain text. + final fakeClipboard = _FakeClipboard(_FakeClipboardReader({ + Formats.uri, + }, [ + _FakeUrlTextReaderItem("https://google.com"), + ])); + + await pasteIntoEditorFromNativeClipboard(editor, testClipboard: fakeClipboard); + + final urlAttribution = LinkAttribution("https://google.com"); + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "https://google.com", + AttributedSpans( + attributions: [ + SpanMarker(attribution: urlAttribution, offset: 0, markerType: SpanMarkerType.start), + SpanMarker(attribution: urlAttribution, offset: 17, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ], + ), + ), + ); + }); + + group("plain text >", () { + test("parses URLs", () async { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Setup fake clipboard with a URL in plain text. + final fakeClipboard = _FakeClipboard(_FakeClipboardReader({ + Formats.plainText, + }, [ + _FakePlainTextReaderItem("Hello, https://google.com world"), + ])); + + await pasteIntoEditorFromNativeClipboard(editor, testClipboard: fakeClipboard); + + final urlAttribution = LinkAttribution("https://google.com"); + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + "Hello, https://google.com world", + AttributedSpans( + attributions: [ + SpanMarker(attribution: urlAttribution, offset: 7, markerType: SpanMarkerType.start), + SpanMarker(attribution: urlAttribution, offset: 24, markerType: SpanMarkerType.end), + ], + ), + ), + ), + ], + ), + ), + ); + }); + }); + }); +} + +const _mixedContent = '' + '

    Before

    $_tableHtml

    After

    '; + +const _tableHtml = '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
    BMI CategoryBMI Range (kg/m²)
    Underweight< 18.5
    Normal weight18.5 - 24.9
    Overweight25.0 - 29.9
    Obesity (Class I)30.0 - 34.9
    Obesity (Class II)35.0 - 39.9
    Obesity (Class III)≥ 40.0
    '; + +final _tableCells = [ + [ + TextNode( + id: "1.1", + text: AttributedText("BMI Category"), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + 'textAlign': TextAlign.center, + }, + ), + TextNode( + id: "1.2", + text: AttributedText("BMI Range (kg/m²)"), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + 'textAlign': TextAlign.center, + }, + ), + ], + [ + TextNode( + id: "2.1", + text: AttributedText("Underweight"), + ), + TextNode( + id: "2.2", + text: AttributedText("< 18.5"), + ), + ], + [ + TextNode( + id: "3.1", + text: AttributedText("Normal weight"), + ), + TextNode( + id: "3.2", + text: AttributedText("18.5 - 24.9"), + ), + ], + [ + TextNode( + id: "4.1", + text: AttributedText("Overweight"), + ), + TextNode( + id: "4.2", + text: AttributedText("25.0 - 29.9"), + ), + ], + [ + TextNode( + id: "5.1", + text: AttributedText("Obesity (Class I)"), + ), + TextNode( + id: "5.2", + text: AttributedText("30.0 - 34.9"), + ), + ], + [ + TextNode( + id: "6.1", + text: AttributedText("Obesity (Class II)"), + ), + TextNode( + id: "6.2", + text: AttributedText("35.0 - 39.9"), + ), + ], + [ + TextNode( + id: "7.1", + text: AttributedText("Obesity (Class III)"), + ), + TextNode( + id: "7.2", + text: AttributedText("≥ 40.0"), + ), + ], +]; + +class _FakeClipboard implements SystemClipboard { + _FakeClipboard(this._reader); + + final ClipboardReader _reader; + + @override + Future read() async => _reader; + + @override + Future write(Iterable items) { + throw UnimplementedError(); + } +} + +class _FakeClipboardReader implements ClipboardReader { + _FakeClipboardReader(this._holdingFormats, this._items); + + final Set _holdingFormats; + + final List _items; + + @override + bool canProvide(DataFormat format) { + return _holdingFormats.contains(format); + } + + @override + bool hasValue(DataFormat format) { + // TODO: implement hasValue + throw UnimplementedError(); + } + + @override + List> getFormats(List> allFormats) { + // TODO: implement getFormats + throw UnimplementedError(); + } + + @override + List get items => List.from(_items); + + @override + List get platformFormats => throw UnimplementedError(); + + @override + DataReaderItem? get rawReader => throw UnimplementedError(); + + @override + Future readValue(ValueFormat format) { + throw UnimplementedError(); + } + + @override + Future getSuggestedName() { + // TODO: implement getSuggestedName + throw UnimplementedError(); + } + + @override + ReadProgress? getFile( + FileFormat? format, + AsyncValueChanged onFile, { + ValueChanged? onError, + bool allowVirtualFiles = true, + bool synthesizeFilesFromURIs = true, + }) { + throw UnimplementedError(); + } + + @override + ReadProgress? getValue(ValueFormat format, AsyncValueChanged onValue, + {ValueChanged? onError}) { + throw UnimplementedError(); + } + + @override + Future getVirtualFileReceiver({FileFormat? format}) { + throw UnimplementedError(); + } + + @override + bool isSynthesized(DataFormat format) { + throw UnimplementedError(); + } + + @override + bool isVirtual(DataFormat format) { + throw UnimplementedError(); + } +} + +class _FakeUrlTextReaderItem implements ClipboardDataReader { + _FakeUrlTextReaderItem(this._uri); + + final String _uri; + + @override + bool canProvide(DataFormat format) { + return format == Formats.uri; + } + + @override + List> getFormats(List> allFormats) { + return [Formats.uri]; + } + + @override + Future readValue(ValueFormat format) async { + return format.codec.decode(_SimpleProvider(_uri), "text/uri-list"); + } + + @override + ReadProgress? getFile(FileFormat? format, AsyncValueChanged onFile, + {ValueChanged? onError, bool allowVirtualFiles = true, bool synthesizeFilesFromURIs = true}) { + // TODO: implement getFile + throw UnimplementedError(); + } + + @override + Future getSuggestedName() { + // TODO: implement getSuggestedName + throw UnimplementedError(); + } + + @override + ReadProgress? getValue(ValueFormat format, AsyncValueChanged onValue, + {ValueChanged? onError}) { + // TODO: implement getValue + throw UnimplementedError(); + } + + @override + Future getVirtualFileReceiver({FileFormat? format}) { + // TODO: implement getVirtualFileReceiver + throw UnimplementedError(); + } + + @override + bool hasValue(DataFormat format) { + return format == Formats.plainText; + } + + @override + bool isSynthesized(DataFormat format) { + return false; + } + + @override + bool isVirtual(DataFormat format) { + return false; + } + + @override + // TODO: implement platformFormats + List get platformFormats => throw UnimplementedError(); + + @override + // TODO: implement rawReader + DataReaderItem? get rawReader => throw UnimplementedError(); +} + +class _FakePlainTextReaderItem implements ClipboardDataReader { + _FakePlainTextReaderItem(this._plainText); + + final String _plainText; + + @override + bool canProvide(DataFormat format) { + return format == Formats.plainText; + } + + @override + List> getFormats(List> allFormats) { + return [Formats.plainText]; + } + + @override + Future readValue(ValueFormat format) async { + return format.codec.decode(_SimpleProvider(_plainText), "public.utf8-plain-text"); + } + + @override + ReadProgress? getFile(FileFormat? format, AsyncValueChanged onFile, + {ValueChanged? onError, bool allowVirtualFiles = true, bool synthesizeFilesFromURIs = true}) { + // TODO: implement getFile + throw UnimplementedError(); + } + + @override + Future getSuggestedName() { + // TODO: implement getSuggestedName + throw UnimplementedError(); + } + + @override + ReadProgress? getValue(ValueFormat format, AsyncValueChanged onValue, + {ValueChanged? onError}) { + // TODO: implement getValue + throw UnimplementedError(); + } + + @override + Future getVirtualFileReceiver({FileFormat? format}) { + // TODO: implement getVirtualFileReceiver + throw UnimplementedError(); + } + + @override + bool hasValue(DataFormat format) { + return format == Formats.plainText; + } + + @override + bool isSynthesized(DataFormat format) { + return false; + } + + @override + bool isVirtual(DataFormat format) { + return false; + } + + @override + // TODO: implement platformFormats + List get platformFormats => throw UnimplementedError(); + + @override + // TODO: implement rawReader + DataReaderItem? get rawReader => throw UnimplementedError(); +} + +class _SimpleProvider extends PlatformDataProvider { + final Object data; + + _SimpleProvider(this.data); + + @override + List getAllFormats() => []; + + @override + Future getData(PlatformFormat format) async => data; +} diff --git a/super_editor_clipboard/test/flutter_test_config.dart b/super_editor_clipboard/test/flutter_test_config.dart new file mode 100644 index 0000000000..261f710387 --- /dev/null +++ b/super_editor_clipboard/test/flutter_test_config.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:super_editor/src/super_textfield/ios/ios_textfield.dart'; +import 'package:super_editor/src/test/test_globals.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + // Disable indeterminate animations + BlinkController.indeterminateAnimationsEnabled = false; + + // Disable iOS selection heuristics, i.e, place the caret at the exact + // tapped position instead of placing it at word boundaries. + IOSTextFieldTouchInteractor.useIosSelectionHeuristics = false; + + Testing.isInTest = true; + + return testMain(); +} diff --git a/super_editor_clipboard/test/ios_native_paste_test.dart b/super_editor_clipboard/test/ios_native_paste_test.dart new file mode 100644 index 0000000000..61faab3440 --- /dev/null +++ b/super_editor_clipboard/test/ios_native_paste_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor_clipboard/src/plugin/ios/super_editor_clipboard_ios_plugin.dart'; +import 'package:super_editor_clipboard/src/super_editor_paste.dart'; +import 'package:super_keyboard/super_keyboard_test.dart'; + +void main() { + group("Paste > iOS > native >", () { + testWidgetsOnIos("takes control of native paste when toolbar is shown", (tester) async { + // Simulate fake keyboard expand/collapse because this impacts decisions to + // show the popover toolbar on iOS. + TestSuperKeyboard.install(id: "editor", vsync: tester); + + try { + int enableCustomPasteCount = 0; + int disableCustomPasteCount = 0; + + // Intercept plugin messages from the Dart side of our plugin to the iOS side. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SuperEditorClipboardIosPlugin.methodChannel, + (MethodCall methodCall) async { + if (methodCall.method == SuperEditorClipboardIosPlugin.messageToPlatformEnableCustomPaste) { + enableCustomPasteCount += 1; + } else if (methodCall.method == SuperEditorClipboardIosPlugin.messageToPlatformDisableCustomPaste) { + disableCustomPasteCount += 1; + } + + // Flutter channels are expected to always return something. + return null; + }); + + await _pumpScaffold(tester); + + // Ensure that the editor hasn't enabled custom paste, yet. + expect(enableCustomPasteCount, 0); + expect(disableCustomPasteCount, 0); + + // Tap to place the caret. + await tester.tapInParagraph("1", 0); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + // Tap again to show the toolbar. + await tester.tapInParagraph("1", 0); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + // Ensure that the editor has enabled custom paste, now that the toolbar is visible. + expect(enableCustomPasteCount, 1); + expect(disableCustomPasteCount, 0); + + // Tap on the caret again, to toggle the toolbar off. + await tester.tap(find.byType(SuperEditor)); + await tester.pumpAndSettle(); + + // Ensure that the editor disabled custom paste, now that the toolbar is gone. + expect(enableCustomPasteCount, 1); + expect(disableCustomPasteCount, 1); + } finally { + // Remove the fake software keyboard. + TestSuperKeyboard.forceUninstall(); + } + }); + }); +} + +Future _pumpScaffold(WidgetTester tester) async { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText()), + ], + ), + ); + + final documentLayoutKey = GlobalKey(debugLabel: "test_document-layout"); + final iOSControlsController = SuperEditorIosControlsControllerWithNativePaste( + editor: editor, + documentLayoutResolver: () => documentLayoutKey.currentState! as DocumentLayout, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditorIosControlsScope( + controller: iOSControlsController, + child: SuperEditor( + editor: editor, + documentLayoutKey: documentLayoutKey, + ), + ), + ), + ), + ); +} diff --git a/super_editor_markdown/CHANGELOG.md b/super_editor_markdown/CHANGELOG.md deleted file mode 100644 index 0dd4fac926..0000000000 --- a/super_editor_markdown/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -## [0.1.3] - July, 2022 - * Updated `AttributedText` serialization to use new `AttributionVisitor` API. - -## [0.1.2] - ~July, 2022~ (Removed from pub) - * Updated `AttributedText` serialization to use new `AttributionVisitor` API. - -## [0.1.1] - June, 2022 - * BREAKING: changed super_editor_markdown to NOT append newlines after every line it serializes to avoid extra newlines at the end of a serialized document. diff --git a/super_editor_markdown/README.md b/super_editor_markdown/README.md deleted file mode 100644 index 05f0581d41..0000000000 --- a/super_editor_markdown/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Super Editor Markdown -Markdown (de)serialization for `super_editor` documents. - diff --git a/super_editor_markdown/lib/src/document_to_markdown_serializer.dart b/super_editor_markdown/lib/src/document_to_markdown_serializer.dart deleted file mode 100644 index 8ab1fa0ab1..0000000000 --- a/super_editor_markdown/lib/src/document_to_markdown_serializer.dart +++ /dev/null @@ -1,348 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:super_editor/super_editor.dart'; - -import 'super_editor_syntax.dart'; - -/// Serializes the given [doc] to Markdown text. -/// -/// The given [syntax] controls how the [doc] is serialized, e.g., [MarkdownSyntax.normal] -/// for standard Markdown syntax, or [MarkdownSyntax.superEditor] to use Super Editor's -/// extended syntax. -/// -/// To serialize [DocumentNode]s that aren't part of Super Editor's standard serialization, -/// provide [customNodeSerializers] to serialize those custom nodes. -String serializeDocumentToMarkdown( - Document doc, { - MarkdownSyntax syntax = MarkdownSyntax.superEditor, - List customNodeSerializers = const [], -}) { - final nodeSerializers = [ - // Custom serializers first, in case the custom serializers handle - // specialized cases of traditional nodes, such as serializing a - // `ParagraphNode` with a special `"blockType"`. - ...customNodeSerializers, - const ImageNodeSerializer(), - const HorizontalRuleNodeSerializer(), - const ListItemNodeSerializer(), - ParagraphNodeSerializer(syntax), - ]; - - StringBuffer buffer = StringBuffer(); - - for (int i = 0; i < doc.nodes.length; ++i) { - if (i > 0) { - // Add a new line before every node, except the first node. - buffer.writeln(""); - } - - // Serialize the current node to markdown. - final node = doc.nodes[i]; - for (final serializer in nodeSerializers) { - final serialization = serializer.serialize(doc, node); - if (serialization != null) { - buffer.write(serialization); - break; - } - } - } - - return buffer.toString(); -} - -/// Serializes a given [DocumentNode] to a Markdown `String`. -abstract class DocumentNodeMarkdownSerializer { - String? serialize(Document document, DocumentNode node); -} - -/// A [DocumentNodeMarkdownSerializer] that automatically rejects any -/// [DocumentNode] that doesn't match the given [NodeType]. -/// -/// Use this base class to avoid repeating type checks across various -/// serializers. -abstract class NodeTypedDocumentNodeMarkdownSerializer implements DocumentNodeMarkdownSerializer { - const NodeTypedDocumentNodeMarkdownSerializer(); - - @override - String? serialize(Document document, DocumentNode node) { - if (node is! NodeType) { - return null; - } - - return doSerialization(document, node as NodeType); - } - - @protected - String doSerialization(Document document, NodeType node); -} - -/// [DocumentNodeMarkdownSerializer] for serializing [ImageNode]s as standard Markdown -/// images. -class ImageNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { - const ImageNodeSerializer(); - - @override - String doSerialization(Document document, ImageNode node) { - return '![${node.altText}](${node.imageUrl})'; - } -} - -/// [DocumentNodeMarkdownSerializer] for serializing [HorizontalRuleNode]s as standard -/// Markdown horizontal rules. -class HorizontalRuleNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { - const HorizontalRuleNodeSerializer(); - - @override - String doSerialization(Document document, HorizontalRuleNode node) { - return '---'; - } -} - -/// [DocumentNodeMarkdownSerializer] for serializing [ListItemNode]s as standard Markdown -/// list items. -/// -/// Includes support for ordered and unordered list items. -class ListItemNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { - const ListItemNodeSerializer(); - - @override - String doSerialization(Document document, ListItemNode node) { - final buffer = StringBuffer(); - - final indent = List.generate(node.indent + 1, (index) => ' ').join(''); - final symbol = node.type == ListItemType.unordered ? '*' : '1.'; - - buffer.write('$indent$symbol ${node.text.toMarkdown()}'); - - final nodeIndex = document.getNodeIndexById(node.id); - final nodeBelow = nodeIndex < document.nodes.length - 1 ? document.nodes[nodeIndex + 1] : null; - if (nodeBelow != null && (nodeBelow is! ListItemNode || nodeBelow.type != node.type)) { - // This list item is the last item in the list. Add an extra - // blank line after it. - buffer.writeln(''); - } - - return buffer.toString(); - } -} - -/// [DocumentNodeMarkdownSerializer] for serializing [ParagraphNode]s as standard Markdown -/// paragraphs. -/// -/// Includes support for headers, blockquotes, and code blocks. -class ParagraphNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer { - const ParagraphNodeSerializer(this.markdownSyntax); - - final MarkdownSyntax markdownSyntax; - - @override - String doSerialization(Document document, ParagraphNode node) { - final buffer = StringBuffer(); - - final Attribution? blockType = node.getMetadataValue('blockType'); - - if (blockType == header1Attribution) { - buffer.write('# ${node.text.toMarkdown()}'); - } else if (blockType == header2Attribution) { - buffer.write('## ${node.text.toMarkdown()}'); - } else if (blockType == header3Attribution) { - buffer.write('### ${node.text.toMarkdown()}'); - } else if (blockType == header4Attribution) { - buffer.write('#### ${node.text.toMarkdown()}'); - } else if (blockType == header5Attribution) { - buffer.write('##### ${node.text.toMarkdown()}'); - } else if (blockType == header6Attribution) { - buffer.write('###### ${node.text.toMarkdown()}'); - } else if (blockType == blockquoteAttribution) { - // TODO: handle multiline - buffer.write('> ${node.text.toMarkdown()}'); - } else if (blockType == codeAttribution) { - buffer // - ..writeln('```') // - ..writeln(node.text.toMarkdown()) // - ..write('```'); - } else { - final String? textAlign = node.getMetadataValue('textAlign'); - // Left alignment is the default, so there is no need to add the alignment token. - if (markdownSyntax == MarkdownSyntax.superEditor && textAlign != null && textAlign != 'left') { - final alignmentToken = _convertAlignmentToMarkdown(textAlign); - if (alignmentToken != null) { - buffer.writeln(alignmentToken); - } - } - buffer.write(node.text.toMarkdown()); - } - - // We're not at the end of the document yet. Add a blank line after the - // paragraph so that we can tell the difference between separate - // paragraphs vs. newlines within a single paragraph. - final nodeIndex = document.getNodeIndexById(node.id); - if (nodeIndex != document.nodes.length - 1) { - buffer.writeln(); - } - - return buffer.toString(); - } -} - -String? _convertAlignmentToMarkdown(String alignment) { - switch (alignment) { - case 'left': - return ':---'; - case 'center': - return ':---:'; - case 'right': - return '---:'; - default: - return null; - } -} - -/// Extension on [AttributedText] to serialize the [AttributedText] to a Markdown `String`. -extension Markdown on AttributedText { - String toMarkdown() { - final serializer = AttributedTextMarkdownSerializer(); - return serializer.serialize(this); - } -} - -/// Serializes an [AttributedText] into markdown format -class AttributedTextMarkdownSerializer extends AttributionVisitor { - late String _fullText; - late StringBuffer _buffer; - late int _bufferCursor; - - String serialize(AttributedText attributedText) { - _fullText = attributedText.text; - _buffer = StringBuffer(); - _bufferCursor = 0; - attributedText.visitAttributions(this); - return _buffer.toString(); - } - - @override - void visitAttributions( - AttributedText fullText, - int index, - Set startingAttributions, - Set endingAttributions, - ) { - // Write out the text between the end of the last markers, and these new markers. - _writeTextToBuffer( - fullText.text.substring(_bufferCursor, index), - ); - - // Add start markers. - if (startingAttributions.isNotEmpty) { - final markdownStyles = _sortAndSerializeAttributions(startingAttributions, AttributionVisitEvent.start); - // Links are different from the plain styles since they are both not NamedAttributions (and therefore - // can't be checked using equality comparison) and asymmetrical in markdown. - final linkMarker = _encodeLinkMarker(startingAttributions, AttributionVisitEvent.start); - - _buffer - ..write(linkMarker) - ..write(markdownStyles); - } - - // Write out the character at this index. - _writeTextToBuffer(_fullText[index]); - _bufferCursor = index + 1; - - // Add end markers. - if (endingAttributions.isNotEmpty) { - final markdownStyles = _sortAndSerializeAttributions(endingAttributions, AttributionVisitEvent.end); - // Links are different from the plain styles since they are both not NamedAttributions (and therefore - // can't be checked using equality comparison) and asymmetrical in markdown. - final linkMarker = _encodeLinkMarker(endingAttributions, AttributionVisitEvent.end); - - _buffer - ..write(markdownStyles) - ..write(linkMarker); - } - } - - @override - void onVisitEnd() { - // When the last span has no attributions, we still have text that wasn't added to the buffer yet. - if (_bufferCursor <= _fullText.length - 1) { - _writeTextToBuffer(_fullText.substring(_bufferCursor)); - } - } - - /// Writes the given [text] to [_buffer]. - /// - /// Separates multiple lines in a single paragraph using two spaces before each line break. - /// - /// A line ending with two or more spaces represents a hard line break, - /// as defined in the Markdown spec. - void _writeTextToBuffer(String text) { - final lines = text.split('\n'); - for (int i = 0; i < lines.length; i++) { - if (i > 0) { - // Adds two spaces before line breaks. - // The Markdown spec defines that a line ending with two or more spaces - // represents a hard line break, which causes the next line to be part of - // the previous paragraph during deserialization. - _buffer.write(' '); - _buffer.write('\n'); - } - - _buffer.write(lines[i]); - } - } - - /// Serializes style attributions into markdown syntax in a repeatable - /// order such that opening and closing styles match each other on - /// the opening and closing ends of a span. - static String _sortAndSerializeAttributions(Set attributions, AttributionVisitEvent event) { - const startOrder = [ - codeAttribution, - boldAttribution, - italicsAttribution, - strikethroughAttribution, - underlineAttribution, - ]; - - final buffer = StringBuffer(); - final encodingOrder = event == AttributionVisitEvent.start ? startOrder : startOrder.reversed; - - for (final markdownStyleAttribution in encodingOrder) { - if (attributions.contains(markdownStyleAttribution)) { - buffer.write(_encodeMarkdownStyle(markdownStyleAttribution)); - } - } - - return buffer.toString(); - } - - static String _encodeMarkdownStyle(Attribution attribution) { - if (attribution == codeAttribution) { - return '`'; - } else if (attribution == boldAttribution) { - return '**'; - } else if (attribution == italicsAttribution) { - return '*'; - } else if (attribution == strikethroughAttribution) { - return '~'; - } else if (attribution == underlineAttribution) { - return '¬'; - } else { - return ''; - } - } - - /// Checks for the presence of a link in the attributions and returns the characters necessary to represent it - /// at the open or closing boundary of the attribution, depending on the event. - static String _encodeLinkMarker(Set attributions, AttributionVisitEvent event) { - final linkAttributions = attributions.where((element) => element is LinkAttribution?); - if (linkAttributions.isNotEmpty) { - final linkAttribution = linkAttributions.first as LinkAttribution; - - if (event == AttributionVisitEvent.start) { - return '['; - } else { - return '](${linkAttribution.url.toString()})'; - } - } - return ""; - } -} diff --git a/super_editor_markdown/lib/src/markdown_to_document_parsing.dart b/super_editor_markdown/lib/src/markdown_to_document_parsing.dart deleted file mode 100644 index 1b8623dd8b..0000000000 --- a/super_editor_markdown/lib/src/markdown_to_document_parsing.dart +++ /dev/null @@ -1,656 +0,0 @@ -import 'dart:convert'; - -import 'package:markdown/markdown.dart' as md; -import 'package:super_editor/super_editor.dart'; - -import 'super_editor_syntax.dart'; - -/// Parses the given [markdown] and deserializes it into a [MutableDocument]. -/// -/// The given [syntax] controls how the [markdown] is parsed, e.g., [MarkdownSyntax.normal] -/// for strict Markdown parsing, or [MarkdownSyntax.superEditor] to use Super Editor's -/// extended syntax. -/// -/// To add support for parsing non-standard Markdown blocks, provide [customBlockSyntax]s -/// that parse Markdown text into [md.Element]s, and provide [customElementToNodeConverters] that -/// turn those [md.Element]s into [DocumentNode]s. -MutableDocument deserializeMarkdownToDocument( - String markdown, { - MarkdownSyntax syntax = MarkdownSyntax.superEditor, - List customBlockSyntax = const [], - List customElementToNodeConverters = const [], -}) { - final markdownLines = const LineSplitter().convert(markdown); - - final markdownDoc = md.Document( - blockSyntaxes: [ - ...customBlockSyntax, - if (syntax == MarkdownSyntax.superEditor) // - _ParagraphWithAlignmentSyntax(), - _EmptyLinePreservingParagraphSyntax(), - ], - ); - final blockParser = md.BlockParser(markdownLines, markdownDoc); - - // Parse markdown string to structured markdown. - final markdownNodes = blockParser.parseLines(); - - // Convert structured markdown to a Document. - final nodeVisitor = _MarkdownToDocument(customElementToNodeConverters); - for (final node in markdownNodes) { - node.accept(nodeVisitor); - } - - final documentNodes = nodeVisitor.content; - - if (documentNodes.isEmpty) { - // An empty markdown was parsed. - // For the user to be able to interact with the editor, at least one - // node is required, so we add an empty paragraph. - documentNodes.add( - ParagraphNode(id: DocumentEditor.createNodeId(), text: AttributedText(text: '')), - ); - } - - return MutableDocument(nodes: documentNodes); -} - -/// Converts structured markdown to a list of [DocumentNode]s. -/// -/// To use [_MarkdownToDocument], obtain a series of markdown -/// nodes from a [BlockParser] (from the markdown package) and -/// then visit each of the nodes with a [_MarkdownToDocument]. -/// After visiting all markdown nodes, [_MarkdownToDocument] -/// contains [DocumentNode]s that correspond to the visited -/// markdown content. -class _MarkdownToDocument implements md.NodeVisitor { - _MarkdownToDocument([this._elementToNodeConverters = const []]); - - final List _elementToNodeConverters; - - final _content = []; - List get content => _content; - - final _listItemTypeStack = []; - - @override - bool visitElementBefore(md.Element element) { - for (final converter in _elementToNodeConverters) { - final node = converter.handleElement(element); - if (node != null) { - _content.add(node); - return true; - } - } - - // TODO: re-organize parsing such that visitElementBefore collects - // the block type info and then visitText and visitElementAfter - // take the action to create the node (#153) - switch (element.tag) { - case 'h1': - _addHeader(element, level: 1); - break; - case 'h2': - _addHeader(element, level: 2); - break; - case 'h3': - _addHeader(element, level: 3); - break; - case 'h4': - _addHeader(element, level: 4); - break; - case 'h5': - _addHeader(element, level: 5); - break; - case 'h6': - _addHeader(element, level: 6); - break; - case 'p': - final inlineVisitor = _parseInline(element); - - if (inlineVisitor.isImage) { - _addImage( - // TODO: handle null image URL - imageUrl: inlineVisitor.imageUrl!, - altText: inlineVisitor.imageAltText!, - ); - } else { - _addParagraph(inlineVisitor.attributedText, element.attributes); - } - break; - case 'blockquote': - _addBlockquote(element); - - // Skip child elements within a blockquote so that we don't - // add another node for the paragraph that comprises the blockquote - return false; - case 'code': - _addCodeBlock(element); - break; - case 'ul': - // A list just started. Push that list type on top of the list type stack. - _listItemTypeStack.add(ListItemType.unordered); - break; - case 'ol': - // A list just started. Push that list type on top of the list type stack. - _listItemTypeStack.add(ListItemType.ordered); - break; - case 'li': - if (_listItemTypeStack.isEmpty) { - throw Exception('Tried to parse a markdown list item but the list item type was null'); - } - - _addListItem( - element, - listItemType: _listItemTypeStack.last, - indent: _listItemTypeStack.length - 1, - ); - break; - case 'hr': - _addHorizontalRule(); - break; - } - - return true; - } - - @override - void visitElementAfter(md.Element element) { - switch (element.tag) { - // A list has ended. Pop the most recent list type from the stack. - case 'ul': - case 'ol': - _listItemTypeStack.removeLast(); - break; - } - } - - @override - void visitText(md.Text text) { - // no-op: this visitor is block-level only - } - - void _addHeader(md.Element element, {required int level}) { - Attribution? headerAttribution; - switch (level) { - case 1: - headerAttribution = header1Attribution; - break; - case 2: - headerAttribution = header2Attribution; - break; - case 3: - headerAttribution = header3Attribution; - break; - case 4: - headerAttribution = header4Attribution; - break; - case 5: - headerAttribution = header5Attribution; - break; - case 6: - headerAttribution = header6Attribution; - break; - } - - _content.add( - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: _parseInlineText(element), - metadata: { - 'blockType': headerAttribution, - }, - ), - ); - } - - void _addParagraph(AttributedText attributedText, Map attributes) { - final textAlign = attributes['textAlign']; - - _content.add( - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: attributedText, - metadata: { - 'textAlign': textAlign != null ? textAlign : null, - }, - ), - ); - } - - void _addBlockquote(md.Element element) { - _content.add( - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: _parseInlineText(element), - metadata: { - 'blockType': blockquoteAttribution, - }, - ), - ); - } - - void _addCodeBlock(md.Element element) { - // TODO: we may need to replace escape characters with literals here - // CodeSampleNode( - // code: element.textContent // - // .replaceAll('<', '<') // - // .replaceAll('>', '>') // - // .trim(), - // ), - - _content.add( - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: element.textContent, - ), - metadata: { - 'blockType': codeAttribution, - }, - ), - ); - } - - void _addImage({ - required String imageUrl, - required String altText, - }) { - _content.add( - ImageNode( - id: DocumentEditor.createNodeId(), - imageUrl: imageUrl, - altText: altText, - ), - ); - } - - void _addHorizontalRule() { - _content.add(HorizontalRuleNode( - id: DocumentEditor.createNodeId(), - )); - } - - void _addListItem( - md.Element element, { - required ListItemType listItemType, - required int indent, - }) { - _content.add( - ListItemNode( - id: DocumentEditor.createNodeId(), - itemType: listItemType, - indent: indent, - text: _parseInlineText(element), - ), - ); - } - - AttributedText _parseInlineText(md.Element element) { - final inlineVisitor = _parseInline(element); - return inlineVisitor.attributedText; - } - - _InlineMarkdownToDocument _parseInline(md.Element element) { - final inlineParser = md.InlineParser( - element.textContent, - md.Document( - inlineSyntaxes: [ - md.StrikethroughSyntax(), - UnderlineSyntax(), - ], - ), - ); - final inlineVisitor = _InlineMarkdownToDocument(); - final inlineNodes = inlineParser.parse(); - for (final inlineNode in inlineNodes) { - inlineNode.accept(inlineVisitor); - } - return inlineVisitor; - } -} - -/// Parses inline markdown content. -/// -/// Apply [_InlineMarkdownToDocument] to a text [Element] to -/// obtain an [AttributedText] that represents the inline -/// styles within the given text. -/// -/// Apply [_InlineMarkdownToDocument] to an [Element] whose -/// content is an image tag to obtain image data. -/// -/// [_InlineMarkdownToDocument] does not support parsing text -/// that contains image tags. If any non-image text is found, -/// the content is treated as styled text. -class _InlineMarkdownToDocument implements md.NodeVisitor { - _InlineMarkdownToDocument(); - - // For our purposes, we only support block-level images. Therefore, - // if we find an image without any text, we're parsing an image. - // Otherwise, if there is any text, then we're parsing a paragraph - // and we ignore the image. - bool get isImage => _imageUrl != null && attributedText.text.isEmpty; - - String? _imageUrl; - String? get imageUrl => _imageUrl; - - String? _imageAltText; - String? get imageAltText => _imageAltText; - - AttributedText get attributedText => _textStack.first; - - final List _textStack = [AttributedText()]; - - @override - bool visitElementBefore(md.Element element) { - if (element.tag == 'img') { - // TODO: handle missing "src" attribute - _imageUrl = element.attributes['src']!; - _imageAltText = element.attributes['alt'] ?? ''; - return true; - } - - _textStack.add(AttributedText()); - - return true; - } - - @override - void visitText(md.Text text) { - final attributedText = _textStack.removeLast(); - _textStack.add(attributedText.copyAndAppend(AttributedText(text: text.text))); - } - - @override - void visitElementAfter(md.Element element) { - // Reset to normal text style because a plain text element does - // not receive a call to visitElementBefore(). - final styledText = _textStack.removeLast(); - - if (element.tag == 'strong') { - styledText.addAttribution( - boldAttribution, - SpanRange( - start: 0, - end: styledText.text.length - 1, - ), - ); - } else if (element.tag == 'em') { - styledText.addAttribution( - italicsAttribution, - SpanRange( - start: 0, - end: styledText.text.length - 1, - ), - ); - } else if (element.tag == "del") { - styledText.addAttribution( - strikethroughAttribution, - SpanRange( - start: 0, - end: styledText.text.length - 1, - ), - ); - } else if (element.tag == "u") { - styledText.addAttribution( - underlineAttribution, - SpanRange( - start: 0, - end: styledText.text.length - 1, - ), - ); - } else if (element.tag == 'a') { - styledText.addAttribution( - LinkAttribution(url: Uri.parse(element.attributes['href']!)), - SpanRange( - start: 0, - end: styledText.text.length - 1, - ), - ); - } - - if (_textStack.isNotEmpty) { - final surroundingText = _textStack.removeLast(); - _textStack.add(surroundingText.copyAndAppend(styledText)); - } else { - _textStack.add(styledText); - } - } -} - -/// Converts a deserialized Markdown element into a [DocumentNode]. -/// -/// For example, the Markdown parser might identify an element called -/// "blockquote". A corresponding [ElementToNodeConverter] would receive -/// the "blockquote" element and create an appropriate [ParagraphNode] to -/// represent that blockquote in the deserialized [Document]. -abstract class ElementToNodeConverter { - DocumentNode? handleElement(md.Element element); -} - -/// A Markdown [TagSyntax] that matches underline spans of text, which are represented in -/// Markdown with surrounding `¬` tags, e.g., "this is ¬underline¬ text". -/// -/// This [TagSyntax] produces `Element`s with a `u` tag. -class UnderlineSyntax extends md.TagSyntax { - UnderlineSyntax() : super('¬', requiresDelimiterRun: true, allowIntraWord: true); - - @override - md.Node close(md.InlineParser parser, md.Delimiter opener, md.Delimiter closer, - {required List Function() getChildren}) { - return md.Element('u', getChildren()); - } -} - -/// Parses a paragraph preceded by an alignment token. -class _ParagraphWithAlignmentSyntax extends _EmptyLinePreservingParagraphSyntax { - /// This pattern matches the text aligment notation. - /// - /// Possible values are `:---`, `:---:` and `---:` - static final _alignmentNotationPattern = RegExp(r'^:-{3}|:-{3}:|-{3}:$'); - - const _ParagraphWithAlignmentSyntax(); - - @override - bool canParse(md.BlockParser parser) { - if (!_alignmentNotationPattern.hasMatch(parser.current)) { - return false; - } - - final nextLine = parser.peek(1); - - // We found a match for a paragraph alignment token. However, the alignment token is the last - // line of content in the document. Therefore, it's not really a paragraph alignment token, and we - // should treat it as regular content. - if (nextLine == null) { - return false; - } - - /// We found a paragraph alignment token, but the block after the alignment token isn't a paragraph. - /// Therefore, the paragraph alignment token is actually regular content. This parser doesn't need to - /// take any action. - if (_standardNonParagraphBlockSyntaxes.any((syntax) => syntax.pattern.hasMatch(nextLine))) { - return false; - } - - // We found a paragraph alignment token, followed by a paragraph. Therefore, this parser should - // parse the given content. - return true; - } - - @override - md.Node? parse(md.BlockParser parser) { - final match = _alignmentNotationPattern.firstMatch(parser.current); - - // We've parsed the alignment token on the current line. We know a paragraph starts on the - // next line. Move the parser to the next line so that we can parse the paragraph. - parser.advance(); - - // Parse the paragraph using the standard Markdown paragraph parser. - final paragraph = super.parse(parser); - - if (paragraph is md.Element) { - paragraph.attributes.addAll({'textAlign': _convertMarkdownAlignmentTokenToSuperEditorAlignment(match!.input)}); - } - - return paragraph; - } - - /// Converts a markdown alignment token to the textAlign metadata used to configure - /// the [ParagraphNode] alignment. - String _convertMarkdownAlignmentTokenToSuperEditorAlignment(String alignmentToken) { - switch (alignmentToken) { - case ':---': - return 'left'; - case ':---:': - return 'center'; - case '---:': - return 'right'; - // As we already check that the input matches the notation, - // we shouldn't reach this point. - default: - return 'left'; - } - } -} - -/// A [BlockSyntax] that parses paragraphs. -/// -/// Allows empty paragraphs and paragraphs containing blank lines. -class _EmptyLinePreservingParagraphSyntax extends md.BlockSyntax { - const _EmptyLinePreservingParagraphSyntax(); - - @override - RegExp get pattern => RegExp(''); - - @override - bool canEndBlock(md.BlockParser parser) => false; - - @override - bool canParse(md.BlockParser parser) => !_standardNonParagraphBlockSyntaxes.any((e) => e.canParse(parser)); - - @override - md.Node? parse(md.BlockParser parser) { - final childLines = []; - final startsWithEmptyLine = parser.current.isEmpty; - - // A hard line break causes the next line to be treated - // as part of the same paragraph, except if the next line is - // the beginning of another block element. - bool hasHardLineBreak = _endsWithHardLineBreak(parser.current); - - if (startsWithEmptyLine) { - // The parser started at an empty line. - // Consume the line as a separator between blocks. - parser.advance(); - - if (parser.isDone) { - // The document ended with a single empty line, so we just ignore it. - // To be considered as a paragraph starting with an empty line - // we need at least two empty lines: - // one to separate the paragraph from the previous block - // and another one to be the content of the paragraph. - return null; - } - - if (!_blankLinePattern.hasMatch(parser.current)) { - // We found an empty line, but the following line isn't blank. - // As there is no hard line break, the first line is consumed - // as a separator between blocks. - // Therefore, we aren't looking at a paragraph with blank lines. - return null; - } - - // We found a paragraph, and the first line of that paragraph is empty. Add a - // corresponding empty line to the parsed version of the paragraph. - childLines.add(''); - - // Check for a hard line break, so we consume the next line if we found one. - hasHardLineBreak = _endsWithHardLineBreak(parser.current); - parser.advance(); - } - - // Consume everything until another block element is found. - // A line break will cause the parser to stop, unless the preceding line - // ends with a hard line break. - while (!_isAtParagraphEnd(parser, ignoreEmptyBlocks: hasHardLineBreak)) { - final currentLine = parser.current; - childLines.add(currentLine); - - hasHardLineBreak = _endsWithHardLineBreak(currentLine); - - parser.advance(); - } - - // We already started looking at a different block element. - // Let another syntax parse it. - if (childLines.isEmpty) { - return null; - } - - // Remove trailing whitespace from each line of the parsed paragraph - // and join them into a single string, separated by a line breaks. - final contents = md.UnparsedContent(childLines.map((e) => _removeTrailingSpaces(e)).join('\n')); - return _LineBreakSeparatedElement('p', [contents]); - } - - /// Checks if the current line ends a paragraph by verifying if another - /// block syntax can parse the current input. - /// - /// An empty line ends the paragraph, unless [ignoreEmptyBlocks] is `true`. - bool _isAtParagraphEnd(md.BlockParser parser, {required bool ignoreEmptyBlocks}) { - if (parser.isDone) { - return true; - } - for (final syntax in parser.blockSyntaxes) { - if (!(syntax is md.EmptyBlockSyntax && ignoreEmptyBlocks) && - syntax.canParse(parser) && - syntax.canEndBlock(parser)) { - return true; - } - } - return false; - } - - /// Removes all whitespace characters except `"\n"`. - String _removeTrailingSpaces(String text) { - final pattern = RegExp(r'[\t ]+$'); - return text.replaceAll(pattern, ''); - } - - /// Returns `true` if [line] ends with a hard line break. - /// - /// As per the Markdown spec, a line ending with two or more spaces - /// represents a hard line break. - /// - /// A hard line break causes the next line to be part of the - /// same paragraph, except if it's the beginning of another block element. - bool _endsWithHardLineBreak(String line) { - return line.endsWith(' '); - } -} - -/// An [Element] that preserves line breaks. -/// -/// The default [Element] implementation ignores all line breaks. -class _LineBreakSeparatedElement extends md.Element { - _LineBreakSeparatedElement(String tag, List? children) : super(tag, children); - - @override - String get textContent { - return (children ?? []).map((md.Node? child) => child!.textContent).join('\n'); - } -} - -/// Matches empty lines or lines containing only whitespace. -final _blankLinePattern = RegExp(r'^(?:[ \t]*)$'); - -const List _standardNonParagraphBlockSyntaxes = [ - md.HeaderSyntax(), - md.CodeBlockSyntax(), - md.FencedCodeBlockSyntax(), - md.BlockquoteSyntax(), - md.HorizontalRuleSyntax(), - md.UnorderedListSyntax(), - md.OrderedListSyntax(), -]; diff --git a/super_editor_markdown/lib/super_editor_markdown.dart b/super_editor_markdown/lib/super_editor_markdown.dart deleted file mode 100644 index a2c7e3b299..0000000000 --- a/super_editor_markdown/lib/super_editor_markdown.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'src/document_to_markdown_serializer.dart'; -export 'src/markdown_to_document_parsing.dart'; -export 'src/super_editor_syntax.dart'; diff --git a/super_editor_markdown/pubspec.yaml b/super_editor_markdown/pubspec.yaml deleted file mode 100644 index 28193c8b72..0000000000 --- a/super_editor_markdown/pubspec.yaml +++ /dev/null @@ -1,36 +0,0 @@ -name: super_editor_markdown -description: Markdown (de)serialization for super_editor documents. -version: 0.1.3 -homepage: https://github.com/superlistapp/super_editor - -environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - - super_editor: ^0.2.2 - logging: ^1.0.1 - markdown: ^4.0.0 - -dependency_overrides: - # Override to local mono-repo path so devs can test this repo - # against changes that they're making to other mono-repo packages - super_editor: - path: ../super_editor - super_text_layout: - path: ../super_text_layout - attributed_text: - path: ../attributed_text - -dev_dependencies: - # TODO: upgrade lints to 2.0.1 when we release super_editor 0.2.1 - flutter_lints: ^1.0.0 - flutter_test: - sdk: flutter - golden_toolkit: ^0.11.0 - -flutter: - # no Flutter configuration diff --git a/super_editor_markdown/test/attributed_text_markdown_test.dart b/super_editor_markdown/test/attributed_text_markdown_test.dart deleted file mode 100644 index 2a5541a883..0000000000 --- a/super_editor_markdown/test/attributed_text_markdown_test.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_editor_markdown/src/document_to_markdown_serializer.dart'; - -void main() { - group("AttributedText markdown serializes", () { - test("un-styled text", () { - expect( - AttributedText(text: "This is unstyled text.").toMarkdown(), - "This is unstyled text.", - ); - }); - - test("single character styles", () { - expect( - AttributedText( - text: "This is single character styles.", - spans: AttributedSpans( - attributions: [ - SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.start), - SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - SpanMarker(attribution: italicsAttribution, offset: 23, markerType: SpanMarkerType.start), - SpanMarker(attribution: italicsAttribution, offset: 23, markerType: SpanMarkerType.end), - ], - ), - ).toMarkdown(), - "This is **s**ingle characte*r* styles.", - ); - }); - - test("bold text", () { - expect( - AttributedText( - text: "This is bold text.", - spans: AttributedSpans( - attributions: [ - SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.start), - SpanMarker(attribution: boldAttribution, offset: 11, markerType: SpanMarkerType.end), - ], - ), - ).toMarkdown(), - "This is **bold** text.", - ); - }); - - test("italics text", () { - expect( - AttributedText( - text: "This is italics text.", - spans: AttributedSpans( - attributions: [ - SpanMarker(attribution: italicsAttribution, offset: 8, markerType: SpanMarkerType.start), - SpanMarker(attribution: italicsAttribution, offset: 14, markerType: SpanMarkerType.end), - ], - ), - ).toMarkdown(), - "This is *italics* text.", - ); - }); - - test("multiple styles across the same span", () { - expect( - AttributedText( - text: "This is multiple styled text.", - spans: AttributedSpans( - attributions: [ - SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.start), - SpanMarker(attribution: boldAttribution, offset: 22, markerType: SpanMarkerType.end), - SpanMarker(attribution: italicsAttribution, offset: 8, markerType: SpanMarkerType.start), - SpanMarker(attribution: italicsAttribution, offset: 22, markerType: SpanMarkerType.end), - ], - ), - ).toMarkdown(), - "This is ***multiple styled*** text.", - ); - }); - - test("partially overlapping styles", () { - expect( - AttributedText( - text: "This is overlapping styles.", - spans: AttributedSpans( - attributions: [ - SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.start), - SpanMarker(attribution: boldAttribution, offset: 13, markerType: SpanMarkerType.end), - SpanMarker(attribution: italicsAttribution, offset: 11, markerType: SpanMarkerType.start), - SpanMarker(attribution: italicsAttribution, offset: 18, markerType: SpanMarkerType.end), - ], - ), - ).toMarkdown(), - "This is **ove*rla**pping* styles.", - ); - }); - }); -} diff --git a/super_editor_markdown/test/custom_block_serializer_test.dart b/super_editor_markdown/test/custom_block_serializer_test.dart deleted file mode 100644 index 4b83e041b0..0000000000 --- a/super_editor_markdown/test/custom_block_serializer_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor/super_editor.dart'; -import 'package:super_editor_markdown/super_editor_markdown.dart'; - -import 'custom_parsers/callout_block.dart'; -import 'custom_parsers/upsell_block.dart'; - -void main() { - group("Markdown serialization", () { - test("handles custom placeholder block node", () { - final markdown = serializeDocumentToMarkdown( - MutableDocument( - nodes: [ - ParagraphNode(id: DocumentEditor.createNodeId(), text: AttributedText(text: "Paragraph 1")), - UpsellNode(DocumentEditor.createNodeId()), - ParagraphNode(id: DocumentEditor.createNodeId(), text: AttributedText(text: "Paragraph 2")), - ], - ), - customNodeSerializers: [UpsellSerializer()], - ); - - expect( - markdown, - '''Paragraph 1 - -@@@ upsell - -Paragraph 2''', - ); - }); - - test("handles custom text node", () { - final markdown = serializeDocumentToMarkdown( - MutableDocument( - nodes: [ - ParagraphNode(id: DocumentEditor.createNodeId(), text: AttributedText(text: "Paragraph 1")), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText( - text: "This is a callout!", - spans: AttributedSpans( - attributions: [ - SpanMarker(attribution: boldAttribution, offset: 10, markerType: SpanMarkerType.start), - SpanMarker(attribution: boldAttribution, offset: 17, markerType: SpanMarkerType.end), - ], - ), - ), - metadata: {"blockType": const NamedAttribution("callout")}, - ), - ParagraphNode(id: DocumentEditor.createNodeId(), text: AttributedText(text: "Paragraph 2")), - ], - ), - customNodeSerializers: [CalloutSerializer()], - ); - - expect( - markdown, - '''Paragraph 1 - -@@@ callout -This is a **callout!** -@@@ - -Paragraph 2''', - ); - }); - }); -} diff --git a/super_editor_markdown/test/super_editor_markdown_test.dart b/super_editor_markdown/test/super_editor_markdown_test.dart deleted file mode 100644 index a7ba22b72f..0000000000 --- a/super_editor_markdown/test/super_editor_markdown_test.dart +++ /dev/null @@ -1,1171 +0,0 @@ -import 'package:super_editor/super_editor.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:super_editor_markdown/super_editor_markdown.dart'; - -void main() { - group('Markdown', () { - group('serialization', () { - test('headers', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'My Header'), - ), - ]); - - (doc.nodes[0] as ParagraphNode).putMetadataValue('blockType', header1Attribution); - expect(serializeDocumentToMarkdown(doc), '# My Header'); - - (doc.nodes[0] as ParagraphNode).putMetadataValue('blockType', header2Attribution); - expect(serializeDocumentToMarkdown(doc), '## My Header'); - - (doc.nodes[0] as ParagraphNode).putMetadataValue('blockType', header3Attribution); - expect(serializeDocumentToMarkdown(doc), '### My Header'); - - (doc.nodes[0] as ParagraphNode).putMetadataValue('blockType', header4Attribution); - expect(serializeDocumentToMarkdown(doc), '#### My Header'); - - (doc.nodes[0] as ParagraphNode).putMetadataValue('blockType', header5Attribution); - expect(serializeDocumentToMarkdown(doc), '##### My Header'); - - (doc.nodes[0] as ParagraphNode).putMetadataValue('blockType', header6Attribution); - expect(serializeDocumentToMarkdown(doc), '###### My Header'); - }); - - test('header with styles', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'My Header', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 3, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - ], - ), - ), - metadata: {'blockType': header1Attribution}, - ), - ]); - - expect(serializeDocumentToMarkdown(doc), '# My **Header**'); - }); - - test('blockquote', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'This is a blockquote'), - metadata: {'blockType': blockquoteAttribution}, - ), - ]); - - expect(serializeDocumentToMarkdown(doc), '> This is a blockquote'); - }); - - test('blockquote with styles', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a blockquote', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 10, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 19, markerType: SpanMarkerType.end), - ], - ), - ), - metadata: {'blockType': blockquoteAttribution}, - ), - ]); - - expect(serializeDocumentToMarkdown(doc), '> This is a **blockquote**'); - }); - - test('code', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'This is some code'), - metadata: {'blockType': codeAttribution}, - ), - ]); - - expect( - serializeDocumentToMarkdown(doc), - ''' -``` -This is some code -```''', - ); - }); - - test('paragraph', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'This is a paragraph.'), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), 'This is a paragraph.'); - }); - - test('paragraph with one inline style', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a paragraph.', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 5, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), 'This **is a** paragraph.'); - }); - - test('paragraph with overlapping bold and italics', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a paragraph.', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 5, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - const SpanMarker(attribution: italicsAttribution, offset: 5, markerType: SpanMarkerType.start), - const SpanMarker(attribution: italicsAttribution, offset: 8, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), 'This ***is a*** paragraph.'); - }); - - test('paragraph with non-overlapping bold and italics', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a paragraph.', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 6, markerType: SpanMarkerType.end), - const SpanMarker(attribution: italicsAttribution, offset: 8, markerType: SpanMarkerType.start), - const SpanMarker(attribution: italicsAttribution, offset: 19, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), '**This is** *a paragraph.*'); - }); - - test('paragraph with intersecting bold and italics', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a paragraph.', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 5, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - const SpanMarker(attribution: italicsAttribution, offset: 5, markerType: SpanMarkerType.start), - const SpanMarker(attribution: italicsAttribution, offset: 18, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), 'This ***is a** paragraph*.'); - }); - - test('paragraph with overlapping code and bold', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a paragraph.', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 5, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - const SpanMarker(attribution: codeAttribution, offset: 5, markerType: SpanMarkerType.start), - const SpanMarker(attribution: codeAttribution, offset: 8, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), 'This `**is a**` paragraph.'); - }); - - test('paragraph with link', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a paragraph.', - spans: AttributedSpans( - attributions: [ - SpanMarker( - attribution: LinkAttribution(url: Uri.https('example.org', '')), - offset: 10, - markerType: SpanMarkerType.start), - SpanMarker( - attribution: LinkAttribution(url: Uri.https('example.org', '')), - offset: 18, - markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), 'This is a [paragraph](https://example.org).'); - }); - - test('paragraph with link overlapping style', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a paragraph.', - spans: AttributedSpans( - attributions: [ - SpanMarker( - attribution: LinkAttribution(url: Uri.https('example.org', '')), - offset: 10, - markerType: SpanMarkerType.start), - SpanMarker( - attribution: LinkAttribution(url: Uri.https('example.org', '')), - offset: 18, - markerType: SpanMarkerType.end), - const SpanMarker(attribution: boldAttribution, offset: 10, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 18, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), 'This is a [**paragraph**](https://example.org).'); - }); - - test('paragraph with link intersecting style', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a paragraph.', - spans: AttributedSpans( - attributions: [ - SpanMarker( - attribution: LinkAttribution(url: Uri.https('example.org', '')), - offset: 0, - markerType: SpanMarkerType.start), - SpanMarker( - attribution: LinkAttribution(url: Uri.https('example.org', '')), - offset: 18, - markerType: SpanMarkerType.end), - const SpanMarker(attribution: boldAttribution, offset: 5, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), '[This **is a** paragraph](https://example.org).'); - }); - - test('paragraph with underline', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a paragraph.', - spans: AttributedSpans( - attributions: [ - SpanMarker(attribution: underlineAttribution, offset: 10, markerType: SpanMarkerType.start), - SpanMarker(attribution: underlineAttribution, offset: 18, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), 'This is a ¬paragraph¬.'); - }); - - test('paragraph with strikethrough', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'This is a paragraph.', - spans: AttributedSpans( - attributions: [ - SpanMarker(attribution: strikethroughAttribution, offset: 10, markerType: SpanMarkerType.start), - SpanMarker(attribution: strikethroughAttribution, offset: 18, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), 'This is a ~paragraph~.'); - }); - - test('paragraph with consecutive links', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText( - text: 'First LinkSecond Link', - spans: AttributedSpans( - attributions: [ - SpanMarker(attribution: LinkAttribution(url: Uri.https('example.org', '')), offset: 0, markerType: SpanMarkerType.start), - SpanMarker(attribution: LinkAttribution(url: Uri.https('example.org', '')), offset: 9, markerType: SpanMarkerType.end), - SpanMarker(attribution: LinkAttribution(url: Uri.https('github.com', '')), offset: 10, markerType: SpanMarkerType.start), - SpanMarker(attribution: LinkAttribution(url: Uri.https('github.com', '')), offset: 20, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), '[First Link](https://example.org)[Second Link](https://github.com)'); - }); - - test('paragraph with left alignment', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Paragraph1'), - metadata: { - 'textAlign': 'left', - }, - ), - ]); - - // Even when using superEditor markdown syntax, which has support - // for text alignment, we don't add an alignment token when - // the paragraph is left-aligned. - // Paragraphs are left-aligned by default, so it isn't necessary - // to serialize the alignment token. - expect(serializeDocumentToMarkdown(doc), 'Paragraph1'); - }); - - test('paragraph with center alignment', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Paragraph1'), - metadata: { - 'textAlign': 'center', - }, - ), - ]); - - expect(serializeDocumentToMarkdown(doc), ':---:\nParagraph1'); - }); - - test('paragraph with right alignment', () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Paragraph1'), - metadata: { - 'textAlign': 'right', - }, - ), - ]); - - expect(serializeDocumentToMarkdown(doc), '---:\nParagraph1'); - }); - - test("doesn't serialize text alignment when not using supereditor syntax", () { - final doc = MutableDocument(nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Paragraph1'), - metadata: { - 'textAlign': 'center', - }, - ), - ]); - - expect(serializeDocumentToMarkdown(doc, syntax: MarkdownSyntax.normal), 'Paragraph1'); - }); - - test('empty paragraph', () { - final serialized = serializeDocumentToMarkdown( - MutableDocument(nodes: [ - ParagraphNode(id: '1', text: AttributedText(text: 'Paragraph1')), - ParagraphNode(id: '2', text: AttributedText(text: '')), - ParagraphNode(id: '3', text: AttributedText(text: 'Paragraph3')), - ]), - ); - - expect(serialized, """Paragraph1 - - - -Paragraph3"""); - }); - - test('separates multiple paragraphs with blank lines', () { - final serialized = serializeDocumentToMarkdown( - MutableDocument(nodes: [ - ParagraphNode(id: '1', text: AttributedText(text: 'Paragraph1')), - ParagraphNode(id: '2', text: AttributedText(text: 'Paragraph2')), - ParagraphNode(id: '3', text: AttributedText(text: 'Paragraph3')), - ]), - ); - - expect(serialized, """Paragraph1 - -Paragraph2 - -Paragraph3"""); - }); - - test('separates paragraph from other blocks with blank lines', () { - final serialized = serializeDocumentToMarkdown( - MutableDocument(nodes: [ - ParagraphNode(id: '1', text: AttributedText(text: 'First Paragraph')), - HorizontalRuleNode(id: '2'), - ]), - ); - - expect(serialized, 'First Paragraph\n\n---'); - }); - - test('preserves linebreaks at the end of a paragraph', () { - final serialized = serializeDocumentToMarkdown( - MutableDocument(nodes: [ - ParagraphNode(id: '1', text: AttributedText(text: 'Paragraph1\n\n')), - ParagraphNode(id: '2', text: AttributedText(text: 'Paragraph2')), - ]), - ); - - expect(serialized, 'Paragraph1 \n \n\n\nParagraph2'); - }); - - test('preserves linebreaks within a paragraph', () { - final serialized = serializeDocumentToMarkdown( - MutableDocument(nodes: [ - ParagraphNode(id: '1', text: AttributedText(text: 'Line1\n\nLine2')), - ]), - ); - - expect(serialized, 'Line1 \n \nLine2'); - }); - - test('preserves linebreaks at the beginning of a paragraph', () { - final serialized = serializeDocumentToMarkdown( - MutableDocument(nodes: [ - ParagraphNode(id: '1', text: AttributedText(text: '\n\nParagraph1')), - ParagraphNode(id: '2', text: AttributedText(text: 'Paragraph2')), - ]), - ); - - expect(serialized, ' \n \nParagraph1\n\nParagraph2'); - }); - - test('image', () { - final doc = MutableDocument(nodes: [ - ImageNode( - id: '1', - imageUrl: 'https://someimage.com/the/image.png', - altText: 'some alt text', - ), - ]); - - expect(serializeDocumentToMarkdown(doc), '![some alt text](https://someimage.com/the/image.png)'); - }); - - test('horizontal rule', () { - final doc = MutableDocument(nodes: [ - HorizontalRuleNode( - id: '1', - ), - ]); - - expect(serializeDocumentToMarkdown(doc), '---'); - }); - - test('unordered list items', () { - final doc = MutableDocument(nodes: [ - ListItemNode( - id: '1', - itemType: ListItemType.unordered, - text: AttributedText(text: 'Unordered 1'), - ), - ListItemNode( - id: '2', - itemType: ListItemType.unordered, - text: AttributedText(text: 'Unordered 2'), - ), - ListItemNode( - id: '3', - itemType: ListItemType.unordered, - indent: 1, - text: AttributedText(text: 'Unordered 2.1'), - ), - ListItemNode( - id: '4', - itemType: ListItemType.unordered, - indent: 1, - text: AttributedText(text: 'Unordered 2.2'), - ), - ListItemNode( - id: '5', - itemType: ListItemType.unordered, - text: AttributedText(text: 'Unordered 3'), - ), - ]); - - expect( - serializeDocumentToMarkdown(doc), - ''' - * Unordered 1 - * Unordered 2 - * Unordered 2.1 - * Unordered 2.2 - * Unordered 3''', - ); - }); - - test('unordered list item with styles', () { - final doc = MutableDocument(nodes: [ - ListItemNode( - id: '1', - itemType: ListItemType.unordered, - text: AttributedText( - text: 'Unordered 1', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), ' * **Unordered** 1'); - }); - - test('ordered list items', () { - final doc = MutableDocument(nodes: [ - ListItemNode( - id: '1', - itemType: ListItemType.ordered, - text: AttributedText(text: 'Ordered 1'), - ), - ListItemNode( - id: '2', - itemType: ListItemType.ordered, - text: AttributedText(text: 'Ordered 2'), - ), - ListItemNode( - id: '3', - itemType: ListItemType.ordered, - indent: 1, - text: AttributedText(text: 'Ordered 2.1'), - ), - ListItemNode( - id: '4', - itemType: ListItemType.ordered, - indent: 1, - text: AttributedText(text: 'Ordered 2.2'), - ), - ListItemNode( - id: '5', - itemType: ListItemType.ordered, - text: AttributedText(text: 'Ordered 3'), - ), - ]); - - expect( - serializeDocumentToMarkdown(doc), - ''' - 1. Ordered 1 - 1. Ordered 2 - 1. Ordered 2.1 - 1. Ordered 2.2 - 1. Ordered 3''', - ); - }); - - test('ordered list item with styles', () { - final doc = MutableDocument(nodes: [ - ListItemNode( - id: '1', - itemType: ListItemType.ordered, - text: AttributedText( - text: 'Ordered 1', - spans: AttributedSpans( - attributions: [ - const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), - const SpanMarker(attribution: boldAttribution, offset: 6, markerType: SpanMarkerType.end), - ], - ), - ), - ), - ]); - - expect(serializeDocumentToMarkdown(doc), ' 1. **Ordered** 1'); - }); - - test('example doc', () { - final doc = MutableDocument(nodes: [ - ImageNode( - id: DocumentEditor.createNodeId(), - imageUrl: 'https://someimage.com/the/image.png', - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Example Doc'), - metadata: {'blockType': header1Attribution}, - ), - HorizontalRuleNode(id: DocumentEditor.createNodeId()), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Unordered list:'), - ), - ListItemNode( - id: DocumentEditor.createNodeId(), - itemType: ListItemType.unordered, - text: AttributedText(text: 'Unordered 1'), - ), - ListItemNode( - id: DocumentEditor.createNodeId(), - itemType: ListItemType.unordered, - text: AttributedText(text: 'Unordered 2'), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Ordered list:'), - ), - ListItemNode( - id: DocumentEditor.createNodeId(), - itemType: ListItemType.ordered, - text: AttributedText(text: 'Ordered 1'), - ), - ListItemNode( - id: DocumentEditor.createNodeId(), - itemType: ListItemType.ordered, - text: AttributedText(text: 'Ordered 2'), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'A blockquote:'), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'This is a blockquote.'), - metadata: {'blockType': blockquoteAttribution}, - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: 'Some code:'), - ), - ParagraphNode( - id: DocumentEditor.createNodeId(), - text: AttributedText(text: '{\n // This is some code.\n}'), - metadata: {'blockType': codeAttribution}, - ), - ]); - - // Ensure that the document serializes. We don't bother with - // validating the output because other tests should validate - // the per-node serializations. - - // ignore: unused_local_variable - final markdown = serializeDocumentToMarkdown(doc); - }); - - test("doesn't add empty lines at the end of the document", () { - final serialized = serializeDocumentToMarkdown( - MutableDocument(nodes: [ - ParagraphNode(id: '1', text: AttributedText(text: 'Paragraph1')), - ]), - ); - - expect(serialized, 'Paragraph1'); - }); - }); - - group('deserialization', () { - test('headers', () { - final header1Doc = deserializeMarkdownToDocument('# Header 1'); - expect((header1Doc.nodes.first as ParagraphNode).getMetadataValue('blockType'), header1Attribution); - - final header2Doc = deserializeMarkdownToDocument('## Header 2'); - expect((header2Doc.nodes.first as ParagraphNode).getMetadataValue('blockType'), header2Attribution); - - final header3Doc = deserializeMarkdownToDocument('### Header 3'); - expect((header3Doc.nodes.first as ParagraphNode).getMetadataValue('blockType'), header3Attribution); - - final header4Doc = deserializeMarkdownToDocument('#### Header 4'); - expect((header4Doc.nodes.first as ParagraphNode).getMetadataValue('blockType'), header4Attribution); - - final header5Doc = deserializeMarkdownToDocument('##### Header 5'); - expect((header5Doc.nodes.first as ParagraphNode).getMetadataValue('blockType'), header5Attribution); - - final header6Doc = deserializeMarkdownToDocument('###### Header 6'); - expect((header6Doc.nodes.first as ParagraphNode).getMetadataValue('blockType'), header6Attribution); - }); - - test('blockquote', () { - final blockquoteDoc = deserializeMarkdownToDocument('> This is a blockquote'); - - final blockquote = blockquoteDoc.nodes.first as ParagraphNode; - expect(blockquote.getMetadataValue('blockType'), blockquoteAttribution); - expect(blockquote.text.text, 'This is a blockquote'); - }); - - test('code block', () { - final codeBlockDoc = deserializeMarkdownToDocument(''' -``` -This is some code -```'''); - - final code = codeBlockDoc.nodes.first as ParagraphNode; - expect(code.getMetadataValue('blockType'), codeAttribution); - expect(code.text.text, 'This is some code\n'); - }); - - test('image', () { - final codeBlockDoc = deserializeMarkdownToDocument('![Image alt text](https://images.com/some/image.png)'); - - final image = codeBlockDoc.nodes.first as ImageNode; - expect(image.imageUrl, 'https://images.com/some/image.png'); - expect(image.altText, 'Image alt text'); - }); - - test('single unstyled paragraph', () { - const markdown = 'This is some unstyled text to parse as markdown'; - - final document = deserializeMarkdownToDocument(markdown); - - expect(document.nodes.length, 1); - expect(document.nodes.first, isA()); - - final paragraph = document.nodes.first as ParagraphNode; - expect(paragraph.text.text, 'This is some unstyled text to parse as markdown'); - }); - - test('single styled paragraph', () { - const markdown = 'This is **some *styled*** text to parse as [markdown](https://example.org)'; - - final document = deserializeMarkdownToDocument(markdown); - - expect(document.nodes.length, 1); - expect(document.nodes.first, isA()); - - final paragraph = document.nodes.first as ParagraphNode; - final styledText = paragraph.text; - expect(styledText.text, 'This is some styled text to parse as markdown'); - - expect(styledText.getAllAttributionsAt(0).isEmpty, true); - expect(styledText.getAllAttributionsAt(8).contains(boldAttribution), true); - expect(styledText.getAllAttributionsAt(13).containsAll([boldAttribution, italicsAttribution]), true); - expect(styledText.getAllAttributionsAt(19).isEmpty, true); - expect(styledText.getAllAttributionsAt(40).single, LinkAttribution(url: Uri.https('example.org', ''))); - }); - - test('link within multiple styles', () { - const markdown = 'This is **some *styled [link](https://example.org) text***'; - - final document = deserializeMarkdownToDocument(markdown); - - expect(document.nodes.length, 1); - expect(document.nodes.first, isA()); - - final paragraph = document.nodes.first as ParagraphNode; - final styledText = paragraph.text; - expect(styledText.text, 'This is some styled link text'); - - expect(styledText.getAllAttributionsAt(0).isEmpty, true); - expect(styledText.getAllAttributionsAt(8).contains(boldAttribution), true); - expect(styledText.getAllAttributionsAt(13).containsAll([boldAttribution, italicsAttribution]), true); - expect( - styledText - .getAllAttributionsAt(20) - .containsAll([boldAttribution, italicsAttribution, LinkAttribution(url: Uri.https('example.org', ''))]), - true); - expect(styledText.getAllAttributionsAt(25).containsAll([boldAttribution, italicsAttribution]), true); - }); - - test('completely overlapping link and style', () { - const markdown = 'This is **[a test](https://example.org)**'; - - final document = deserializeMarkdownToDocument(markdown); - - expect(document.nodes.length, 1); - expect(document.nodes.first, isA()); - - final paragraph = document.nodes.first as ParagraphNode; - final styledText = paragraph.text; - expect(styledText.text, 'This is a test'); - - expect(styledText.getAllAttributionsAt(0).isEmpty, true); - expect(styledText.getAllAttributionsAt(8).contains(boldAttribution), true); - expect( - styledText - .getAllAttributionsAt(13) - .containsAll([boldAttribution, LinkAttribution(url: Uri.https('example.org', ''))]), - true); - }); - - test('single style intersecting link', () { - // This isn't necessarily the behavior that you would expect, but it has been tested against multiple Markdown - // renderers (such as VS Code) and it matches their behaviour. - const markdown = 'This **is [a** link](https://example.org) test'; - final document = deserializeMarkdownToDocument(markdown); - - expect(document.nodes.length, 1); - expect(document.nodes.first, isA()); - - final paragraph = document.nodes.first as ParagraphNode; - final styledText = paragraph.text; - expect(styledText.text, 'This **is a** link test'); - - expect(styledText.getAllAttributionsAt(9).isEmpty, true); - expect(styledText.getAllAttributionsAt(12).single, LinkAttribution(url: Uri.https('example.org', ''))); - }); - - test('empty link', () { - // This isn't necessarily the behavior that you would expect, but it has been tested against multiple Markdown - // renderers (such as VS Code) and it matches their behaviour. - const markdown = 'This is [a link]() test'; - final document = deserializeMarkdownToDocument(markdown); - - expect(document.nodes.length, 1); - expect(document.nodes.first, isA()); - - final paragraph = document.nodes.first as ParagraphNode; - final styledText = paragraph.text; - expect(styledText.text, 'This is a link test'); - - expect(styledText.getAllAttributionsAt(12).single, LinkAttribution(url: Uri.parse(''))); - }); - - test('unordered list', () { - const markdown = ''' - * list item 1 - * list item 2 - * list item 2.1 - * list item 2.2 - * list item 3'''; - - final document = deserializeMarkdownToDocument(markdown); - - expect(document.nodes.length, 5); - for (final node in document.nodes) { - expect(node, isA()); - expect((node as ListItemNode).type, ListItemType.unordered); - } - - expect((document.nodes[0] as ListItemNode).indent, 0); - expect((document.nodes[1] as ListItemNode).indent, 0); - expect((document.nodes[2] as ListItemNode).indent, 1); - expect((document.nodes[3] as ListItemNode).indent, 1); - expect((document.nodes[4] as ListItemNode).indent, 0); - }); - - test('ordered list', () { - const markdown = ''' - 1. list item 1 - 1. list item 2 - 1. list item 2.1 - 1. list item 2.2 - 1. list item 3'''; - - final document = deserializeMarkdownToDocument(markdown); - - expect(document.nodes.length, 5); - for (final node in document.nodes) { - expect(node, isA()); - expect((node as ListItemNode).type, ListItemType.ordered); - } - - expect((document.nodes[0] as ListItemNode).indent, 0); - expect((document.nodes[1] as ListItemNode).indent, 0); - expect((document.nodes[2] as ListItemNode).indent, 1); - expect((document.nodes[3] as ListItemNode).indent, 1); - expect((document.nodes[4] as ListItemNode).indent, 0); - }); - - test('example doc 1', () { - final document = deserializeMarkdownToDocument(exampleMarkdownDoc1); - - expect(document.nodes.length, 18); - - expect(document.nodes[0], isA()); - expect((document.nodes[0] as ParagraphNode).getMetadataValue('blockType'), header1Attribution); - - expect(document.nodes[1], isA()); - - expect(document.nodes[2], isA()); - - expect(document.nodes[3], isA()); - - for (int i = 4; i < 9; ++i) { - expect(document.nodes[i], isA()); - } - - expect(document.nodes[9], isA()); - - for (int i = 10; i < 15; ++i) { - expect(document.nodes[i], isA()); - } - - expect(document.nodes[15], isA()); - - expect(document.nodes[16], isA()); - - expect(document.nodes[17], isA()); - }); - - test('paragraph with strikethrough', () { - final doc = deserializeMarkdownToDocument('~This is~ a paragraph.'); - final styledText = (doc.nodes[0] as ParagraphNode).text; - - // Ensure text within the range is attributed. - expect(styledText.getAllAttributionsAt(0).contains(strikethroughAttribution), true); - expect(styledText.getAllAttributionsAt(6).contains(strikethroughAttribution), true); - - // Ensure text outside the range isn't attributed. - expect(styledText.getAllAttributionsAt(7).contains(strikethroughAttribution), false); - }); - - test('paragraph with underline', () { - final doc = deserializeMarkdownToDocument('¬This is¬ a paragraph.'); - final styledText = (doc.nodes[0] as ParagraphNode).text; - - // Ensure text within the range is attributed. - expect(styledText.getAllAttributionsAt(0).contains(underlineAttribution), true); - expect(styledText.getAllAttributionsAt(6).contains(underlineAttribution), true); - - // Ensure text outside the range isn't attributed. - expect(styledText.getAllAttributionsAt(7).contains(underlineAttribution), false); - }); - - test('paragraph with left alignment', () { - final doc = deserializeMarkdownToDocument(':---\nParagraph1'); - - final paragraph = doc.nodes.first as ParagraphNode; - expect(paragraph.getMetadataValue('textAlign'), 'left'); - expect(paragraph.text.text, 'Paragraph1'); - }); - - test('paragraph with center alignment', () { - final doc = deserializeMarkdownToDocument(':---:\nParagraph1'); - - final paragraph = doc.nodes.first as ParagraphNode; - expect(paragraph.getMetadataValue('textAlign'), 'center'); - expect(paragraph.text.text, 'Paragraph1'); - }); - - test('paragraph with right alignment', () { - final doc = deserializeMarkdownToDocument('---:\nParagraph1'); - - final paragraph = doc.nodes.first as ParagraphNode; - expect(paragraph.getMetadataValue('textAlign'), 'right'); - expect(paragraph.text.text, 'Paragraph1'); - }); - - test('treats alignment token as text at the end of the document', () { - final doc = deserializeMarkdownToDocument('---:'); - - final paragraph = doc.nodes.first as ParagraphNode; - expect(paragraph.getMetadataValue('textAlign'), isNull); - expect(paragraph.text.text, '---:'); - }); - - test('treats alignment token as text when not followed by a paragraph', () { - final doc = deserializeMarkdownToDocument('---:\n - - -'); - - final paragraph = doc.nodes.first as ParagraphNode; - expect(paragraph.getMetadataValue('textAlign'), isNull); - expect(paragraph.text.text, '---:'); - - // Ensure the horizontal rule is parsed. - expect(doc.nodes[1], isA()); - }); - - test('treats alignment token as text when not using supereditor syntax', () { - final doc = deserializeMarkdownToDocument(':---\nParagraph1', syntax: MarkdownSyntax.normal); - - final paragraph = doc.nodes.first as ParagraphNode; - expect(paragraph.getMetadataValue('textAlign'), isNull); - expect(paragraph.text.text, ':---\nParagraph1'); - }); - - test('multiple paragraphs', () { - final input = """Paragraph1 - -Paragraph2"""; - final doc = deserializeMarkdownToDocument(input); - - expect(doc.nodes.length, 2); - expect((doc.nodes[0] as ParagraphNode).text.text, 'Paragraph1'); - expect((doc.nodes[1] as ParagraphNode).text.text, 'Paragraph2'); - }); - - test('empty paragraph between paragraphs', () { - final input = """Paragraph1 - - - -Paragraph3"""; - final doc = deserializeMarkdownToDocument(input); - - expect(doc.nodes.length, 3); - expect((doc.nodes[0] as ParagraphNode).text.text, 'Paragraph1'); - expect((doc.nodes[1] as ParagraphNode).text.text, ''); - expect((doc.nodes[2] as ParagraphNode).text.text, 'Paragraph3'); - }); - - test('multiple empty paragraph between paragraphs', () { - final input = """Paragraph1 - - - - - -Paragraph4"""; - final doc = deserializeMarkdownToDocument(input); - - expect(doc.nodes.length, 4); - expect((doc.nodes[0] as ParagraphNode).text.text, 'Paragraph1'); - expect((doc.nodes[1] as ParagraphNode).text.text, ''); - expect((doc.nodes[2] as ParagraphNode).text.text, ''); - expect((doc.nodes[3] as ParagraphNode).text.text, 'Paragraph4'); - }); - - test('paragraph ending with one blank line', () { - final doc = deserializeMarkdownToDocument('First Paragraph. \n\n\nSecond Paragraph'); - expect(doc.nodes.length, 2); - - expect(doc.nodes.first, isA()); - expect((doc.nodes.first as ParagraphNode).text.text, 'First Paragraph.\n'); - - expect(doc.nodes.last, isA()); - expect((doc.nodes.last as ParagraphNode).text.text, 'Second Paragraph'); - }); - - test('paragraph ending with multiple blank lines', () { - final doc = deserializeMarkdownToDocument('First Paragraph. \n \n \n\n\nSecond Paragraph'); - - expect(doc.nodes.length, 2); - - expect(doc.nodes.first, isA()); - expect((doc.nodes.first as ParagraphNode).text.text, 'First Paragraph.\n\n\n'); - - expect(doc.nodes.last, isA()); - expect((doc.nodes.last as ParagraphNode).text.text, 'Second Paragraph'); - }); - - test('paragraph with multiple blank lines at the middle', () { - final doc = - deserializeMarkdownToDocument('First Paragraph. \n \n \nStill First Paragraph\n\nSecond Paragraph'); - - expect(doc.nodes.length, 2); - - expect(doc.nodes.first, isA()); - expect((doc.nodes.first as ParagraphNode).text.text, 'First Paragraph.\n\n\nStill First Paragraph'); - - expect(doc.nodes.last, isA()); - expect((doc.nodes.last as ParagraphNode).text.text, 'Second Paragraph'); - }); - - test('paragraph beginning with multiple blank lines', () { - final doc = - deserializeMarkdownToDocument(' \n \nFirst Paragraph.\n\nSecond Paragraph'); - - expect(doc.nodes.length, 2); - - expect(doc.nodes.first, isA()); - expect((doc.nodes.first as ParagraphNode).text.text, '\n\nFirst Paragraph.'); - - expect(doc.nodes.last, isA()); - expect((doc.nodes.last as ParagraphNode).text.text, 'Second Paragraph'); - }); - - test('document ending with an empty paragraph', () { - final doc = deserializeMarkdownToDocument(""" -First Paragraph. - - -"""); - - expect(doc.nodes.length, 2); - - expect(doc.nodes.first, isA()); - expect((doc.nodes.first as ParagraphNode).text.text, 'First Paragraph.'); - - expect(doc.nodes.last, isA()); - expect((doc.nodes.last as ParagraphNode).text.text, ''); - }); - - test('empty markdown produces an empty paragraph', () { - final doc = deserializeMarkdownToDocument(''); - - expect(doc.nodes.length, 1); - - expect(doc.nodes.first, isA()); - expect((doc.nodes.first as ParagraphNode).text.text, ''); - }); - }); - }); -} - -const exampleMarkdownDoc1 = ''' -# Example 1 ---- -This is an example doc that has various types of nodes, like [links](https://example.org). - -It includes multiple paragraphs, ordered list items, unordered list items, images, and HRs. - - * unordered item 1 - * unordered item 2 - * unordered item 2.1 - * unordered item 2.2 - * unordered item 3 - ---- - - 1. ordered item 1 - 2. ordered item 2 - 1. ordered item 2.1 - 2. ordered item 2.2 - 3. ordered item 3 - ---- - -![Image alt text](https://images.com/some/image.png) - -The end! -'''; diff --git a/super_editor_markdown/.gitignore b/super_editor_spellcheck/.gitignore similarity index 98% rename from super_editor_markdown/.gitignore rename to super_editor_spellcheck/.gitignore index e90f4b2196..dc400f121f 100644 --- a/super_editor_markdown/.gitignore +++ b/super_editor_spellcheck/.gitignore @@ -1,3 +1,6 @@ +# Golden failures +**/failures/ + # Miscellaneous *.class *.log diff --git a/super_editor_spellcheck/.metadata b/super_editor_spellcheck/.metadata new file mode 100644 index 0000000000..32580c6f8d --- /dev/null +++ b/super_editor_spellcheck/.metadata @@ -0,0 +1,30 @@ +# 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: "af84f6b8471c761d61332dc499880cd4e486799d" + channel: "master" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: af84f6b8471c761d61332dc499880cd4e486799d + base_revision: af84f6b8471c761d61332dc499880cd4e486799d + - platform: macos + create_revision: af84f6b8471c761d61332dc499880cd4e486799d + base_revision: af84f6b8471c761d61332dc499880cd4e486799d + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_editor_spellcheck/.run/Mac Plugin Sample.run.xml b/super_editor_spellcheck/.run/Mac Plugin Sample.run.xml new file mode 100644 index 0000000000..ebe871414f --- /dev/null +++ b/super_editor_spellcheck/.run/Mac Plugin Sample.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor_spellcheck/.run/Super Editor Integration.run.xml b/super_editor_spellcheck/.run/Super Editor Integration.run.xml new file mode 100644 index 0000000000..6319a48ede --- /dev/null +++ b/super_editor_spellcheck/.run/Super Editor Integration.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor_spellcheck/.run/main.dart.run.xml b/super_editor_spellcheck/.run/main.dart.run.xml new file mode 100644 index 0000000000..488fdf526d --- /dev/null +++ b/super_editor_spellcheck/.run/main.dart.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor_spellcheck/CHANGELOG.md b/super_editor_spellcheck/CHANGELOG.md new file mode 100644 index 0000000000..41cc7d8192 --- /dev/null +++ b/super_editor_spellcheck/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/super_editor_markdown/LICENSE b/super_editor_spellcheck/LICENSE similarity index 100% rename from super_editor_markdown/LICENSE rename to super_editor_spellcheck/LICENSE diff --git a/super_editor_spellcheck/README.md b/super_editor_spellcheck/README.md new file mode 100644 index 0000000000..3a63a65329 --- /dev/null +++ b/super_editor_spellcheck/README.md @@ -0,0 +1,4 @@ +# super_editor_spellcheck + +A plugin for running spellcheck against arbitrary text. + diff --git a/super_editor_spellcheck/analysis_options.yaml b/super_editor_spellcheck/analysis_options.yaml new file mode 100644 index 0000000000..a5744c1cfb --- /dev/null +++ b/super_editor_spellcheck/analysis_options.yaml @@ -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 diff --git a/super_editor_spellcheck/example/.gitignore b/super_editor_spellcheck/example/.gitignore new file mode 100644 index 0000000000..79c113f9b5 --- /dev/null +++ b/super_editor_spellcheck/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# 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 +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/super_editor_spellcheck/example/.metadata b/super_editor_spellcheck/example/.metadata new file mode 100644 index 0000000000..37e80d9fa8 --- /dev/null +++ b/super_editor_spellcheck/example/.metadata @@ -0,0 +1,30 @@ +# 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: "17f63272a044ed77f3acc08437977757cebdc0b5" + channel: "master" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 17f63272a044ed77f3acc08437977757cebdc0b5 + base_revision: 17f63272a044ed77f3acc08437977757cebdc0b5 + - platform: android + create_revision: 17f63272a044ed77f3acc08437977757cebdc0b5 + base_revision: 17f63272a044ed77f3acc08437977757cebdc0b5 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_editor_spellcheck/example/README.md b/super_editor_spellcheck/example/README.md new file mode 100644 index 0000000000..07cefe2a9e --- /dev/null +++ b/super_editor_spellcheck/example/README.md @@ -0,0 +1,16 @@ +# super_editor_spellcheck_example + +Demonstrates how to use the super_editor_spellcheck 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://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/super_editor_spellcheck/example/analysis_options.yaml b/super_editor_spellcheck/example/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_editor_spellcheck/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_editor_spellcheck/example/android/.gitignore b/super_editor_spellcheck/example/android/.gitignore new file mode 100644 index 0000000000..55afd919c6 --- /dev/null +++ b/super_editor_spellcheck/example/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/super_editor_spellcheck/example/android/app/build.gradle b/super_editor_spellcheck/example/android/app/build.gradle new file mode 100644 index 0000000000..85418d1c2f --- /dev/null +++ b/super_editor_spellcheck/example/android/app/build.gradle @@ -0,0 +1,44 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.example.super_editor_spellcheck_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.super_editor_spellcheck_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/super_editor_spellcheck/example/android/app/src/debug/AndroidManifest.xml b/super_editor_spellcheck/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_editor_spellcheck/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_editor_spellcheck/example/android/app/src/main/AndroidManifest.xml b/super_editor_spellcheck/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..fcb24b1a1a --- /dev/null +++ b/super_editor_spellcheck/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor_spellcheck/example/android/app/src/main/kotlin/com/example/super_editor_spellcheck_example/MainActivity.kt b/super_editor_spellcheck/example/android/app/src/main/kotlin/com/example/super_editor_spellcheck_example/MainActivity.kt new file mode 100644 index 0000000000..2da2e9e23c --- /dev/null +++ b/super_editor_spellcheck/example/android/app/src/main/kotlin/com/example/super_editor_spellcheck_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.super_editor_spellcheck_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/super_editor_spellcheck/example/android/app/src/main/res/drawable-v21/launch_background.xml b/super_editor_spellcheck/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/super_editor_spellcheck/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_editor_spellcheck/example/android/app/src/main/res/drawable/launch_background.xml b/super_editor_spellcheck/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..304732f884 --- /dev/null +++ b/super_editor_spellcheck/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_editor_spellcheck/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/super_editor_spellcheck/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..db77bb4b7b Binary files /dev/null and b/super_editor_spellcheck/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/super_editor_spellcheck/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/super_editor_spellcheck/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..17987b79bb Binary files /dev/null and b/super_editor_spellcheck/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/super_editor_spellcheck/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/super_editor_spellcheck/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..09d4391482 Binary files /dev/null and b/super_editor_spellcheck/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/super_editor_spellcheck/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/super_editor_spellcheck/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d5f1c8d34e Binary files /dev/null and b/super_editor_spellcheck/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/super_editor_spellcheck/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/super_editor_spellcheck/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..4d6372eebd Binary files /dev/null and b/super_editor_spellcheck/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/super_editor_spellcheck/example/android/app/src/main/res/values-night/styles.xml b/super_editor_spellcheck/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/super_editor_spellcheck/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_editor_spellcheck/example/android/app/src/main/res/values/styles.xml b/super_editor_spellcheck/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..cb1ef88056 --- /dev/null +++ b/super_editor_spellcheck/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_editor_spellcheck/example/android/app/src/profile/AndroidManifest.xml b/super_editor_spellcheck/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_editor_spellcheck/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_editor_spellcheck/example/android/build.gradle b/super_editor_spellcheck/example/android/build.gradle new file mode 100644 index 0000000000..d2ffbffa4c --- /dev/null +++ b/super_editor_spellcheck/example/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/super_editor_spellcheck/example/android/gradle.properties b/super_editor_spellcheck/example/android/gradle.properties new file mode 100644 index 0000000000..2597170821 --- /dev/null +++ b/super_editor_spellcheck/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/super_editor_spellcheck/example/android/gradle/wrapper/gradle-wrapper.properties b/super_editor_spellcheck/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..7bb2df6ba6 --- /dev/null +++ b/super_editor_spellcheck/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip diff --git a/super_editor_spellcheck/example/android/settings.gradle b/super_editor_spellcheck/example/android/settings.gradle new file mode 100644 index 0000000000..a42444ded0 --- /dev/null +++ b/super_editor_spellcheck/example/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.2.1" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/super_editor_spellcheck/example/ios/.gitignore b/super_editor_spellcheck/example/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/super_editor_spellcheck/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/super_editor_spellcheck/example/ios/Flutter/AppFrameworkInfo.plist b/super_editor_spellcheck/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..7c56964006 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/super_editor_spellcheck/example/ios/Flutter/Debug.xcconfig b/super_editor_spellcheck/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..ec97fc6f30 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/super_editor_spellcheck/example/ios/Flutter/Release.xcconfig b/super_editor_spellcheck/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..c4855bfe20 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/super_editor_spellcheck/example/ios/Podfile b/super_editor_spellcheck/example/ios/Podfile new file mode 100644 index 0000000000..d97f17e223 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# 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', '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 Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/super_editor_spellcheck/example/ios/Podfile.lock b/super_editor_spellcheck/example/ios/Podfile.lock new file mode 100644 index 0000000000..5ac3728b48 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - Flutter (1.0.0) + - integration_test (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + +PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 + +COCOAPODS: 1.12.1 diff --git a/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.pbxproj b/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..ba0bf9a550 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,731 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 25A446013D34DBE4F1CD2A26 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9662D805E33B93568C1EC25C /* Pods_Runner.framework */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 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 */; }; + F2DF2EC224B38C34769B2EE6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 44EE823F68307A6B6458BF83 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 44EE823F68307A6B6458BF83 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 69FC3617F3EFE64040DDBF32 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 70E23004EE54AC731F19B26B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7895BAE9DCCBD43F49695A5F /* 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 = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7CA329463CB6CE2797F3CE8A /* 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 = ""; }; + 9454D165BC8E0F386E252A75 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 9662D805E33B93568C1EC25C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AA5EAE04351B1E66D59A9BFA /* 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 22E2884338B5B5D19BA05159 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F2DF2EC224B38C34769B2EE6 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 25A446013D34DBE4F1CD2A26 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + F65A781DCD2A3B6F76FE2116 /* Pods */, + CB25DC16239B5D39B24D06E8 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + CB25DC16239B5D39B24D06E8 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9662D805E33B93568C1EC25C /* Pods_Runner.framework */, + 44EE823F68307A6B6458BF83 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + F65A781DCD2A3B6F76FE2116 /* Pods */ = { + isa = PBXGroup; + children = ( + 7895BAE9DCCBD43F49695A5F /* Pods-Runner.debug.xcconfig */, + 7CA329463CB6CE2797F3CE8A /* Pods-Runner.release.xcconfig */, + AA5EAE04351B1E66D59A9BFA /* Pods-Runner.profile.xcconfig */, + 70E23004EE54AC731F19B26B /* Pods-RunnerTests.debug.xcconfig */, + 9454D165BC8E0F386E252A75 /* Pods-RunnerTests.release.xcconfig */, + 69FC3617F3EFE64040DDBF32 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + DCCD2F83101DC8F3945F52D9 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 22E2884338B5B5D19BA05159 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + D31AAD6203451833AF55DDAF /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + C1ADFBB7B695370A27074493 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + C1ADFBB7B695370A27074493 /* [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; + }; + D31AAD6203451833AF55DDAF /* [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; + }; + DCCD2F83101DC8F3945F52D9 /* [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-RunnerTests-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 */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2J62ZA726Z; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.superEditorSpellcheckExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 70E23004EE54AC731F19B26B /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.superEditorSpellcheckExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9454D165BC8E0F386E252A75 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.superEditorSpellcheckExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 69FC3617F3EFE64040DDBF32 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.superEditorSpellcheckExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2J62ZA726Z; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.superEditorSpellcheckExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 2J62ZA726Z; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.superEditorSpellcheckExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_editor_spellcheck/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_editor_spellcheck/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..8e3ca5dfe1 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor_spellcheck/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/super_editor_spellcheck/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_editor_spellcheck/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor_spellcheck/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor_spellcheck/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_editor_spellcheck/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_editor_spellcheck/example/ios/Runner/AppDelegate.swift b/super_editor_spellcheck/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..626664468b --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000..dc9ada4725 Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..7353c41ecf Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..6ed2d933e1 Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..4cd7b0099c Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..fe730945a0 Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..321773cd85 Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..502f463a9b Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..e9f5fea27c Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..84ac32ae7d Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..8953cba090 Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..0467bf12aa Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/super_editor_spellcheck/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/super_editor_spellcheck/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor_spellcheck/example/ios/Runner/Base.lproj/Main.storyboard b/super_editor_spellcheck/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor_spellcheck/example/ios/Runner/Info.plist b/super_editor_spellcheck/example/ios/Runner/Info.plist new file mode 100644 index 0000000000..207a4c25b2 --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Super Editor Spellcheck Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + super_editor_spellcheck_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/super_editor_spellcheck/example/ios/Runner/Runner-Bridging-Header.h b/super_editor_spellcheck/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/super_editor_spellcheck/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/super_editor_spellcheck/example/ios/RunnerTests/RunnerTests.swift b/super_editor_spellcheck/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..86a7c3b1b6 --- /dev/null +++ b/super_editor_spellcheck/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/super_editor_spellcheck/example/lib/main_plugin_sample.dart b/super_editor_spellcheck/example/lib/main_plugin_sample.dart new file mode 100644 index 0000000000..b4d52a1338 --- /dev/null +++ b/super_editor_spellcheck/example/lib/main_plugin_sample.dart @@ -0,0 +1,245 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'dart:async'; + +import 'package:super_editor_spellcheck/super_editor_spellcheck.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + final _superEditorSpellcheckPlugin = SuperEditorSpellCheckerPlugin(); + final _textController = TextEditingController(text: 'She go to the store everys day.'); + + List _suggestions = []; + String? _correction; + + TextRange _firstMispelledWord = TextRange.empty; + List _firstMispelledWordSuggestions = []; + CheckGrammarResult? _grammarAnalysis; + int? _wordCount; + int? _documentTag; + Map _userReplacementsDictionary = {}; + List _completionsForLastWord = []; + Timer? _searchTimer; + + @override + void initState() { + super.initState(); + + _textController.addListener(_onTextChanged); + _fetchSuggestions(); + } + + @override + void dispose() { + _textController.removeListener(_onTextChanged); + _textController.dispose(); + if (_documentTag != null) { + _superEditorSpellcheckPlugin.macSpellChecker.closeSpellDocumentWithTag(_documentTag!); + } + super.dispose(); + } + + void _onTextChanged() { + _searchTimer?.cancel(); + _searchTimer = Timer( + const Duration(milliseconds: 300), + _fetchSuggestions, + ); + } + + Future _fetchSuggestions() async { + final textToSearch = _textController.text; + final locale = PlatformDispatcher.instance.locale; + + int? tag = _documentTag; + tag ??= await _superEditorSpellcheckPlugin.macSpellChecker.uniqueSpellDocumentTag(); + + final language = _superEditorSpellcheckPlugin.macSpellChecker.convertDartLocaleToMacLanguageCode(locale)!; + + final suggestions = await _superEditorSpellcheckPlugin.fetchSuggestions( + locale, + textToSearch, + ); + + if (_shouldAbortCurrentSearch(textToSearch)) { + return; + } + + final firstMisspelled = await _superEditorSpellcheckPlugin.macSpellChecker.checkSpelling( + stringToCheck: textToSearch, + startingOffset: 0, + language: language, + ); + + if (_shouldAbortCurrentSearch(textToSearch)) { + return; + } + + final firstSuggestions = firstMisspelled.isValid + ? await _superEditorSpellcheckPlugin.macSpellChecker.guesses( + range: firstMisspelled, + text: textToSearch, + language: language, + ) + : []; + + if (_shouldAbortCurrentSearch(textToSearch)) { + return; + } + + final correction = await _superEditorSpellcheckPlugin.macSpellChecker.correction( + text: textToSearch, + range: firstMisspelled, + language: language, + ); + + if (_shouldAbortCurrentSearch(textToSearch)) { + return; + } + + final grammarAnalysis = await _superEditorSpellcheckPlugin.macSpellChecker.checkGrammar( + stringToCheck: textToSearch, + startingOffset: 0, + language: language, + ); + + if (_shouldAbortCurrentSearch(textToSearch)) { + return; + } + + final wordCount = await _superEditorSpellcheckPlugin.macSpellChecker.countWords( + text: textToSearch, + language: language, + ); + + if (_shouldAbortCurrentSearch(textToSearch)) { + return; + } + + final replacements = await _superEditorSpellcheckPlugin.macSpellChecker.userReplacementsDictionary(); + + if (_shouldAbortCurrentSearch(textToSearch)) { + return; + } + + final completionOffset = max(textToSearch.lastIndexOf(' '), 0); + + final completions = await _superEditorSpellcheckPlugin.macSpellChecker.completions( + partialWordRange: TextRange(start: completionOffset, end: textToSearch.length), + text: textToSearch, + language: language, + ); + + if (_shouldAbortCurrentSearch(textToSearch)) { + return; + } + + setState(() { + _documentTag = tag; + _suggestions = suggestions; + _correction = correction; + _firstMispelledWord = firstMisspelled; + _firstMispelledWordSuggestions = firstSuggestions; + _grammarAnalysis = grammarAnalysis; + _wordCount = wordCount; + _userReplacementsDictionary = replacements; + _completionsForLastWord = completions; + }); + } + + bool _shouldAbortCurrentSearch(String textToSearch) { + if (!mounted) { + return true; + } + + if (textToSearch != _textController.text) { + // The user changed the text while the search was happening. Ignore the results, + // because a new search will happen. + return true; + } + + return false; + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Plugin example app'), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + TextField( + controller: _textController, + ), + if (_documentTag != null) // + Text('Document tag: $_documentTag'), + if (_wordCount != null) // + Text('Word count: $_wordCount'), + if (_firstMispelledWord.isValid) + Text('First misspelled word: ${_textController.text.substring( + _firstMispelledWord.start, + _firstMispelledWord.end, + )}'), + if (_correction != null) // + Text('Correction for first misspelled word: $_correction'), + if (_firstMispelledWordSuggestions.isNotEmpty) + Text('Suggestions for first misspelled word: ${_firstMispelledWordSuggestions.join(', ')}'), + const SizedBox(height: 10), + _suggestions.isEmpty + ? const Text('No spelling errors found.') + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _suggestions.map(_buildSuggestions).toList(), + ), + if (_grammarAnalysis != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _grammarAnalysis!.details.map(_buildGrammarAnalysis).toList(), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _userReplacementsDictionary.entries.map(_buildReplacement).toList(), + ), + if (_completionsForLastWord.isNotEmpty) + Text('Completions for last word: ${_completionsForLastWord.join(', ')}'), + ], + ), + ), + ), + ); + } + + Widget _buildSuggestions(TextSuggestion span) { + return Text( + '${_textController.text.substring(span.range.start, span.range.end)}: ${span.suggestions.join(', ')}', + ); + } + + Widget _buildGrammarAnalysis(GrammaticalAnalysisDetail? detail) { + return Text( + '${_textController.text.substring(detail!.range.start, detail.range.end)}: ${detail.userDescription}', + ); + } + + Widget _buildReplacement(MapEntry entry) { + return Text( + 'Replace ${entry.key} with ${entry.value}', + ); + } +} diff --git a/super_editor_spellcheck/example/lib/main_super_editor_spellcheck.dart b/super_editor_spellcheck/example/lib/main_super_editor_spellcheck.dart new file mode 100644 index 0000000000..f4d76c85d3 --- /dev/null +++ b/super_editor_spellcheck/example/lib/main_super_editor_spellcheck.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_spellcheck/super_editor_spellcheck.dart'; + +void main() { + runApp(_SuperEditorSpellcheckPluginApp()); +} + +class _SuperEditorSpellcheckPluginApp extends StatefulWidget { + @override + State<_SuperEditorSpellcheckPluginApp> createState() => _SuperEditorSpellcheckPluginAppState(); +} + +class _SuperEditorSpellcheckPluginAppState extends State<_SuperEditorSpellcheckPluginApp> { + var _brightness = Brightness.light; + + void _toggleBrightness() { + setState(() { + _brightness = _brightness == Brightness.light ? Brightness.dark : Brightness.light; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + brightness: _brightness, + ), + home: SafeArea( + child: Stack( + children: [ + const _SuperEditorSpellcheckScreen(), + Positioned( + top: 0, + bottom: 0, + right: 0, + child: _buildToolbar(), + ), + ], + ), + ), + ); + } + + Widget _buildToolbar() { + return SizedBox( + width: 48, + child: Column( + children: [ + const Spacer(), + IconButton( + onPressed: _toggleBrightness, + icon: Icon(_brightness == Brightness.light ? Icons.dark_mode : Icons.light_mode), + ), + ], + ), + ); + } +} + +class _SuperEditorSpellcheckScreen extends StatefulWidget { + const _SuperEditorSpellcheckScreen(); + + @override + State<_SuperEditorSpellcheckScreen> createState() => _SuperEditorSpellcheckScreenState(); +} + +class _SuperEditorSpellcheckScreenState extends State<_SuperEditorSpellcheckScreen> { + late final Editor _editor; + late final SpellingAndGrammarPlugin _spellingAndGrammarPlugin; + + late final SuperEditorIosControlsController _iosControlsController; + late final SuperEditorAndroidControlsController _androidControlsController; + + @override + void initState() { + super.initState(); + + _iosControlsController = SuperEditorIosControlsController(); + _androidControlsController = SuperEditorAndroidControlsController(); + + _spellingAndGrammarPlugin = SpellingAndGrammarPlugin( + iosControlsController: _iosControlsController, + androidControlsController: _androidControlsController, + spellCheckDelayAfterEdit: const Duration(seconds: 1), + ignoreRules: [ + SpellingIgnoreRules.byAttribution(codeAttribution), + SpellingIgnoreRules.byAttributionFilter((attr) => attr is LinkAttribution), + SpellingIgnoreRules.byPattern(RegExp(r'#\w+')), + ], + ); + + _editor = createDefaultDocumentEditor( + document: MutableDocument( + // Start the document with some misspelled content to ensure pre-existing + // content is analyzed and styled. + nodes: [ + ParagraphNode(id: "1", text: AttributedText("Tihs is mipelled")), + ParagraphNode(id: "2", text: AttributedText()), + ], + ), + composer: MutableDocumentComposer(), + ); + + _insertMisspelledText(); + } + + @override + void dispose() { + _iosControlsController.dispose(); + _androidControlsController.dispose(); + super.dispose(); + } + + void _insertMisspelledText() { + WidgetsBinding.instance.addPostFrameCallback((_) { + _editor.execute([ + InsertTextRequest( + documentPosition: DocumentPosition( + nodeId: _editor.context.document.last.id, + nodePosition: _editor.context.document.last.beginningPosition, + ), + textToInsert: + 'Flutter is a populr framework developd by Google for buildng natively compilid applications for mobil, web, and desktop from a single code base. Its hot reload featur allows developers to see the changes they make in real-time without havng to restart the app, which can greatly sped up the development proccess. With a rich set of widgets and a customizble UI, Flutter makes it easy to creat beautiful and performant apps quickly.', + attributions: {}, + ), + ]); + _editor.execute([ + InsertNodeAfterNodeRequest( + existingNodeId: _editor.context.document.last.id, + newNode: ParagraphNode(id: Editor.createNodeId(), text: AttributedText('')), + ) + ]); + _editor.execute([ + InsertAttributedTextRequest( + DocumentPosition( + nodeId: _editor.context.document.last.id, + nodePosition: _editor.context.document.last.endPosition, + ), + AttributedText( + 'The spellchecking can be configured to ignore spelling errors for some situation, like links: https://www.populr.com, ' + 'tags: #framwork, or text with specific attributions, like bold attbution.', + AttributedSpans( + attributions: [ + const SpanMarker( + attribution: LinkAttribution('https://www.populr.com'), + offset: 94, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: LinkAttribution('https://www.populr.com'), + offset: 115, + markerType: SpanMarkerType.end, + ), + const SpanMarker( + attribution: PatternTagAttribution(), + offset: 124, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: PatternTagAttribution(), + offset: 132, + markerType: SpanMarkerType.end, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 176, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 189, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ) + ]); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SuperEditorAndroidControlsScope( + controller: _androidControlsController, + child: SuperEditorIosControlsScope( + controller: _iosControlsController, + child: SuperEditor( + autofocus: true, + editor: _editor, + stylesheet: defaultStylesheet.copyWith( + inlineTextStyler: (attributions, existingStyle) { + TextStyle style = defaultInlineTextStyler(attributions, existingStyle); + + if (attributions.whereType().isNotEmpty) { + style = style.copyWith( + color: Colors.orange, + ); + } + + return style; + }, + addRulesAfter: [ + if (Theme.of(context).brightness == Brightness.dark) ..._darkModeStyles, + ], + ), + plugins: { + _spellingAndGrammarPlugin, + }, + ), + ), + ), + ); + } +} + +// Makes text light, for use during dark mode styling. +final _darkModeStyles = [ + StyleRule( + BlockSelector.all, + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFFCCCCCC), + ), + }; + }, + ), + StyleRule( + const BlockSelector("header1"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + ), + }; + }, + ), + StyleRule( + const BlockSelector("header2"), + (doc, docNode) { + return { + Styles.textStyle: const TextStyle( + color: Color(0xFF888888), + ), + }; + }, + ), +]; diff --git a/super_editor_spellcheck/example/macos/.gitignore b/super_editor_spellcheck/example/macos/.gitignore new file mode 100644 index 0000000000..746adbb6b9 --- /dev/null +++ b/super_editor_spellcheck/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/super_editor_spellcheck/example/macos/Flutter/Flutter-Debug.xcconfig b/super_editor_spellcheck/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..4b81f9b2d2 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_editor_spellcheck/example/macos/Flutter/Flutter-Release.xcconfig b/super_editor_spellcheck/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5caa9d1579 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/super_editor_spellcheck/example/macos/Flutter/GeneratedPluginRegistrant.swift b/super_editor_spellcheck/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..06172bdb42 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import super_editor_spellcheck +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + SuperEditorSpellcheckPlugin.register(with: registry.registrar(forPlugin: "SuperEditorSpellcheckPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/super_editor_spellcheck/example/macos/Podfile b/super_editor_spellcheck/example/macos/Podfile new file mode 100644 index 0000000000..c795730db8 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14' + +# 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__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/super_editor_spellcheck/example/macos/Podfile.lock b/super_editor_spellcheck/example/macos/Podfile.lock new file mode 100644 index 0000000000..7c393a399e --- /dev/null +++ b/super_editor_spellcheck/example/macos/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - FlutterMacOS (1.0.0) + - super_editor_spellcheck (0.0.1): + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - super_editor_spellcheck (from `Flutter/ephemeral/.symlinks/plugins/super_editor_spellcheck/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + super_editor_spellcheck: + :path: Flutter/ephemeral/.symlinks/plugins/super_editor_spellcheck/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + super_editor_spellcheck: 5063cbc89fc64c395067cf5b25fd56081920949b + url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 + +PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 + +COCOAPODS: 1.14.2 diff --git a/super_editor_spellcheck/example/macos/Runner.xcodeproj/project.pbxproj b/super_editor_spellcheck/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..b5b134a3a7 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + 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 */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 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 */; }; + 809AA79D5A9E87764874BD37 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EEA22E2151C2F7A1F9B22F11 /* Pods_RunnerTests.framework */; }; + D6E0CE7639C57474207AFD54 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CB025B9F5A38FC8EB25EEC0 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 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 */ + 1219EEAABC8F468C3B7507E0 /* 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 = ""; }; + 18F3283A0AC276D64EF6F393 /* 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 = ""; }; + 2C1BC64E3FA804CB9072CF97 /* 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 = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* super_editor_spellcheck_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = super_editor_spellcheck_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 571BB3B434F91722476F0387 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 7CB025B9F5A38FC8EB25EEC0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 820CD96BE6D98463C2353CF7 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + E02D3CFA20ACB6DDACD234D9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + EEA22E2151C2F7A1F9B22F11 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 809AA79D5A9E87764874BD37 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D6E0CE7639C57474207AFD54 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + C5230C2ACAC4519FB2377B07 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* super_editor_spellcheck_example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + C5230C2ACAC4519FB2377B07 /* Pods */ = { + isa = PBXGroup; + children = ( + 1219EEAABC8F468C3B7507E0 /* Pods-Runner.debug.xcconfig */, + 2C1BC64E3FA804CB9072CF97 /* Pods-Runner.release.xcconfig */, + 18F3283A0AC276D64EF6F393 /* Pods-Runner.profile.xcconfig */, + E02D3CFA20ACB6DDACD234D9 /* Pods-RunnerTests.debug.xcconfig */, + 571BB3B434F91722476F0387 /* Pods-RunnerTests.release.xcconfig */, + 820CD96BE6D98463C2353CF7 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7CB025B9F5A38FC8EB25EEC0 /* Pods_Runner.framework */, + EEA22E2151C2F7A1F9B22F11 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 61526A56C984C68863924739 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 752987369C4D8D5A9E99C8F3 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 525B477094F69C369F61C2FE /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* super_editor_spellcheck_example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 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 */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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; + alwaysOutOfDate = 1; + 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"; + }; + 525B477094F69C369F61C2FE /* [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; + }; + 61526A56C984C68863924739 /* [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-RunnerTests-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; + }; + 752987369C4D8D5A9E99C8F3 /* [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 */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 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 */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 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 = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E02D3CFA20ACB6DDACD234D9 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.supereditor.spellcheck.superEditorSpellcheckExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/super_editor_spellcheck_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/super_editor_spellcheck_example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 571BB3B434F91722476F0387 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.supereditor.spellcheck.superEditorSpellcheckExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/super_editor_spellcheck_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/super_editor_spellcheck_example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 820CD96BE6D98463C2353CF7 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.supereditor.spellcheck.superEditorSpellcheckExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/super_editor_spellcheck_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/super_editor_spellcheck_example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + 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; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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.14; + 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 */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 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 */; +} diff --git a/super_editor_spellcheck/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor_spellcheck/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor_spellcheck/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_editor_spellcheck/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..4d8ec54345 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor_spellcheck/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/super_editor_spellcheck/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_editor_spellcheck/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_editor_spellcheck/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_editor_spellcheck/example/macos/Runner/AppDelegate.swift b/super_editor_spellcheck/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..b3c1761412 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..a2ec33f19f --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..82b6f9d9a3 Binary files /dev/null and b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000..13b35eba55 Binary files /dev/null and b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000..0a3f5fa40f Binary files /dev/null and b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000..bdb57226d5 Binary files /dev/null and b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000000..f083318e09 Binary files /dev/null and b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000000..326c0e72c9 Binary files /dev/null and b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000..2f1632cfdd Binary files /dev/null and b/super_editor_spellcheck/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/super_editor_spellcheck/example/macos/Runner/Base.lproj/MainMenu.xib b/super_editor_spellcheck/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..80e867a4e0 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_editor_spellcheck/example/macos/Runner/Configs/AppInfo.xcconfig b/super_editor_spellcheck/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..af3c42e7e1 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/Configs/AppInfo.xcconfig @@ -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 = super_editor_spellcheck_example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.supereditor.spellcheck.superEditorSpellcheckExample + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 com.flutterbountyhunters.supereditor.spellcheck. All rights reserved. diff --git a/super_editor_spellcheck/example/macos/Runner/Configs/Debug.xcconfig b/super_editor_spellcheck/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_editor_spellcheck/example/macos/Runner/Configs/Release.xcconfig b/super_editor_spellcheck/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/super_editor_spellcheck/example/macos/Runner/Configs/Warnings.xcconfig b/super_editor_spellcheck/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/super_editor_spellcheck/example/macos/Runner/DebugProfile.entitlements b/super_editor_spellcheck/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..dddb8a30c8 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/super_editor_spellcheck/example/macos/Runner/Info.plist b/super_editor_spellcheck/example/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/super_editor_spellcheck/example/macos/Runner/MainFlutterWindow.swift b/super_editor_spellcheck/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..3cc05eb234 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/super_editor_spellcheck/example/macos/Runner/Release.entitlements b/super_editor_spellcheck/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..852fa1a472 --- /dev/null +++ b/super_editor_spellcheck/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/super_editor_spellcheck/example/macos/RunnerTests/RunnerTests.swift b/super_editor_spellcheck/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..1122523ca6 --- /dev/null +++ b/super_editor_spellcheck/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,28 @@ +import Cocoa +import FlutterMacOS +import XCTest + + +@testable import super_editor_spellcheck + +// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. +// +// See https://developer.apple.com/documentation/xctest for more information about using XCTest. + +class RunnerTests: XCTestCase { + + func testGetPlatformVersion() { + let plugin = SuperEditorSpellcheckPlugin() + + let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual(result as! String, + "macOS " + ProcessInfo.processInfo.operatingSystemVersionString) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + +} diff --git a/super_editor_spellcheck/example/pubspec.lock b/super_editor_spellcheck/example/pubspec.lock new file mode 100644 index 0000000000..cc3a7e151e --- /dev/null +++ b/super_editor_spellcheck/example/pubspec.lock @@ -0,0 +1,759 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + attributed_text: + dependency: transitive + description: + name: attributed_text + sha256: "177ea01f58a8d8df279f4066834375a2009bdd304d559c084bb06f784b258477" + url: "https://pub.dev" + source: hosted + version: "0.4.5" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "576aaab8b1abdd452e0f656c3e73da9ead9d7880e15bdc494189d9c1a1baf0db" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + url: "https://pub.dev" + source: hosted + version: "3.0.5" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_quill_delta: + dependency: transitive + description: + name: dart_quill_delta + sha256: "6aa89f0903ca3e70f5ceeb1d75d722f6ca583e87a2a8893c7b9f42f7a947f6e5" + url: "https://pub.dev" + source: hosted + version: "9.6.0" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" + url: "https://pub.dev" + source: hosted + version: "2.0.27" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_test_robots: + dependency: transitive + description: + name: flutter_test_robots + sha256: "3b00f2081148bde55190997c2772f934ad2f4529cbcfc4ccfa593f8ddc117a28" + url: "https://pub.dev" + source: hosted + version: "0.0.24" + flutter_test_runners: + dependency: transitive + description: + name: flutter_test_runners + sha256: cc575117ed66a79185a26995399d7048341517a1bd21188cb43753739627832d + url: "https://pub.dev" + source: hosted + version: "0.0.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + follow_the_leader: + dependency: transitive + description: + name: follow_the_leader + sha256: "2e4c4ebe6b3f1942b2385904b118ba8ba117fae0b30c8c453be0b64a271dd07a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + http: + dependency: transitive + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlord: + dependency: transitive + description: + name: overlord + sha256: "532f5685ac09ee805d97ce89794a4eeda41672c32955b4a835bdfce93e720a05" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + super_editor: + dependency: "direct main" + description: + path: "../../super_editor" + relative: true + source: path + version: "0.3.0-dev.40" + super_editor_spellcheck: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.0.1" + super_keyboard: + dependency: transitive + description: + name: super_keyboard + sha256: e3accebf33635f760efbd4d3c13f6484242a09e773ce8e711f4aa745d52b73b1 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + super_text_layout: + dependency: transitive + description: + name: super_text_layout + sha256: e25f01ceb809118da66fd095b3dcdc608a611bf45e364f303e7f9f0af0c5f8d1 + url: "https://pub.dev" + source: hosted + version: "0.1.18" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" + text_table: + dependency: transitive + description: + name: text_table + sha256: a42b35675be614274b884ee482d4bdf4bdf707bc65de18cb8f1ad288c1beb1f4 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: "direct overridden" + description: + name: url_launcher_android + sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + url: "https://pub.dev" + source: hosted + version: "6.3.16" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" + url: "https://pub.dev" + source: hosted + version: "3.0.4" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.27.0" diff --git a/super_editor_spellcheck/example/pubspec.yaml b/super_editor_spellcheck/example/pubspec.yaml new file mode 100644 index 0000000000..9eeb3a9768 --- /dev/null +++ b/super_editor_spellcheck/example/pubspec.yaml @@ -0,0 +1,70 @@ +name: super_editor_spellcheck_example +description: "Demonstrates how to use the super_editor_spellcheck plugin." +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + + super_editor: + path: ../../super_editor + + super_editor_spellcheck: + # When depending on this package from a real application you should use: + # super_editor_spellcheck: ^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: ../ + + cupertino_icons: ^1.0.6 + +dependency_overrides: + super_editor: + path: ../../super_editor + url_launcher_android: 6.3.16 + +dev_dependencies: + integration_test: + sdk: flutter + flutter_test: + sdk: flutter + + flutter_lints: ^3.0.0 + +flutter: + 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 diff --git a/super_editor_spellcheck/lib/src/platform/messages.g.dart b/super_editor_spellcheck/lib/src/platform/messages.g.dart new file mode 100644 index 0000000000..a21b28618b --- /dev/null +++ b/super_editor_spellcheck/lib/src/platform/messages.g.dart @@ -0,0 +1,649 @@ +// Autogenerated from Pigeon (v21.2.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +/// A range of characters in a string of text. +/// +/// The text included in the range includes the character at [start], but not +/// the one at [end]. +/// +/// This is used because we can't use `TextRange` in pigeon. +class PigeonRange { + PigeonRange({ + required this.start, + required this.end, + }); + + int start; + + int end; + + Object encode() { + return [ + start, + end, + ]; + } + + static PigeonRange decode(Object result) { + result as List; + return PigeonRange( + start: result[0]! as int, + end: result[1]! as int, + ); + } +} + +/// The result of a grammatical analysis. +class PigeonCheckGrammarResult { + PigeonCheckGrammarResult({ + this.firstError, + this.details, + }); + + /// The range of the first error found in the text or `null` if no errors were found. + PigeonRange? firstError; + + /// A list of details about the grammatical errors found in the text or `null` + /// if no errors were found. + List? details; + + Object encode() { + return [ + firstError, + details, + ]; + } + + static PigeonCheckGrammarResult decode(Object result) { + result as List; + return PigeonCheckGrammarResult( + firstError: result[0] as PigeonRange?, + details: (result[1] as List?)?.cast(), + ); + } +} + +/// A detail about a grammatical error found in a text. +class PigeonGrammaticalAnalysisDetail { + PigeonGrammaticalAnalysisDetail({ + required this.range, + required this.userDescription, + }); + + /// The range of the grammatical error in the text. + PigeonRange range; + + /// A description of the grammatical error. + String userDescription; + + Object encode() { + return [ + range, + userDescription, + ]; + } + + static PigeonGrammaticalAnalysisDetail decode(Object result) { + result as List; + return PigeonGrammaticalAnalysisDetail( + range: result[0]! as PigeonRange, + userDescription: result[1]! as String, + ); + } +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is PigeonRange) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is PigeonCheckGrammarResult) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is PigeonGrammaticalAnalysisDetail) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return PigeonRange.decode(readValue(buffer)!); + case 130: + return PigeonCheckGrammarResult.decode(readValue(buffer)!); + case 131: + return PigeonGrammaticalAnalysisDetail.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class SpellCheckMac { + /// Constructor for [SpellCheckMac]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + SpellCheckMac({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// {@template mac_spell_checker_available_languages} + /// A list containing all the available spell checking languages. + /// + /// The languages are ordered in the user’s preferred order as set in the + /// system preferences. + /// {@endtemplate} + Future> availableLanguages() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.availableLanguages$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as List?)!.cast(); + } + } + + /// {@template mac_spell_checker_unique_spell_document_tag} + /// Returns a unique tag to partition stateful operations in the spell checking system. + /// + /// Use the tag returned by this method in the spell checking methods when there are different + /// texts being spell checked. + /// + /// For example, if there are two texts being spell checked, with tags `1` and `2`, + /// the spell checker will keep the state of the ignored words separate for each one. If an + /// ignored word is added to the tag `1`, it won't be seen as misspelled for tag `1`, but it + /// will be for the tag `2`. + /// + /// Call [closeSpellDocument] when you are done with the tag to release resources. + /// {@endtemplate} + Future uniqueSpellDocumentTag() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.uniqueSpellDocumentTag$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } + + /// {@template mac_spell_checker_close_spell_document} + /// Notifies the spell checking system that the user has finished with the tagged document. + /// + /// The spell checker will release any resources associated with the document, + /// including but not necessarily limited to, ignored words. + /// {@endtemplate} + Future closeSpellDocument(int tag) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.closeSpellDocument$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([tag]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// {@template mac_spell_checker_check_spelling} + /// Searches for a misspelled word in [stringToCheck], starting at [startingOffset], and returns the + /// [TextRange] surrounding the misspelled word. + /// + /// If no misspelled word is found, a [TextRange] is returned with bounds of `-1`, which can also be + /// queried more conveniently with [TextRange.isValid]. + /// + /// To find all (or multiple) misspelled words in a given string, call this + /// method repeatedly, passing in different values for [startingOffset]. + /// {@endtemplate} + Future checkSpelling({required String stringToCheck, required int startingOffset, String? language, bool wrap = false, int inSpellDocumentWithTag = 0,}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.checkSpelling$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([stringToCheck, startingOffset, language, wrap, inSpellDocumentWithTag]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as PigeonRange?)!; + } + } + + /// {@template mac_spell_checker_guesses} + /// Returns possible substitutions for the specified misspelled word at [range] inside the [text]. + /// + /// - [range]: The range, within the [text], for which possible substitutions should be generated. + /// - [text]: The string containing the word/text for which substitutions should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// {@endtemplate} + Future?> guesses({required String text, required PigeonRange range, String? language, int inSpellDocumentWithTag = 0,}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.guesses$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([text, range, language, inSpellDocumentWithTag]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as List?)?.cast(); + } + } + + /// {@template mac_spell_checker_correction} + /// Returns a single proposed correction if a word is mis-spelled. + /// + /// - [range]: The range, within the [text], for which a possible should be generated. + /// - [text]: The string containing the word/text for which the correction should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// {@endtemplate} + Future correction({required String text, required PigeonRange range, required String language, int inSpellDocumentWithTag = 0,}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.correction$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([text, range, language, inSpellDocumentWithTag]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as String?); + } + } + + /// {@template mac_spell_checker_check_grammar} + /// Performs a grammatical analysis of [stringToCheck], starting at [startingOffset]. + /// + /// - [stringToCheck]: The string containing the text to be analyzed. + /// - [startingOffset]: Location within the text at which the analysis should start. + /// - [wrap]: `true` to specify that the analysis continue to the beginning of the text when + /// the end is reached. `false` to have the analysis stop at the end of the text. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [stringToCheck] in isolation, without connection to any given document. + /// {@endtemplate} + Future checkGrammar({required String stringToCheck, required int startingOffset, String? language, bool wrap = false, int inSpellDocumentWithTag = 0,}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.checkGrammar$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([stringToCheck, startingOffset, language, wrap, inSpellDocumentWithTag]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as PigeonCheckGrammarResult?)!; + } + } + + /// {@template mac_spell_checker_completions} + /// Provides a list of complete words that the user might be trying to type based on a partial word + /// at [partialWordRange] in the given [text]. + /// + /// - [partialWordRange] - The range, within the [text], for which possible completions should be generated. + /// - [text] - The string containing the partial word for which completions should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// + /// The items of the list are in the order they should be presented to the user. + /// {@endtemplate} + Future?> completions({required PigeonRange partialWordRange, required String text, String? language, int inSpellDocumentWithTag = 0,}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.completions$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([partialWordRange, text, language, inSpellDocumentWithTag]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as List?)?.cast(); + } + } + + /// {@template mac_spell_checker_count_words} + /// Returns the number of words in the specified string. + /// {@endtemplate} + Future countWords({required String text, String? language}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.countWords$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([text, language]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } + + /// {@template mac_spell_checker_learn_word} + /// Adds the [word] to the spell checker dictionary. + /// {@endtemplate} + Future learnWord(String word) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.learnWord$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([word]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// {@template mac_spell_checker_has_learned_word} + /// Indicates whether the spell checker has learned a given word. + /// {@endtemplate} + Future hasLearnedWord(String word) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.hasLearnedWord$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([word]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + + /// {@template mac_spell_checker_unlearn_word} + /// Tells the spell checker to unlearn a given word. + /// {@endtemplate} + Future unlearnWord(String word) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.unlearnWord$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([word]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// {@template mac_spell_checker_ignore_word} + /// Instructs the spell checker to ignore all future occurrences of [word] in the document + /// identified by [documentTag]. + /// {@endtemplate} + Future ignoreWord({required String word, required int documentTag}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.ignoreWord$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([word, documentTag]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// {@template mac_spell_checker_ignored_words} + /// Returns the array of ignored words for a document identified by [documentTag]. + /// {@endtemplate} + Future?> ignoredWords(int documentTag) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.ignoredWords$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([documentTag]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as List?)?.cast(); + } + } + + /// {@template mac_spell_checker_set_ignored_words} + /// Updates the ignored-words document (a dictionary identified by [documentTag] with [words]) + /// with a list of [words] to ignore. + /// {@endtemplate} + Future setIgnoredWords({required List words, required int documentTag}) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.setIgnoredWords$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([words, documentTag]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + /// {@template mac_spell_checker_user_replacements_dictionary} + /// Returns the dictionary used when replacing words, as defined by the user in the system preferences. + /// + /// This can be used to create an UI with replacement options when the user types a certain + /// combination of characters. For example, the user might want to automatically replace + /// "omw" with "on my way". When the user types "omw", an UI should display "on my way" as + /// a possible replacement. + /// {@endtemplate} + Future> userReplacementsDictionary() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.userReplacementsDictionary$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as Map?)!.cast(); + } + } +} diff --git a/super_editor_spellcheck/lib/src/platform/spell_checker.dart b/super_editor_spellcheck/lib/src/platform/spell_checker.dart new file mode 100644 index 0000000000..b661fd4486 --- /dev/null +++ b/super_editor_spellcheck/lib/src/platform/spell_checker.dart @@ -0,0 +1,91 @@ +import 'dart:ui'; + +import 'package:super_editor_spellcheck/src/platform/spell_checker_mac.dart'; + +class SuperEditorSpellCheckerPlugin { + /// Exposes the macOS spell checker methods. + final SuperEditorSpellCheckerMacPlugin macSpellChecker = SuperEditorSpellCheckerMacPlugin(); + + /// Checks the given [text] for spelling errors with the given [locale]. + /// + /// Returns a list of [TextSuggestion]s, where each span represents a + /// misspelled word, with the possible suggestions. + /// + /// Returns an empty list if no spelling errors are found or if the [locale] + /// isn't supported by the spell checker. + /// + /// If the same misspelled word is found multiple times in the text, it will be + /// included in the list multiple times, since each range is different. + Future> fetchSuggestions( + Locale locale, + String text, { + int inSpellDocumentWithTag = 0, + }) async { + final language = macSpellChecker.convertDartLocaleToMacLanguageCode(locale); + if (language?.isNotEmpty != true) { + throw Exception("The argument 'language' must not be empty"); + } + + final result = []; + if (text.isEmpty) { + // We can't look for misspelled words without a text. + return result; + } + + final availableLanguages = await macSpellChecker.availableLanguages(); + + String languageCode = language!.replaceFirst("-", "_"); + if (!availableLanguages.contains(languageCode)) { + // The given language isn't supported by the spell checker. It might be the case that + // the user has a language configured with an incompatible region. For example, + // a user might have "en-BR" configured, which means that the language is English, + // but the region is Brazil. In this case, we should try to use only the language. + languageCode = language.split("_").first; + if (!availableLanguages.contains(languageCode)) { + // The given language isn't supported by the spell checker. Fizzle. + return result; + } + } + + // The start of the substring we are looking at. + int currentOffset = 0; + while (currentOffset < text.length) { + final misspelledRange = await macSpellChecker.checkSpelling( + stringToCheck: text, + startingOffset: currentOffset, + language: languageCode, + wrap: false, + inSpellDocumentWithTag: 0, + ); + + if (misspelledRange.start == -1) { + // There are no more misspelled words in the text. + break; + } + + // We found a misspeled word. Check for suggestions. + final guesses = await macSpellChecker.guesses( + text: text, + range: misspelledRange, + language: languageCode, + inSpellDocumentWithTag: inSpellDocumentWithTag, + ); + + // Only append the suggestion span if we have suggestions. + // It wouldn't help to return a misspelled word without suggestions. + if (guesses.isEmpty == false) { + result.add( + TextSuggestion( + range: TextRange(start: misspelledRange.start, end: misspelledRange.end), + suggestions: guesses, + ), + ); + } + + // Place the offset after the current word to continue the search. + currentOffset += misspelledRange.end; + } + + return result; + } +} diff --git a/super_editor_spellcheck/lib/src/platform/spell_checker_mac.dart b/super_editor_spellcheck/lib/src/platform/spell_checker_mac.dart new file mode 100644 index 0000000000..d05f571d1f --- /dev/null +++ b/super_editor_spellcheck/lib/src/platform/spell_checker_mac.dart @@ -0,0 +1,250 @@ +import 'dart:ui'; + +import 'package:super_editor_spellcheck/src/platform/messages.g.dart'; + +/// Plugin to check spelling errors in text using the native macOS spell checker. +class SuperEditorSpellCheckerMacPlugin { + final SpellCheckMac _spellCheckApi = SpellCheckMac(); + + /// {@macro mac_spell_checker_available_languages} + Future> availableLanguages() async { + final languages = await _spellCheckApi.availableLanguages(); + + return languages // + .where((e) => e != null) + .cast() + .toList(); + } + + /// {@macro mac_spell_checker_unique_spell_document_tag} + Future uniqueSpellDocumentTag() async { + return await _spellCheckApi.uniqueSpellDocumentTag(); + } + + /// {@macro mac_spell_checker_close_spell_document} + Future closeSpellDocumentWithTag(int tag) async { + await _spellCheckApi.closeSpellDocument(tag); + } + + /// {@macro mac_spell_checker_check_spelling} + Future checkSpelling({ + required String stringToCheck, + required int startingOffset, + String? language, + bool wrap = false, + int inSpellDocumentWithTag = 0, + }) async { + final result = await _spellCheckApi.checkSpelling( + stringToCheck: stringToCheck, + startingOffset: startingOffset, + language: language, + wrap: wrap, + inSpellDocumentWithTag: inSpellDocumentWithTag, + ); + + return TextRange( + start: result.start, + end: result.end, + ); + } + + /// {@macro mac_spell_checker_guesses} + Future> guesses({ + required TextRange range, + required String text, + String? language, + int inSpellDocumentWithTag = 0, + }) async { + final result = await _spellCheckApi.guesses( + range: PigeonRange(start: range.start, end: range.end), + text: text, + language: language, + inSpellDocumentWithTag: inSpellDocumentWithTag, + ); + + if (result == null) { + return []; + } + + return result // + .where((e) => e != null) + .cast() + .toList(); + } + + /// {@macro mac_spell_checker_correction} + Future correction({ + required String text, + required TextRange range, + required String language, + int inSpellDocumentWithTag = 0, + }) async { + final result = await _spellCheckApi.correction( + text: text, + range: PigeonRange(start: range.start, end: range.end), + language: language, + inSpellDocumentWithTag: inSpellDocumentWithTag, + ); + + return result; + } + + /// {@macro mac_spell_checker_check_grammar} + Future checkGrammar({ + required String stringToCheck, + required int startingOffset, + String? language, + bool wrap = false, + int inSpellDocumentWithTag = 0, + }) async { + final result = await _spellCheckApi.checkGrammar( + stringToCheck: stringToCheck, + startingOffset: startingOffset, + language: language, + inSpellDocumentWithTag: inSpellDocumentWithTag, + wrap: wrap, + ); + + return CheckGrammarResult( + firstError: result.firstError != null + ? TextRange( + start: result.firstError!.start, + end: result.firstError!.end, + ) + : null, + details: result.details + ?.map( + (e) => GrammaticalAnalysisDetail( + range: TextRange(start: e!.range.start, end: e.range.end), + userDescription: e.userDescription, + ), + ) + .toList() ?? + [], + ); + } + + /// {@macro mac_spell_checker_completions} + Future> completions({ + required TextRange partialWordRange, + required String text, + required String language, + int inSpellDocumentWithTag = 0, + }) async { + final result = await _spellCheckApi.completions( + partialWordRange: PigeonRange(start: partialWordRange.start, end: partialWordRange.end), + text: text, + language: language, + inSpellDocumentWithTag: inSpellDocumentWithTag, + ); + + if (result == null) { + return []; + } + + return result // + .where((e) => e != null) + .cast() + .toList(); + } + + /// {@macro mac_spell_checker_count_words} + Future countWords({required String text, required String language}) async { + return await _spellCheckApi.countWords( + text: text, + language: language, + ); + } + + /// {@macro mac_spell_checker_learn_word} + Future learnWord(String word) async { + await _spellCheckApi.learnWord(word); + } + + /// {@macro mac_spell_checker_has_learned_word} + Future hasLearnedWord(String word) async { + return await _spellCheckApi.hasLearnedWord(word); + } + + /// {@macro mac_spell_checker_unlearn_word} + Future unlearnWord(String word) async { + await _spellCheckApi.unlearnWord(word); + } + + /// {@macro mac_spell_checker_ignore_word} + Future ignoreWord({required String word, required int documentTag}) async { + await _spellCheckApi.ignoreWord(word: word, documentTag: documentTag); + } + + /// {@macro mac_spell_checker_ignored_words} + Future> ignoredWords({required int documentTag}) async { + final words = await _spellCheckApi.ignoredWords(documentTag); + if (words == null) { + return []; + } + + return words // + .where((e) => e != null) + .cast() + .toList(); + } + + /// {@macro mac_spell_checker_set_ignored_words} + Future setIgnoredWords({required List words, required int documentTag}) async { + await _spellCheckApi.setIgnoredWords(words: words, documentTag: documentTag); + } + + /// {@macro mac_spell_checker_user_replacements_dictionary} + Future> userReplacementsDictionary() async { + final dict = await _spellCheckApi.userReplacementsDictionary(); + dict.removeWhere((k, v) => k == null || v == null); + + return dict.cast(); + } + + /// Converts the dart locale to macOS language code. + /// + /// For example, converts "pt-BR" to "pt_BR". + /// + /// Returns `null` if the [locale] is `null`. + String? convertDartLocaleToMacLanguageCode(Locale? locale) { + if (locale == null) { + return null; + } + + return locale.toLanguageTag().replaceAll("-", "_"); + } +} + +class CheckGrammarResult { + CheckGrammarResult({ + this.firstError, + required this.details, + }); + + final TextRange? firstError; + final List details; +} + +class GrammaticalAnalysisDetail { + GrammaticalAnalysisDetail({ + required this.range, + required this.userDescription, + }); + + final TextRange range; + + /// The description of the grammatical error that should be displayed to the user. + final String userDescription; +} + +/// A range containing a misspelled word and its suggestions. +class TextSuggestion { + TextSuggestion({ + required this.range, + required this.suggestions, + }); + + final TextRange range; + final List suggestions; +} diff --git a/super_editor_spellcheck/lib/src/super_editor/spell_checker_popover_controller.dart b/super_editor_spellcheck/lib/src/super_editor/spell_checker_popover_controller.dart new file mode 100644 index 0000000000..92fec83944 --- /dev/null +++ b/super_editor_spellcheck/lib/src/super_editor/spell_checker_popover_controller.dart @@ -0,0 +1,142 @@ +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spelling_error_suggestions.dart'; + +/// Shows/hides a popover with spelling suggestions. +/// +/// A [SpellCheckerPopoverController] must be attached to a [SpellCheckerPopoverDelegate], +/// which will effectively show/hide the popover. +class SpellCheckerPopoverController { + SpellCheckerPopoverController(); + + SpellCheckerPopoverDelegate? _delegate; + + var _orientation = SpellcheckToolbarOrientation.auto; + + /// Whether or not the popover is currently showing. + bool get isShowing => _isShowing; + bool _isShowing = false; + + /// Attaches this controller to a delegate that knows how to + /// show a popover with spelling suggestions. + /// + /// A [SpellCheckerPopoverDelegate] must call this method after + /// to register itself as the active delegate. + void attach(SpellCheckerPopoverDelegate delegate) { + // Detach from the previous delegate, if any. + detach(); + + _delegate = delegate; + _delegate!.setOrientation(_orientation); + } + + /// Detaches this controller from the delegate. + /// + /// This controller can't show/hide the popover while detached from a delegate. + /// + /// A [SpellCheckerPopoverDelegate] must call this method to unregister itself + /// when it can no longer be used. + void detach() { + _delegate?.onDetached(); + _delegate = null; + } + + /// Shows the spelling suggestions popover with the suggetions + /// provided by [SpellingError.suggestions]. + /// + /// Does nothing if [spelling] doesn't have any suggestions. + /// + /// Provide a [onDismiss] callback to be called when the popover + /// is dismissed by tapping outside of the suggestions popover. + /// For example, restoring the previous selection when the popover + /// is dismissed. + void showSuggestions( + SpellingError spelling, { + VoidCallback? onDismiss, + }) { + _delegate?.showSuggestions( + spelling, + onDismiss: onDismiss, + ); + _isShowing = true; + } + + @Deprecated("This is a temporary behavior until we generalize the control (June 19, 2025)") + void setOrientation(SpellcheckToolbarOrientation orientation) { + _orientation = orientation; + _delegate?.setOrientation(orientation); + } + + /// Hides the spelling suggestions popover if it's visible. + void hide() { + _delegate?.hideSuggestionsPopover(); + _isShowing = false; + } + + /// Finds spelling suggestions for the word at the given [wordRange]. + /// + /// Returns `null` if no suggestions are found. + SpellingError? findSuggestionsForWordAt(DocumentRange wordRange) { + return _delegate?.findSuggestionsForWordAt(wordRange); + } +} + +enum SpellcheckToolbarOrientation { + // Use whatever the standard is. + auto, + // Display toolbar above the misspelled word. + above, + // Display toolbar below the misspelled word. + below, +} + +/// Delegate that's attached to a [SpellCheckerPopoverController], to show/hide +/// a popover with spelling suggestions. +/// +/// A [SpellCheckerPopoverDelegate] must call [SpellCheckerPopoverController.attach] +/// to register itself as the active delegate, and [SpellCheckerPopoverController.detach] +/// to unregister itself when it can no longer be used. +/// +/// The popover should be displayed only upon a [showSuggestions] call. The delegate +/// should not display the popover on its own when selection changes. +abstract class SpellCheckerPopoverDelegate { + /// Called on this delegate by the [SpellCheckerPopoverController] when the controller + /// attaches to the delegate. + void onAttached(SpellCheckerPopoverController controller); + + /// Called on this delegate by the [SpellCheckerPopoverController] when the controller + /// detaches from the delegate. + /// + /// The delegate should hide the popover when detached, if it's visible. + void onDetached(); + + /// Shows the spelling suggestions popover with the suggetions + /// provided by [SpellingError.suggestions]. + /// + /// If the popover is already visible, this method should update + /// the suggestions with the new ones. + /// + /// If the document changes while the popover is visible, the popover + /// should be closed. + /// + /// This method should not update the document selection. + /// + /// Provide a [onDismiss] callback to be called when the popover + /// is dismissed by tapping outside of the suggestions popover. + /// For example, restoring the previous selection when the popover + /// is dismissed. + void showSuggestions( + SpellingError suggestions, { + VoidCallback? onDismiss, + }) {} + + @Deprecated("This is a temporary behavior until we generalize the control (June 19, 2025)") + void setOrientation(SpellcheckToolbarOrientation orientation); + + /// Hides the spelling suggestions popover if it's visible. + void hideSuggestionsPopover() {} + + /// Finds spelling suggestions for the word at the given [wordRange]. + /// + /// Returns `null` if no suggestions are found. + SpellingError? findSuggestionsForWordAt(DocumentRange wordRange) => null; +} diff --git a/super_editor_spellcheck/lib/src/super_editor/spellcheck_clock.dart b/super_editor_spellcheck/lib/src/super_editor/spellcheck_clock.dart new file mode 100644 index 0000000000..cc1df8c14f --- /dev/null +++ b/super_editor_spellcheck/lib/src/super_editor/spellcheck_clock.dart @@ -0,0 +1,399 @@ +import 'dart:async'; + +import 'package:clock/clock.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/scheduler.dart'; + +/// A clock, which is used by the Super Editor spellcheck system to schedule spellchecks, +/// such as shortly after a use has stopped typing. +/// +/// This implementation of a clock exists primarily so that spellcheck delays can be run in +/// widget tests. This clock defaults to using [Timer]s for production performance, however it can be +/// configured to use a [Ticker] and post frame callbacks for testing purposes. +abstract class SpellcheckClock { + /// Creates a [SpellcheckClock] that monitors time with [Timer]s, which is ideal for production, + /// so that frames aren't needlessly pumped by something like a [Ticker]. + factory SpellcheckClock.forProduction() { + return _TimerSpellcheckClock(); + } + + /// Creates a [SpellcheckClock] that simulates time passage with post frame callbacks, and forces frame pumping + /// with a [Ticker], both of which are important behaviors within widget tests. + static WidgetTestSpellcheckClock forTesting(TickerProvider tickerProvider, [DateTime? startTime]) { + return WidgetTestSpellcheckClock(tickerProvider, startTime); + } + + void dispose(); + + /// The current time, according to this [StopwatchClock]. + DateTime get now; + + /// Runs [callback] at the given [time], or as close to it at this [StopwatchClock] can monitor. + SpellcheckAlarm createAlarm(DateTime time, VoidCallback callback); + + /// Runs [callback] after a delay of [duration], or as close to it as this [StopwatchClock] can monitor. + SpellcheckTimer createTimer(Duration duration, VoidCallback callback); +} + +abstract class SpellcheckAlarm { + bool get isActive; + + DateTime get time; + + void cancel(); +} + +abstract class SpellcheckTimer { + bool get isActive; + + Duration get duration; + + void cancel(); +} + +/// A [SpellcheckClock] that uses [Timer]s to monitor the passage of time. +class _TimerSpellcheckClock implements SpellcheckClock { + _TimerSpellcheckClock() : _clock = const Clock(); + + @override + void dispose() { + for (final timer in _timers) { + timer.cancel(); + } + _timers.clear(); + } + + final Clock _clock; + + final _timers = {}; + + @override + DateTime get now => _clock.now(); + + @override + SpellcheckAlarm createAlarm(DateTime time, VoidCallback callback) { + final timeNow = now; + if (time.compareTo(timeNow) < 0) { + throw Exception( + "Tried to schedule a callback for time $time, but the current time has already passed that: $now", + ); + } + + final timer = Timer(time.difference(timeNow), _createTimerCallback(callback)); + _timers.add(timer); + + return _TimerSpellcheckAlarm(timer, time, _clearExpiredTimers); + } + + @override + SpellcheckTimer createTimer(Duration duration, VoidCallback callback) { + final timer = Timer(duration, _createTimerCallback(callback)); + _timers.add(timer); + + return _TimerSpellcheckTimer(timer, duration, _clearExpiredTimers); + } + + VoidCallback _createTimerCallback(VoidCallback realCallback) { + return () { + _timers.removeWhere((timer) => !timer.isActive); + realCallback(); + }; + } + + void _clearExpiredTimers() { + _timers.removeWhere((timer) => !timer.isActive); + } +} + +class _TimerSpellcheckAlarm implements SpellcheckAlarm { + _TimerSpellcheckAlarm(this._timer, this.time, this._onCancel); + + final Timer _timer; + final VoidCallback _onCancel; + + @override + final DateTime time; + + @override + bool get isActive => _timer.isActive; + + @override + void cancel() { + _timer.cancel(); + _onCancel(); + } +} + +class _TimerSpellcheckTimer implements SpellcheckTimer { + _TimerSpellcheckTimer(this._timer, this.duration, this._onCancel); + + final Timer _timer; + final VoidCallback _onCancel; + + @override + final Duration duration; + + @override + bool get isActive => _timer.isActive; + + @override + void cancel() { + _timer.cancel(); + _onCancel(); + } +} + +/// A [SpellcheckClock] that users a [Ticker] and post frame callbacks to simulate the passage of +/// time, and notify alarms and timers. +/// +/// The simulated time in a [WidgetTestSpellcheckClock] moves forward on every Flutter frame, by whatever +/// amount of time is reported by Flutter. In a widget test, these post frame callbacks should report whatever +/// time was specified in a call to `pump()`. Or, if `pumpAndSettle()` is used, each internal pump should report +/// a small passage of time, reflecting a simulated frame time. +/// +/// As long as there's at least one registered alarm or timer, this clock also registers a [Ticker] so that +/// Flutter keeps pumping frames. This is important because the simulated time only moves forward as long as Flutter +/// pumps frames, so if frames stop pumping, time stops moving. This [Ticker] start/stop behavior happens +/// automatically within this clock. +/// +/// ## Manually Stopping the Ticker +/// There's an edge case in tests where a developer might need to explicitly turn off the [Ticker] in this +/// clock. Consider the following test code, which types a character and then verifies that spell check only +/// runs after a delay: +/// +/// await tester.insertImeText("F"); +/// expect(spellchecker.lastSubmission, null); +/// +/// await tester.pump(const Duration(seconds: 1)); +/// expect(spellchecker.lastSubmission, "F"); +/// +/// In the example above, we want to make sure that spell check doesn't run immediately after +/// inserting "F". Instead, we expect spell check to run a second later. Therefore, within the spell +/// check system is a [SpellcheckClock] that's waiting for one second to pass. +/// +/// But the call to `insertImeText()` includes a call to `pumpAndSettle()`. Because of the +/// [WidgetTestSpellcheckClock], `pumpAndSettle()` will keep pumping over and over until one second +/// of simulated time has passed, then run spell check, then return. As a result, we can't verify +/// that spell check **didn't** run immediately, because the `pumpAndSettle()` didn't return until +/// after running the spell check timer. +/// +/// To work around these situations where `pumpAndSettle()` is outside your control, this clock +/// has [pauseAutomaticFramePumping] and [resumeAutomaticFramePumping]. When paused, this clock stops +/// pumping frames with its [Ticker]. As a result, calls to `pumpAndSettle()` aren't held up by any +/// alarms or timers. When the immediate test expectations are done, calling [resumeAutomaticFramePumping] +/// will once again start pumping frames with its [Ticker], continuing as before. +/// +/// When this clock pauses its [Ticker], it still listens to post frame callbacks. Therefore, calls to +/// `pumpAndSettle()` and `pump()` will continue to move the simulated time forward. +class WidgetTestSpellcheckClock implements SpellcheckClock { + /// Creates a [SpellcheckClock] that begins with a [startTime] and then adds time to it for every frame + /// that Flutter pumps in a test. + /// + /// The amount of time added to this clock in a given frame is equal to whatever frame time Flutter reports + /// to a post frame callback. + WidgetTestSpellcheckClock(TickerProvider tickerProvider, [DateTime? startTime]) : _tickerProvider = tickerProvider { + _startTime = startTime ?? DateTime.now(); + + // Note: `_onFrame` will re-register itself for all future post-frame callbacks, until stopped. + WidgetsBinding.instance.addPostFrameCallback(_onFrame); + } + + @override + void dispose() { + _isDisposed = true; + _isFramePumpingPaused = true; + _ticker?.dispose(); + _tickerAlarmsAndTimers.clear(); + } + + var _isDisposed = false; + + /// The simulated time when this clock was created. + /// + /// For example, a test might want to always begin at 9PM on June 2nd. That would be the [_startTime]. + late final DateTime _startTime; + + /// The epoch timestamp reported by Flutter on our very first frame, which is then used to + /// calculate elapsed time from that point forward. + Duration? _frameReferenceTime; + + /// The simulated duration since the creation of this clock, which is calculated by taking the latest + /// Flutter epoch timestamp from a post frame callback, and subtracting [_frameReferenceTime]. + var _elapsedTime = Duration.zero; + + final TickerProvider? _tickerProvider; + Ticker? _ticker; + var _isFramePumpingPaused = false; + + final _tickerAlarmsAndTimers = <_WidgetTestSpellcheckTimeEvent>{}; + + /// The current time, according to this [StopwatchClock]. + @override + DateTime get now => _startTime.add(_elapsedTime); + + /// Runs [callback] at the given [time], or as close to it at this [StopwatchClock] can monitor. + @override + SpellcheckAlarm createAlarm(DateTime time, VoidCallback callback) { + final timeNow = now; + if (time.compareTo(timeNow) < 0) { + throw Exception( + "Tried to schedule a callback for time $time, but the current time has already passed that: $now", + ); + } + + _ticker ??= _tickerProvider!.createTicker(_onTick); + + final alarm = _WidgetTestSpellcheckTimeEvent.alarm( + time, + callback, + _clearExpiredAlarmsAndTimers, + ); + _tickerAlarmsAndTimers.add(alarm); + + _startPumpingFramesIfNeeded(); + + return alarm; + } + + /// Runs [callback] after a delay of [duration], or as close to it as this [StopwatchClock] can monitor. + @override + SpellcheckTimer createTimer(Duration duration, VoidCallback callback) { + _ticker ??= _tickerProvider!.createTicker(_onTick); + + final timer = _WidgetTestSpellcheckTimeEvent.timer( + now, + duration, + callback, + _clearExpiredAlarmsAndTimers, + ); + _tickerAlarmsAndTimers.add(timer); + + _startPumpingFramesIfNeeded(); + + return timer; + } + + void _clearExpiredAlarmsAndTimers() { + _tickerAlarmsAndTimers.removeWhere((timeEvent) => !timeEvent.isActive); + } + + /// Stops this clock from scheduling more frames, even if alarms and timers are scheduled. + /// + /// Pausing frames is useful in situations where a `pumpAndSettle()` is unavoidable, but you + /// don't want this clock to keep pumping frames until the alarms and timers go off. + void pauseAutomaticFramePumping() { + _isFramePumpingPaused = true; + if (true != _ticker?.isActive) { + return; + } + + _ticker!.stop(); + } + + /// Starts pumping frames again, after an earlier call to [pauseAutomaticFramePumping]. + /// + /// Only starts pumping frames if at least one alarm or timer is pending. + void resumeAutomaticFramePumping() { + _isFramePumpingPaused = false; + if (_ticker == null || _ticker!.isActive || _tickerAlarmsAndTimers.isEmpty) { + return; + } + + _ticker!.start(); + } + + void _startPumpingFramesIfNeeded() { + if (!_isFramePumpingPaused && _ticker != null && !_ticker!.isActive && _tickerAlarmsAndTimers.isNotEmpty) { + _ticker!.start(); + } + } + + void _onTick(Duration elapsedTime) { + // No-op: The Ticker only ticks to ensure that `pumpAndSettle()` keeps running when + // we have outstanding alarms and timers. + } + + void _onFrame(Duration timeStamp) { + if (_frameReferenceTime == null) { + _frameReferenceTime = timeStamp; + } else { + _elapsedTime = timeStamp - _frameReferenceTime!; + } + + // Run all alarms and timers that have reached their goal time. + final nowTime = now; + final toRemove = <_WidgetTestSpellcheckTimeEvent>{}; + final alarmsAndTimersCopy = Set.from(_tickerAlarmsAndTimers); + for (final alarmOrTimer in alarmsAndTimersCopy) { + if (alarmOrTimer.time.compareTo(nowTime) <= 0) { + alarmOrTimer.execute(); + toRemove.add(alarmOrTimer); + } + } + + // Remove all expired alarms and timers. + _tickerAlarmsAndTimers.removeAll(toRemove); + + // If we have no more alarms or timers waiting, then stop the ticker, so that + // calls to `pumpAndSettle()` can finish. + if (_tickerAlarmsAndTimers.isEmpty) { + _ticker?.stop(); + } + + if (!_isDisposed) { + // Always register another post frame callback. This won't cause a frame to be scheduled, + // but it ensures that we're made aware of the next frame, whenever it arrives. + // + // In our simulated time logic, we add Flutter's reported frame time to the current time. This + // is what causes our internal simulated time to move forward. By doing this on every frame, we + // ensure that our simulated time increases on every frame, which should eventually trigger any + // pending alarms and/or timers, even when `pumpAndSettle()` is called. + // + // This automatic time increase should also tend to keep the simulated time in line with other + // simulated timing in tests. Without this, other aspects of test might `pump()` some amount of + // time into the future, while our simulated time remains in the past. While we can't expect this + // clock to magically match other unrelated clocks in a widget test, it's desirable that this clock + // roughly move forward at a similar rate to other clocks. + WidgetsBinding.instance.addPostFrameCallback(_onFrame); + } + } +} + +class _WidgetTestSpellcheckTimeEvent implements SpellcheckAlarm, SpellcheckTimer { + _WidgetTestSpellcheckTimeEvent.alarm(DateTime alarmTime, this._onAlarmOrTimer, this._onCancel) { + _startTime = DateTime.now(); + duration = alarmTime.difference(_startTime); + } + + _WidgetTestSpellcheckTimeEvent.timer(this._startTime, this.duration, this._onAlarmOrTimer, this._onCancel) { + time = _startTime.add(duration); + } + + late final DateTime _startTime; + final VoidCallback _onAlarmOrTimer; + final VoidCallback _onCancel; + var _isActive = true; + + @override + late final DateTime time; + + @override + late final Duration duration; + + @override + bool get isActive => _isActive; + + void execute() { + _onAlarmOrTimer(); + _isActive = false; + } + + @override + void cancel() { + _isActive = false; + _onCancel(); + } + + @override + String toString() => "[_WidgetTestSpellcheckTimeEvent] - start: $_startTime, alarm time: $time ($hashCode)"; +} diff --git a/super_editor_spellcheck/lib/src/super_editor/spelling_and_grammar_plugin.dart b/super_editor_spellcheck/lib/src/super_editor/spelling_and_grammar_plugin.dart new file mode 100644 index 0000000000..a98567fb01 --- /dev/null +++ b/super_editor_spellcheck/lib/src/super_editor/spelling_and_grammar_plugin.dart @@ -0,0 +1,1207 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_spellcheck/src/platform/spell_checker.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spell_checker_popover_controller.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spellcheck_clock.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spelling_error_suggestion_overlay.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spelling_error_suggestions.dart'; + +/// A [SuperEditorPlugin] that checks spelling and grammar across a [Document], +/// underlining spelling and grammar mistakes, and offering corrections. +/// +/// This plugin works on Android, iOS, and macOS. +class SpellingAndGrammarPlugin extends SuperEditorPlugin { + static const spellingErrorSuggestionsKey = "SpellingAndGrammarPlugin.spellingErrorSuggestions"; + + /// Creates a new [SpellingAndGrammarPlugin]. + /// + /// - [isSpellingCheckEnabled]: determines whether spelling checks are initially enabled. This + /// can be toggled at runtime by setting the value of [isSpellCheckEnabled]. + /// - [spellingErrorUnderlineStyle]: the style of underline to apply to misspelled words. + /// - [isGrammarCheckEnabled]: determines whether grammar checks are initially enabled. This + /// can be toggled at runtime by setting the value of [isGrammarCheckEnabled]. + /// - [grammarErrorUnderlineStyle]: the style of underline to apply to grammar errors. + /// - [toolbarBuilder]: builds the toolbar for showing suggestions for misspelled words. + /// - [selectedWordHighlightColor]: the color to use when highlighting the selected word, + /// if it's a misspelled word. + /// - [androidControlsController]: the controls controller to use when running on Android. This + /// is required when running on Android. + /// - [iosControlsController]: the controls controller to use when running on iOS. This is + /// required when running on iOS. + /// - [ignoreRules]: a list of rules that determine ranges that should be ignored from spellchecking. + /// It can be used, for example, to ignore links or text with specific attributions. See [SpellingIgnoreRules] + /// for a list of built-in rules. + /// - [spellCheckService]: a spell check service to use for spell checking. If this is provided, + /// the plugin will use this service instead of the default spell check service. The default spell checker + /// supports macOS, Android, and iOS. + /// - [grammarCheckService]: a grammar check service to use for grammar checking. If this is provided, + /// the plugin will use this service instead of the default grammar check service. The default grammar checker + /// supports macOS only. + SpellingAndGrammarPlugin({ + bool isSpellingCheckEnabled = true, + UnderlineStyle spellingErrorUnderlineStyle = defaultSpellingErrorUnderlineStyle, + bool isGrammarCheckEnabled = true, + UnderlineStyle grammarErrorUnderlineStyle = defaultGrammarErrorUnderlineStyle, + Duration spellCheckDelayAfterEdit = Duration.zero, + SpellingErrorSuggestionToolbarBuilder toolbarBuilder = defaultSpellingSuggestionToolbarBuilder, + Color? selectedWordHighlightColor, + SuperEditorAndroidControlsController? androidControlsController, + SuperEditorIosControlsController? iosControlsController, + List ignoreRules = const [], + SpellCheckService? spellCheckService, + GrammarCheckService? grammarCheckService, + SpellcheckClock? clock, + }) : _isSpellCheckEnabled = isSpellingCheckEnabled, + _isGrammarCheckEnabled = isGrammarCheckEnabled, + _spellCheckDelayAfterEdit = spellCheckDelayAfterEdit { + assert(defaultTargetPlatform != TargetPlatform.android || androidControlsController != null, + 'The androidControlsController must be provided when running on Android.'); + + assert(defaultTargetPlatform != TargetPlatform.iOS || iosControlsController != null, + 'The iosControlsController must be provided when running on iOS.'); + + _clock = clock ?? SpellcheckClock.forProduction(); + + _spellCheckService = spellCheckService ?? + switch (defaultTargetPlatform) { + TargetPlatform.macOS => MacSpellCheckService(), + TargetPlatform.android || TargetPlatform.iOS => DefaultSpellCheckService(), + _ => null, + }; + + _grammarCheckService = grammarCheckService ?? + switch (defaultTargetPlatform) { + TargetPlatform.macOS => MacGrammarCheckService(), + _ => null, + }; + + documentOverlayBuilders = [ + SpellingErrorSuggestionOverlayBuilder( + _spellingErrorSuggestions, + _selectedWordLink, + popoverController: _popoverController, + toolbarBuilder: toolbarBuilder, + ), + ]; + + _styler = SpellingAndGrammarStyler( + selectionHighlightColor: selectedWordHighlightColor ?? + (defaultTargetPlatform == TargetPlatform.android // + ? Colors.red.withValues(alpha: 0.3) + : null), + ); + + _ignoreRules = ignoreRules; + + _contentTapHandler = switch (defaultTargetPlatform) { + TargetPlatform.android => SuperEditorAndroidSpellCheckerTapHandler( + popoverController: _popoverController, + controlsController: androidControlsController!, + styler: _styler, + ), + TargetPlatform.iOS => SuperEditorIosSpellCheckerTapHandler( + popoverController: _popoverController, + controlsController: iosControlsController!, + styler: _styler, + ), + _ => _SuperEditorDesktopSpellCheckerTapHandler(popoverController: _popoverController), + }; + } + + late final SpellcheckClock _clock; + + /// A service that provides spell checking functionality. + late final SpellCheckService? _spellCheckService; + + /// A service that provides grammar checking functionality. + late final GrammarCheckService? _grammarCheckService; + + /// The time to wait after a user edit before running the spelling and grammar check. + late final Duration _spellCheckDelayAfterEdit; + + final _spellingErrorSuggestions = SpellingErrorSuggestions(); + + late final SpellingAndGrammarStyler _styler; + + /// Leader attached to an invisible rectangle around the currently selected + /// misspelled word. + final _selectedWordLink = LeaderLink(); + + late final List _ignoreRules; + + late SpellingAndGrammarReaction _reaction; + + /// Whether this reaction checks spelling in the document. + bool get isSpellCheckEnabled => _isSpellCheckEnabled; + bool _isSpellCheckEnabled; + set isSpellCheckEnabled(bool isEnabled) { + _isSpellCheckEnabled = isEnabled; + _reaction.isSpellCheckEnabled = isEnabled; + } + + /// The [UnderlineStyle] applied to words of text that are mis-spelled. + set spellingErrorUnderlineStyle(UnderlineStyle style) => _styler.spellingErrorUnderlineStyle = style; + + /// Whether this reaction checks grammar in the document. + bool get isGrammarCheckEnabled => _isGrammarCheckEnabled; + bool _isGrammarCheckEnabled; + set isGrammarCheckEnabled(bool isEnabled) { + _isGrammarCheckEnabled = isEnabled; + _reaction.isGrammarCheckEnabled = isEnabled; + } + + /// The [UnderlineStyle] applied to runs of text with incorrect grammar. + set grammarErrorUnderlineStyle(UnderlineStyle style) => _styler.grammarErrorUnderlineStyle = style; + + /// A [SuperEditor] style phase that applies spelling error and grammar error + /// underlines to text in the document. + SpellingAndGrammarStyler get styler => _styler; + + /// [SuperEditor] overlay widgets that should be added to the [SuperEditor] this + /// plugin is attached to. + @override + late final List documentOverlayBuilders; + + @override + List get contentTapHandlers => _contentTapHandler != null // + ? [_contentTapHandler!] + : const []; + late final _SpellCheckerContentTapDelegate? _contentTapHandler; + + final _popoverController = SpellCheckerPopoverController(); + + @Deprecated("This is a temporary behavior until we generalize the control (June 19, 2025)") + void setToolbarOrientation(SpellcheckToolbarOrientation orientation) => _popoverController.setOrientation( + orientation, + ); + + @override + List get appendedStylePhases => [_styler]; + + @override + void attach(Editor editor) { + editor.context.put(spellingErrorSuggestionsKey, _spellingErrorSuggestions); + _contentTapHandler?.editor = editor; + + _reaction = SpellingAndGrammarReaction( + _spellingErrorSuggestions, + _styler, + _ignoreRules, + _spellCheckService!, + _grammarCheckService, + spellCheckDelayAfterEdit: _spellCheckDelayAfterEdit, + clock: _clock, + ); + editor.reactionPipeline.add(_reaction); + + // Do initial spelling and grammar analysis, in case the document already + // contains some content. + _reaction.analyzeWholeDocument(editor.context); + } + + @override + void detach(Editor editor) { + _styler.clearAllErrors(); + editor.reactionPipeline.remove(_reaction); + _reaction.dispose(); + _contentTapHandler?.editor = null; + + editor.context.remove(spellingErrorSuggestionsKey, _spellingErrorSuggestions); + _spellingErrorSuggestions.clear(); + } +} + +extension SpellingAndGrammarEditableExtensions on EditContext { + SpellingErrorSuggestions get spellingErrorSuggestions => find( + SpellingAndGrammarPlugin.spellingErrorSuggestionsKey, + ); + + SpellingErrorSuggestions? get maybeSpellingErrorSuggestions => findMaybe( + SpellingAndGrammarPlugin.spellingErrorSuggestionsKey, + ); +} + +extension SpellingAndGrammarEditorExtensions on Editor { + /// Deletes the text within the given [wordRange] and replaces it with + /// the given [correctSpelling]. + void fixMisspelledWord(DocumentRange wordRange, String correctSpelling) { + execute([ + // Move caret to start of mis-spelled word so that we ensure the + // caret location is legitimate after deleting the word. E.g., + // consider what would happen if the mis-spelled word is the last + // word in the given paragraph. + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: wordRange.start, + ), + SelectionChangeType.alteredContent, + SelectionReason.contentChange, + ), + // Delete the mis-spelled word. + DeleteContentRequest( + documentRange: DocumentRange( + start: wordRange.start, + end: wordRange.end.copyWith( + nodePosition: TextNodePosition( + // +1 to make end of range exclusive. + offset: (wordRange.end.nodePosition as TextNodePosition).offset + 1, + ), + ), + ), + ), + // Insert the correctly spelled word. + InsertTextRequest( + documentPosition: wordRange.start, + textToInsert: correctSpelling, + attributions: {}, + ), + // Make the composing region to start at the end of the corrected word. Otherwise, + // the software keyboard will keep the misspelled word bounds as the composing region. + ChangeComposingRegionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: wordRange.start.nodeId, + nodePosition: TextNodePosition( + offset: (wordRange.start.nodePosition as TextNodePosition).offset + correctSpelling.length, + ), + ), + ), + ), + ]); + } + + void removeMisspelledWord(DocumentRange wordRange) { + execute([ + // Move caret to start of mis-spelled word so that we ensure the + // caret location is legitimate after deleting the word. E.g., + // consider what would happen if the mis-spelled word is the last + // word in the given paragraph. + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: wordRange.start, + ), + SelectionChangeType.alteredContent, + SelectionReason.contentChange, + ), + // Delete the mis-spelled word. + DeleteContentRequest( + documentRange: DocumentRange( + start: wordRange.start, + end: wordRange.end.copyWith( + nodePosition: TextNodePosition( + // +1 to make end of range exclusive. + offset: (wordRange.end.nodePosition as TextNodePosition).offset + 1, + ), + ), + ), + ), + const ClearComposingRegionRequest(), + ]); + } +} + +/// An [EditReaction] that runs spelling and grammar checks on all [TextNode]s +/// in a given [Document]. +class SpellingAndGrammarReaction implements EditReaction { + SpellingAndGrammarReaction( + this._suggestions, + this._styler, + this._ignoreRules, + this._spellCheckService, + this._grammarCheckService, { + Duration spellCheckDelayAfterEdit = Duration.zero, + SpellcheckClock? clock, + }) : _spellCheckDelayAfterEdit = spellCheckDelayAfterEdit { + _clock = clock ?? SpellcheckClock.forProduction(); + } + + void dispose() { + _delayedChecks.clear(); + + _delayedChecksTimer?.cancel(); + _delayedChecksTimer = null; + } + + late final SpellcheckClock _clock; + + final SpellingErrorSuggestions _suggestions; + + final SpellingAndGrammarStyler _styler; + + final List _ignoreRules; + + final SpellCheckService _spellCheckService; + + final GrammarCheckService? _grammarCheckService; + + bool isSpellCheckEnabled = true; + + set spellingErrorUnderlineStyle(UnderlineStyle style) => _styler.spellingErrorUnderlineStyle = style; + + bool isGrammarCheckEnabled = true; + + set grammarErrorUnderlineStyle(UnderlineStyle style) => _styler.grammarErrorUnderlineStyle = style; + + /// An amount of time to wait after a content edit, before running a spell check. + /// + /// For example, with a delay of 500ms, as the user types, spell check doesn't run + /// until the user stops typing for 500ms. + final Duration _spellCheckDelayAfterEdit; + + /// The time at which various nodes should be checked for spelling and grammar. + /// + /// This map is used to orchestrate delayed spelling and grammar checks. + final _delayedChecks = {}; + + /// A [Timer] that's scheduled when a spelling and grammar check is desired, which + /// then runs the actual spelling and grammar check after [_spellCheckDelayAfterEdit]. + /// + /// There may be many waiting checks, each with a different desired check time. This timer + /// is scheduled for the nearest desired check time. + SpellcheckTimer? _delayedChecksTimer; + + /// A map from a document node to the ID of the most recent spelling and grammar + /// check request ID. + /// + /// This map is used to ignore spell and grammar check responses that arrive after + /// later spelling and grammar checks. This is a concern because we cross an async + /// boundary to the platform to run such checks, removing any guarantee about order + /// of receipt. + final _asyncRequestIds = {}; + + /// Checks every [TextNode] in the given document for spelling and grammar + /// errors and stores them for visual styling. + void analyzeWholeDocument(EditContext editorContext) { + for (final node in editorContext.document) { + if (node is! TextNode) { + continue; + } + + _findSpellingAndGrammarErrors(node); + } + } + + @override + void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { + // No-op - spelling and grammar checks style the document, they don't alter the document. + } + + @override + void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { + if (kIsWeb || + !const [TargetPlatform.macOS, TargetPlatform.android, TargetPlatform.iOS].contains(defaultTargetPlatform)) { + // We currently only support spell check when running on Mac desktop or mobile platforms. + return; + } + + // Clear our request cache for any nodes that were deleted. + // Also clear suggestions for deleted nodes. + for (final event in changeList) { + if (event is! DocumentEdit) { + continue; + } + + final change = event.change; + if (change is! NodeRemovedEvent) { + continue; + } + + _suggestions.clearNode(change.nodeId); + _asyncRequestIds.remove(change.nodeId); + } + + if (!isSpellCheckEnabled && !isGrammarCheckEnabled) { + return; + } + + final document = editorContext.document; + + final textChanges = {}; + for (final event in changeList) { + if (event is! DocumentEdit) { + continue; + } + + final change = event.change; + if (change is! NodeChangeEvent) { + continue; + } + + final node = document.getNodeById(change.nodeId); + if (node is! TextNode) { + continue; + } + + // A TextNode was changed in some way. Queue it for spelling and grammar checks. + textChanges.add(change); + } + + for (final change in textChanges) { + final textNode = document.getNodeById(change.nodeId); + if (textNode == null) { + editorSpellingAndGrammarLog.warning( + "A TextNode that was listed as changed in a transaction somehow disappeared from the Document before this Reaction ran."); + continue; + } + if (textNode is! TextNode) { + editorSpellingAndGrammarLog.warning( + "A TextNode that was listed as changed in a transaction somehow became a non-text node before this Reaction ran."); + continue; + } + + if (change is TextInsertionEvent) { + _updateExistingErrorsAfterTextInsertion(change); + } else if (change is TextDeletedEvent) { + _updateExistingErrorsAfterTextDeletion(change); + } + + _scheduleSpellingAndGrammarCheck(textNode); + } + } + + void _updateExistingErrorsAfterTextInsertion(TextInsertionEvent change) { + final textPushbackStart = change.offset; + final pushbackAmount = change.text.length; + final previousErrors = _styler.getErrorsForNode(change.nodeId); + final updatedErrors = {}; + + for (final previousError in previousErrors) { + if (previousError.range.start < textPushbackStart) { + // This error wasn't impacted by the text insertion. + updatedErrors.add(previousError); + continue; + } + + // Push this error back by the insertion amount. + updatedErrors.add( + TextError( + nodeId: previousError.nodeId, + type: previousError.type, + value: previousError.value, + range: TextRange( + start: previousError.range.start + pushbackAmount, + end: previousError.range.end + pushbackAmount, + ), + ), + ); + } + + _styler.clearErrorsForNode(change.nodeId); + _styler.addErrors(change.nodeId, updatedErrors); + } + + void _updateExistingErrorsAfterTextDeletion(TextDeletedEvent change) { + // Remove errors that overlap the deleted text. + _clearErrorForDeletedRange(change); + + // Find all downstream errors and move them up by the deletion amount. + final textPushUpStart = change.offset + change.deletedText.length; + final pushUpAmount = change.deletedText.length; + final previousErrors = _styler.getErrorsForNode(change.nodeId); + final updatedErrors = {}; + + for (final previousError in previousErrors) { + if (previousError.range.start < textPushUpStart) { + // This error wasn't impacted by the text insertion. + updatedErrors.add(previousError); + continue; + } + + // Push this error up by the deletion amount. + updatedErrors.add( + TextError( + nodeId: previousError.nodeId, + type: previousError.type, + value: previousError.value, + range: TextRange( + start: previousError.range.start - pushUpAmount, + end: previousError.range.end - pushUpAmount, + ), + ), + ); + } + + _styler.clearErrorsForNode(change.nodeId); + _styler.addErrors(change.nodeId, updatedErrors); + } + + /// Clears any pre-existing error for any word that was partially or entirely deleted by the given + /// [deletion] change. + void _clearErrorForDeletedRange(TextDeletedEvent deletion) { + final errors = _styler.getErrorsForNode(deletion.nodeId); + final errorsToClear = {}; + for (final error in errors) { + final deletedRange = TextRange(start: deletion.offset, end: deletion.offset + deletion.deletedText.length); + final errorRange = error.range; + if (errorRange.start >= deletedRange.start && errorRange.start <= deletedRange.end || + errorRange.end >= deletedRange.start && errorRange.end <= deletedRange.end) { + errorsToClear.add(error); + } + } + + _styler.clearSomeErrorsForNode(deletion.nodeId, errorsToClear); + } + + void _scheduleSpellingAndGrammarCheck(TextNode textNode) { + if (_spellCheckDelayAfterEdit == Duration.zero) { + // The user doesn't want any delay. Run spell and grammar check immediately. + _findSpellingAndGrammarErrors(textNode); + return; + } + + // The user wants a delay before running spelling and grammar checks. Schedule + // this node for a check after a delay. + _delayedChecks[textNode.id] = (_clock.now.add(_spellCheckDelayAfterEdit), textNode); + + // Schedule a timer for the next delayed check. + _delayedChecksTimer ??= _clock.createTimer(_spellCheckDelayAfterEdit, _runCheckAfterDelay); + } + + void _runCheckAfterDelay() { + // Find all nodes that haven't changed in the delayed amount of time. + final now = _clock.now; + final waitingNodes = _delayedChecks.keys.toList(growable: false); + final nodesToCheck = {}; + for (final nodeId in waitingNodes) { + if (now.isAfter(_delayedChecks[nodeId]!.$1)) { + nodesToCheck.add(_delayedChecks[nodeId]!.$2); + } + } + + // Check each node that has exceeded the delay. + for (final textNode in nodesToCheck) { + _delayedChecks.remove(textNode.id); + _findSpellingAndGrammarErrors(textNode); + } + + // Schedule the next timer if there are still nodes waiting to be checked. + if (_delayedChecks.isNotEmpty) { + _delayedChecksTimer = _clock.createTimer(_findNextDelayedCheckDuration(), _runCheckAfterDelay); + } else { + _delayedChecksTimer = null; + } + } + + Duration _findNextDelayedCheckDuration() { + var nearest = _delayedChecks.entries.first.value.$1; + for (final entry in _delayedChecks.entries) { + if (entry.value.$1.isBefore(nearest)) { + nearest = entry.value.$1; + } + } + + final timeDifference = nearest.difference(_clock.now); + if (timeDifference <= Duration.zero) { + // This shouldn't happen, but the clock has already passed + // at least one desired check time. Schedule an immediate + // timer. + return Duration.zero; + } + + return timeDifference; + } + + Future _findSpellingAndGrammarErrors(TextNode textNode) async { + final textErrors = {}; + final spellingSuggestions = {}; + + final redactedText = _filterIgnoredRanges(textNode); + if (redactedText.isEmpty) { + // On Android it appears that running spell check on an empty string breaks + // spell check for the remainder of the app session, so don't even try. + // https://github.com/superlistapp/super_editor/issues/2640 + + // Since we're not running a check on any text in this node, clear any previously + // reported errors for this node. + _styler.clearErrorsForNode(textNode.id); + + return; + } + + // Track this spelling and grammar request to make sure we don't process + // the response out of order with other requests. + _asyncRequestIds[textNode.id] ??= 0; + final requestId = _asyncRequestIds[textNode.id]! + 1; + _asyncRequestIds[textNode.id] = requestId; + + if (isSpellCheckEnabled) { + // Android can't execute concurrent spell checks and returns `null` when we try to run a 2nd+ spell check + // at the same time. We'll retry our spell check up to this number of times before giving up. + // https://github.com/superlistapp/super_editor/issues/2640 + const maxTryCount = 5; + + List? suggestions; + int tryCount = 0; + do { + suggestions = await _spellCheckService.fetchSpellCheckSuggestions( + PlatformDispatcher.instance.locale, + redactedText, + ); + tryCount += 1; + } while (suggestions == null && tryCount < maxTryCount); + + if (suggestions != null) { + for (final suggestion in suggestions) { + final misspelledWord = redactedText.substring(suggestion.range.start, suggestion.range.end); + spellingSuggestions[suggestion.range] = SpellingError( + word: misspelledWord, + nodeId: textNode.id, + range: suggestion.range, + suggestions: suggestion.suggestions, + ); + textErrors.add( + TextError.spelling( + nodeId: textNode.id, + range: suggestion.range, + value: misspelledWord, + suggestions: suggestion.suggestions, + ), + ); + } + } + } + + if (isGrammarCheckEnabled && _grammarCheckService != null) { + final grammarErrors = await _grammarCheckService!.checkGrammar( + PlatformDispatcher.instance.locale, + redactedText, + ); + + if (grammarErrors != null) { + for (final grammarError in grammarErrors) { + final errorRange = grammarError.range; + final text = redactedText.substring(errorRange.start, errorRange.end); + textErrors.add( + TextError.grammar( + nodeId: textNode.id, + range: errorRange, + value: text, + ), + ); + } + } + } + + if (requestId != _asyncRequestIds[textNode.id]) { + // Another request was started for this node while we were running our + // request. Fizzle. + return; + } + // Reset the request ID counter to zero so that we avoid increasing infinitely. + _asyncRequestIds[textNode.id] = 0; + + // Display underlines on spelling and grammar errors. + _styler + ..clearErrorsForNode(textNode.id) + ..addErrors(textNode.id, textErrors); + + // Update the shared repository of spelling suggestions so that the user can + // see suggestions and select them. + _suggestions.putSuggestions(textNode.id, spellingSuggestions); + } + + /// Filters out ranges that should be ignored from spellchecking. + /// + /// This method replaces the ignored ranges with whitespaces so that the spellchecker + /// doesn't see them. + String _filterIgnoredRanges(TextNode node) { + final ranges = _ignoreRules // + .map((rule) => rule(node)) + .expand((listOfRanges) => listOfRanges) + .toList(); + + final text = node.text.toPlainText(); + + if (ranges.isEmpty) { + // We don't have any ranges to remove, short circuit. + return text; + } + if (ranges.length == 1 && ranges.first.start == 0 && ranges.first.end >= text.length) { + // We want to ignore all of the text in this node. + return ""; + } + + final buffer = StringBuffer(); + + final mergedRanges = _mergeOverlappingRanges(ranges); + int currentOffset = 0; + for (final range in mergedRanges) { + if (range.start > currentOffset) { + // We have text before the ignored range. Add it. + buffer.write(text.substring(currentOffset, range.start)); + } + + // Fill the ignored range with whitespaces. + buffer.write(' ' * (range.end - range.start)); + + currentOffset = range.end; + } + + // Add the remaining text, after the last ignored range, if any. + if (currentOffset < text.length) { + buffer.write(text.substring(currentOffset)); + } + + return buffer.toString(); + } + + /// Merges overlapping ranges in the given list of [ranges]. + /// + /// Returns a new sorted list of ranges where overlapping ranges are merged. + List _mergeOverlappingRanges(List ranges) { + final sortedRanges = ranges.sorted((a, b) { + if (a.start < b.start) { + return -1; + } else if (a.start > b.start) { + return 1; + } + + return a.end - b.end; + }); + + TextRange currentRange = sortedRanges.first; + + final mergedRanges = []; + for (int i = 1; i < sortedRanges.length; i++) { + final nextRange = sortedRanges[i]; + if (currentRange.end >= nextRange.start) { + // The ranges overlap, merge them. + currentRange = TextRange( + start: currentRange.start, + end: nextRange.end, + ); + } else { + // The ranges don't overlap. + mergedRanges.add(currentRange); + currentRange = nextRange; + } + } + mergedRanges.add(currentRange); + + return mergedRanges; + } +} + +/// A [ContentTapDelegate] that shows the suggestions popover when the user taps on +/// a misspelled word. +/// +/// When the suggestions popover is displayed, the selection expands to the whole word +/// and the selection handles are hidden. +class SuperEditorIosSpellCheckerTapHandler extends _SpellCheckerContentTapDelegate { + SuperEditorIosSpellCheckerTapHandler({ + required this.popoverController, + required this.controlsController, + required this.styler, + }); + + final SpellCheckerPopoverController popoverController; + final SuperEditorIosControlsController controlsController; + final SpellingAndGrammarStyler styler; + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + if (editor == null) { + return TapHandlingInstruction.continueHandling; + } + + final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); + if (tapPosition == null) { + return TapHandlingInstruction.continueHandling; + } + + final spelling = popoverController.findSuggestionsForWordAt(DocumentSelection.collapsed(position: tapPosition)); + if (spelling == null || spelling.suggestions.isEmpty) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + controlsController + ..hideToolbar() + ..hideMagnifier() + ..preventSelectionHandles(); + + // Select the whole word. + editor!.execute([ + ChangeSelectionRequest( + DocumentSelection( + base: DocumentPosition( + nodeId: tapPosition.nodeId, + nodePosition: TextNodePosition(offset: spelling.range.start), + ), + extent: DocumentPosition( + nodeId: tapPosition.nodeId, + nodePosition: TextNodePosition(offset: spelling.range.end), + ), + ), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + + // Change the selection color while the suggestions popover is visible. + styler.overrideSelectionColor(); + + popoverController.showSuggestions(spelling); + + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTap(DocumentTapDetails details) { + final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); + if (tapPosition == null) { + return TapHandlingInstruction.continueHandling; + } + + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + @override + TapHandlingInstruction onPanStart(DocumentTapDetails details) { + if (popoverController.isShowing) { + _hideSpellCheckerPopover(); + } + return TapHandlingInstruction.continueHandling; + } + + void _hideSpellCheckerPopover() { + styler.useDefaultSelectionColor(); + controlsController.allowSelectionHandles(); + popoverController.hide(); + } +} + +/// A [ContentTapDelegate] that shows the suggestions popover when the user taps on +/// a misspelled word. +/// +/// When the suggestions popover is displayed, the selection and the composing region +/// expand to the whole word and the selection handles are hidden. +class SuperEditorAndroidSpellCheckerTapHandler extends _SpellCheckerContentTapDelegate { + SuperEditorAndroidSpellCheckerTapHandler({ + required this.popoverController, + required this.controlsController, + required this.styler, + }); + + final SpellCheckerPopoverController popoverController; + final SuperEditorAndroidControlsController controlsController; + final SpellingAndGrammarStyler styler; + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + if (editor == null) { + return TapHandlingInstruction.continueHandling; + } + + final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); + if (tapPosition == null) { + return TapHandlingInstruction.continueHandling; + } + + final selectionAtTapPosition = DocumentSelection.collapsed(position: tapPosition); + + final spelling = popoverController.findSuggestionsForWordAt(selectionAtTapPosition); + if (spelling == null || spelling.suggestions.isEmpty) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + // On Android, tapping on a misspelled word first places the caret at the tap + // position, then expands the selection to the whole word and shows the suggestions + // popover, after a brief delay. + editor!.execute([ + ChangeSelectionRequest( + selectionAtTapPosition, + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ChangeComposingRegionRequest(selectionAtTapPosition), + ]); + + // Allow the selection handles, otherwise the caret won't be visible prior + // to expanding the selection. + controlsController.allowSelectionHandles(); + + Timer(const Duration(milliseconds: 300), () { + // Hide all controls and prevent handles being displayed. We don't want + // to display drag handles while the suggestion popover is visible. + controlsController + ..hideToolbar() + ..hideMagnifier() + ..hideToolbar() + ..preventSelectionHandles(); + + // The word bounds around the tap position. + final wordSelection = DocumentSelection( + base: DocumentPosition( + nodeId: tapPosition.nodeId, + nodePosition: TextNodePosition(offset: spelling.range.start), + ), + extent: DocumentPosition( + nodeId: tapPosition.nodeId, + nodePosition: TextNodePosition(offset: spelling.range.end), + ), + ); + + // Select the whole word and update the composing region to match + // the Android behavior of placing the whole word on the composing + // region when tapping at a word. + editor!.execute([ + ChangeSelectionRequest( + wordSelection, + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ChangeComposingRegionRequest(wordSelection), + ]); + + // Change the selection color while the suggestion popover is visible. + styler.overrideSelectionColor(); + + popoverController.showSuggestions( + spelling, + // When the user dismisses the popover, we want to restore the selection + // to the exact tap position. + onDismiss: () { + editor!.execute([ + ChangeSelectionRequest( + selectionAtTapPosition, + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ChangeComposingRegionRequest(selectionAtTapPosition), + ]); + + _hideSpellCheckerPopover(); + }, + ); + }); + + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTap(DocumentTapDetails details) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + void _hideSpellCheckerPopover() { + controlsController.allowSelectionHandles(); + styler.useDefaultSelectionColor(); + popoverController.hide(); + } +} + +/// A [ContentTapDelegate] that shows the suggestions popover when the user taps on +/// a misspelled word. +class _SuperEditorDesktopSpellCheckerTapHandler extends _SpellCheckerContentTapDelegate { + _SuperEditorDesktopSpellCheckerTapHandler({ + required this.popoverController, + }); + + final SpellCheckerPopoverController popoverController; + + @override + TapHandlingInstruction onTap(DocumentTapDetails details) { + if (editor == null) { + return TapHandlingInstruction.continueHandling; + } + + final tapPosition = details.documentLayout.getDocumentPositionNearestToOffset(details.layoutOffset); + if (tapPosition == null) { + return TapHandlingInstruction.continueHandling; + } + + final spelling = popoverController.findSuggestionsForWordAt(DocumentSelection.collapsed(position: tapPosition)); + if (spelling == null || spelling.suggestions.isEmpty) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + editor!.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed(position: tapPosition), + SelectionChangeType.expandSelection, + SelectionReason.userInteraction, + ), + ]); + + popoverController.showSuggestions(spelling); + + return TapHandlingInstruction.halt; + } + + @override + TapHandlingInstruction onDoubleTap(DocumentTapDetails details) { + _hideSpellCheckerPopover(); + return TapHandlingInstruction.continueHandling; + } + + void _hideSpellCheckerPopover() { + popoverController.hide(); + } +} + +/// A [ContentTapDelegate] that has access to the [editor] while the +/// plugin is attached to it. +class _SpellCheckerContentTapDelegate extends ContentTapDelegate { + _SpellCheckerContentTapDelegate(); + + Editor? editor; +} + +/// A function that determines ranges to be ignored from spellchecking. +typedef SpellingIgnoreRule = List Function(TextNode node); + +/// A collection of built-in rules for ignoring spans of text from spellchecking. +class SpellingIgnoreRules { + /// Creates a rule that ignores an entire text block of the given [blockType]. + /// + /// For example, a rule might ignore a code block or a blockquote. + static SpellingIgnoreRule byBlockType(Attribution blockType) { + return (TextNode node) { + if (node.metadata[NodeMetadata.blockType] == blockType) { + return [TextRange(start: 0, end: node.text.length)]; + } + + return []; + }; + } + + /// Creates a rule that ignores text spans that match the given [pattern]. + static SpellingIgnoreRule byPattern(Pattern pattern) { + return (TextNode node) { + return pattern + .allMatches(node.text.toPlainText()) + .map((match) => TextRange(start: match.start, end: match.end)) + .toList(); + }; + } + + /// Creates a rule that ignores text spans that have the given [attribution]. + static SpellingIgnoreRule byAttribution(Attribution attribution) { + return byAttributionFilter((candidate) => candidate == attribution); + } + + /// Creates a rule that ignore text spans that have at least one atribution that matches the given [filter]. + static SpellingIgnoreRule byAttributionFilter(AttributionFilter filter) { + return (TextNode node) { + return node.text.spans + .getAttributionSpansInRange( + attributionFilter: filter, + start: 0, + end: node.text.toPlainText().length - 1, // -1 to make end of range inclusive. + ) + .map((span) => TextRange(start: span.start, end: span.end + 1)) // +1 to make the end exclusive. + .toList(); + }; + } +} + +/// A [SpellCheckService] that uses a macOS plugin to check spelling. +class MacSpellCheckService implements SpellCheckService { + final _macSpellChecker = SuperEditorSpellCheckerPlugin().macSpellChecker; + + @override + Future?> fetchSpellCheckSuggestions(Locale locale, String text) async { + // TODO: Investigate whether we can parallelize spelling and grammar checks + // for a given node (and whether it's worth the complexity). + + final suggestionSpans = []; + + int startingOffset = 0; + TextRange prevError = TextRange.empty; + final locale = PlatformDispatcher.instance.locale; + final language = _macSpellChecker.convertDartLocaleToMacLanguageCode(locale)!; + do { + prevError = await _macSpellChecker.checkSpelling( + stringToCheck: text, + startingOffset: startingOffset, + language: language, + ); + + if (prevError.isValid) { + final guesses = await _macSpellChecker.guesses(range: prevError, text: text); + suggestionSpans.add(SuggestionSpan(prevError, guesses)); + startingOffset = prevError.end; + } + } while (prevError.isValid); + + return suggestionSpans; + } +} + +/// A service that knows how to check grammar on a text. +abstract class GrammarCheckService { + /// Checks the given [text] for grammar errors with the given [locale]. + /// + /// Returns a list of [GrammarError]s where each item represents a sentence + /// that has a grammatical error, with details about the error. + /// + /// Returns an empty list if no grammar errors are found or if the [locale] + /// isn't supported by the grammar checker. + /// + /// Returns `null` if the check was unsucessful. + Future?> checkGrammar(Locale locale, String text); +} + +/// A [GrammarCheckService] that uses a macOS plugin to check grammar. +class MacGrammarCheckService implements GrammarCheckService { + final _macSpellChecker = SuperEditorSpellCheckerPlugin().macSpellChecker; + + @override + Future?> checkGrammar(Locale locale, String text) async { + final errors = []; + + final language = _macSpellChecker.convertDartLocaleToMacLanguageCode(locale)!; + + int startingOffset = 0; + TextRange prevError = TextRange.empty; + do { + final result = await _macSpellChecker.checkGrammar( + stringToCheck: text, + startingOffset: startingOffset, + language: language, + ); + prevError = result.firstError ?? TextRange.empty; + + if (prevError.isValid) { + errors.addAll( + result.details.map( + (detail) => GrammarError( + range: detail.range, + description: detail.userDescription, + ), + ), + ); + + startingOffset = prevError.end; + } + } while (prevError.isValid); + + return errors; + } +} + +/// A grammatical error found in a text at [range]. +class GrammarError { + GrammarError({ + required this.range, + required this.description, + }); + + /// The range of text that has a grammatical error. + final TextRange range; + + /// The description of the grammatical error. + final String description; +} diff --git a/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestion_overlay.dart b/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestion_overlay.dart new file mode 100644 index 0000000000..a989f89de9 --- /dev/null +++ b/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestion_overlay.dart @@ -0,0 +1,1047 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spell_checker_popover_controller.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spelling_and_grammar_plugin.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spelling_error_suggestions.dart'; + +class SpellingErrorSuggestionOverlayBuilder implements SuperEditorLayerBuilder { + const SpellingErrorSuggestionOverlayBuilder( + this.suggestions, + this.selectedWordLink, { + this.toolbarBuilder = defaultSpellingSuggestionToolbarBuilder, + required this.popoverController, + }); + + final SpellingErrorSuggestions suggestions; + final LeaderLink selectedWordLink; + + /// Builder that creates the spelling suggestion toolbar, which appears near + /// the currently selected mis-spelled word. + final SpellingErrorSuggestionToolbarBuilder toolbarBuilder; + + /// A controller to which the overlay will attach itself as the delegate for + /// showing/hiding the spelling suggestions popover. + final SpellCheckerPopoverController popoverController; + + @override + ContentLayerWidget build(BuildContext context, SuperEditorContext editContext) { + return SpellingErrorSuggestionOverlay( + editorFocusNode: editContext.editorFocusNode, + editor: editContext.editor, + suggestions: suggestions, + selectedWordLink: selectedWordLink, + toolbarBuilder: toolbarBuilder, + popoverController: popoverController, + ); + } +} + +class SpellingErrorSuggestionOverlay extends DocumentLayoutLayerStatefulWidget { + const SpellingErrorSuggestionOverlay({ + super.key, + required this.editorFocusNode, + required this.editor, + required this.suggestions, + required this.selectedWordLink, + this.toolbarBuilder = defaultSpellingSuggestionToolbarBuilder, + this.popoverController, + this.showDebugLeaderBounds = false, + }); + + final FocusNode editorFocusNode; + + final Editor editor; + + /// Repository of specific mis-spelled words in the document, and suggested + /// corrections. + final SpellingErrorSuggestions suggestions; + + /// A [LeaderLink] that's bound to a rectangle around the currently selected + /// misspelled word - if the selection isn't currently within a word, or if the + /// selected word isn't misspelled, then this link is left unattached. + final LeaderLink selectedWordLink; + + /// Builder that creates the spelling suggestion toolbar, which appears near + /// the currently selected mis-spelled word. + final SpellingErrorSuggestionToolbarBuilder toolbarBuilder; + + /// A controller to which this overlay will attach itself as the delegate for + /// showing/hiding the spelling suggestions popover. + final SpellCheckerPopoverController? popoverController; + + /// Whether to paint colorful bounds around the leader widgets, for debugging purposes. + final bool showDebugLeaderBounds; + + @override + DocumentLayoutLayerState createState() => + _SpellingErrorSuggestionOverlayState(); +} + +class _SpellingErrorSuggestionOverlayState + extends DocumentLayoutLayerState + implements SpellCheckerPopoverDelegate { + final _suggestionToolbarOverlayController = OverlayPortalController(); + + var _toolbarOrientation = SpellcheckToolbarOrientation.auto; + + DocumentRange? _ignoredSpellingErrorRange; + + final _suggestionListenable = ValueNotifier(null); + + final _boundsKey = GlobalKey(); + + SpellingError? _currentSpellingSuggestions; + + VoidCallback? _onDismissToolbar; + + @override + void initState() { + super.initState(); + + widget.editor.context.document.addListener(_onDocumentChange); + widget.editor.context.composer.selectionNotifier.addListener(_onSelectionChange); + widget.editor.context.spellingErrorSuggestions.addListener(_onSpellingSuggestionsChange); + + widget.popoverController?.attach(this); + + _suggestionToolbarOverlayController.show(); + } + + @override + void didUpdateWidget(SpellingErrorSuggestionOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.editor.context.document != oldWidget.editor.context.document) { + oldWidget.editor.context.document.removeListener(_onDocumentChange); + widget.editor.context.document.addListener(_onDocumentChange); + } + + if (widget.editor.context.composer.selectionNotifier != oldWidget.editor.context.composer.selectionNotifier) { + oldWidget.editor.context.composer.selectionNotifier.removeListener(_onSelectionChange); + widget.editor.context.composer.selectionNotifier.addListener(_onSelectionChange); + } + + // Note: We use maybeSpellingErrorSuggestions on the old Editor because its possible that the + // old Editor already had its spelling plugin removed. + if (widget.editor.context.spellingErrorSuggestions != oldWidget.editor.context.maybeSpellingErrorSuggestions) { + oldWidget.editor.context.maybeSpellingErrorSuggestions?.removeListener(_onSpellingSuggestionsChange); + widget.editor.context.spellingErrorSuggestions.addListener(_onSpellingSuggestionsChange); + } + + if (widget.popoverController != oldWidget.popoverController) { + oldWidget.popoverController?.detach(); + widget.popoverController?.attach(this); + } + } + + @override + void dispose() { + if (_suggestionToolbarOverlayController.isShowing) { + _suggestionToolbarOverlayController.hide(); + } + + widget.editor.document.removeListener(_onDocumentChange); + widget.editor.context.composer.selectionNotifier.removeListener(_onSelectionChange); + widget.editor.context.spellingErrorSuggestions.removeListener(_onSpellingSuggestionsChange); + + widget.popoverController?.detach(); + + super.dispose(); + } + + @override + void onAttached(SpellCheckerPopoverController controller) {} + + @override + void onDetached() { + if (_suggestionToolbarOverlayController.isShowing) { + _suggestionToolbarOverlayController.hide(); + } + } + + @override + void showSuggestions( + SpellingError suggestions, { + VoidCallback? onDismiss, + }) { + setState(() { + _currentSpellingSuggestions = suggestions; + _onDismissToolbar = onDismiss; + }); + } + + @override + @Deprecated("This is a temporary behavior until we generalize the control (June 19, 2025)") + void setOrientation(SpellcheckToolbarOrientation orientation) { + if (_toolbarOrientation == orientation) { + return; + } + + setState(() { + _toolbarOrientation = orientation; + }); + } + + @override + void hideSuggestionsPopover() { + setState(() { + _currentSpellingSuggestions = null; + _onDismissToolbar = null; + }); + } + + @override + SpellingError? findSuggestionsForWordAt(DocumentRange wordRange) { + final misspelledSpan = _findSpellingSuggestionAtRange(widget.suggestions, wordRange); + if (misspelledSpan == null) { + // No selected mis-spelled word. Fizzle. + return null; + } + + final misspelledWordRange = misspelledSpan.toDocumentRange; + if (misspelledWordRange == _ignoredSpellingErrorRange) { + // The user already cancelled the suggestions for this word. + return null; + } + + return misspelledSpan; + } + + void _onSelectionChange() { + if (!mounted) { + return; + } + + setState(() { + // Re-compute layout data. The layout needs to be re-computed regardless + // of any conditions that follow this comment. + + // If the selection was sitting in an ignored spelling error, and + // now the selection is somewhere else, reset the ignored error. + if (_ignoredSpellingErrorRange == null) { + return; + } + + final selection = widget.editor.context.composer.selection; + if (selection == null) { + // There's no selection. Therefore, the user isn't still selecting + // the mis-spelled word. Reset the ignored word. + _ignoredSpellingErrorRange = null; + } else { + // There's a selection. If it's not still in the ignored word, reset + // the ignored word. + final ignoredWordAsSelection = DocumentSelection( + base: _ignoredSpellingErrorRange!.start, + extent: _ignoredSpellingErrorRange!.end.copyWith( + // Add one to the downstream offset so that when the caret sits immediately + // after the mis-spelled word, it's still considered to sit within the word. + // We do this because we don't want to reset the ignored word when the caret + // sits immediately after it. + nodePosition: TextNodePosition( + offset: (_ignoredSpellingErrorRange!.end.nodePosition as TextNodePosition).offset + 1, + ), + ), + ); + final isBaseInWord = + widget.editor.document.doesSelectionContainPosition(ignoredWordAsSelection, selection.base); + final isExtentInWord = + widget.editor.document.doesSelectionContainPosition(ignoredWordAsSelection, selection.extent); + + if (!isBaseInWord || !isExtentInWord) { + _ignoredSpellingErrorRange = null; + } + } + }); + } + + void _onDocumentChange(DocumentChangeLog changeLog) { + if (!mounted) { + return; + } + + // After the document changes, the currently visible suggestions + // might not be valid anymore. Hide the popover. + hideSuggestionsPopover(); + } + + void _onSpellingSuggestionsChange() { + if (!mounted) { + return; + } + + setState(() { + // Re-compute layout data. + }); + } + + @override + SpellingErrorSuggestionLayout? computeLayoutDataWithDocumentLayout( + BuildContext contentLayersContext, + BuildContext documentContext, + DocumentLayout documentLayout, + ) { + _suggestionListenable.value = null; + + // When there's no selected spelling error, we need to hide the toolbar overlay. + // Rather than conditionally hide the toolbar based on the code below, we start + // by hiding the toolbar overlay in all cases. Then, if it's needed, the code + // below will bring it back. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + + if (_suggestionToolbarOverlayController.isShowing) { + _suggestionToolbarOverlayController.hide(); + } + }); + + if (widget.editor.context.composer.selection == null) { + // There can't be any spelling suggestions because there's no selection. Fizzle. + return null; + } + + final spellingSuggestion = _currentSpellingSuggestions; + if (spellingSuggestion == null) { + // No selected mis-spelled word. Fizzle. + return null; + } + + final misspelledWordRange = spellingSuggestion.toDocumentRange; + if (misspelledWordRange == _ignoredSpellingErrorRange) { + // The user already cancelled the suggestions for this word. + return null; + } + + final selectedComponent = documentLayout.getComponentByNodeId( + widget.editor.context.composer.selectionNotifier.value!.extent.nodeId, + ); + if (selectedComponent == null) { + // Assume that we're in a momentary transitive state where the document layout + // just gained or lost a component. We expect this method to run again in a moment + // to correct for this. + return null; + } + + _suggestionListenable.value = spellingSuggestion; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + + _suggestionToolbarOverlayController.show(); + }); + + return SpellingErrorSuggestionLayout( + documentLayout: documentLayout, + selectedWordBounds: documentLayout.getRectForSelection( + misspelledWordRange.start, + misspelledWordRange.end.copyWith( + nodePosition: TextNodePosition( + // +1 to make end exclusive. + offset: (misspelledWordRange.end.nodePosition as TextNodePosition).offset + 1, + ), + ), + ), + selectedWordRange: misspelledWordRange, + suggestions: spellingSuggestion.suggestions, + ); + } + + SpellingError? _findSpellingSuggestionAtRange( + SpellingErrorSuggestions allSuggestions, + DocumentRange selection, + ) { + if (selection.start.nodeId != selection.end.nodeId) { + // It doesn't make sense to correct spelling across paragraphs. Fizzle. + return null; + } + + final textNode = widget.editor.context.document.getNodeById(selection.end.nodeId) as TextNode; + + final selectionBaseOffset = (selection.start.nodePosition as TextNodePosition).offset; + final spellingSuggestionsAtBase = allSuggestions.getSuggestionsAtTextOffset(textNode.id, selectionBaseOffset); + if (spellingSuggestionsAtBase == null) { + return null; + } + + final selectionExtentOffset = (selection.end.nodePosition as TextNodePosition).offset; + final spellingSuggestionsAtExtent = allSuggestions.getSuggestionsAtTextOffset(textNode.id, selectionExtentOffset); + if (spellingSuggestionsAtExtent == null) { + return null; + } + + if (spellingSuggestionsAtBase.range != spellingSuggestionsAtExtent.range) { + // We found different spelling errors. This probably means the selection + // spans multiple words, including multiple spelling errors. We can't + // suggest a single fix. Fizzle. + return null; + } + final spellingErrorRange = spellingSuggestionsAtExtent.range; + + // The user's selection sits somewhere within a word. Check if it's mis-spelled. + final suggestions = widget.suggestions.getSuggestionsForWord( + selection.end.nodeId, + TextRange(start: spellingErrorRange.start, end: spellingErrorRange.end), + ); + + return suggestions; + } + + // Called when the user presses the "x" (cancel) button on the spelling + // correction suggestion toolbar. + // + // The expected behavior is that cancelling the toolbar will hide it, + // the toolbar will not re-appear as long as the user's selection remains + // within the mis-spelled word, but the toolbar will come back if the + // selection moves away and then moves back to the mis-spelled word. + void _onCancelPressed() { + _suggestionToolbarOverlayController.hide(); + _ignoredSpellingErrorRange = layoutData?.selectedWordRange; + } + + @override + Widget doBuild(BuildContext context, SpellingErrorSuggestionLayout? layoutData) { + if (layoutData == null) { + return const SizedBox(); + } + + // We display the Follower in an OverlayPortal for two reasons: + // 1. Ensure the Follower is above all other content + // 2. Ensure the Follower has access to the same theme as the editor + return OverlayPortal( + key: _boundsKey, + controller: _suggestionToolbarOverlayController, + overlayChildBuilder: (overlayContext) { + if (layoutData.suggestions.isEmpty) { + return const SizedBox(); + } + + return _buildToolbarPositioner( + child: widget.toolbarBuilder( + context, + editorFocusNode: widget.editorFocusNode, + editor: widget.editor, + documentLayout: layoutData.documentLayout, + selectedWordRange: layoutData.selectedWordRange!, + suggestions: layoutData.suggestions, + onCancelPressed: _onCancelPressed, + closeToolbar: hideSuggestionsPopover, + ), + ); + }, + child: IgnorePointer( + child: Stack( + children: [ + if (layoutData.selectedWordBounds != null) + Positioned.fromRect( + rect: layoutData.selectedWordBounds!, + child: Leader( + link: widget.selectedWordLink, + child: widget.showDebugLeaderBounds + ? DecoratedBox( + decoration: BoxDecoration(border: Border.all(width: 4, color: const Color(0xFFFF00FF))), + ) + : null, + ), + ), + ], + ), + ), + ); + } + + Widget _buildToolbarPositioner({required Widget child}) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + return Follower.withAligner( + link: widget.selectedWordLink, + aligner: CupertinoPopoverToolbarAligner(), + boundary: const ScreenFollowerBoundary(), + child: child, + ); + case TargetPlatform.android: + late final Alignment leaderAnchor; + late final Alignment followerAnchor; + late final Offset offset; + if (_toolbarOrientation == SpellcheckToolbarOrientation.above) { + // Show toolbar above. + leaderAnchor = Alignment.topLeft; + followerAnchor = Alignment.bottomLeft; + offset = const Offset(0, -16); + } else { + // "Auto" or explicitly "below". + leaderAnchor = Alignment.bottomLeft; + followerAnchor = Alignment.topLeft; + offset = const Offset(0, 16); + } + + return Stack( + children: [ + // On Android, the user can't interact with the content + // bellow the toolbar. + ModalBarrier( + dismissible: true, + onDismiss: () { + _onDismissToolbar?.call(); + hideSuggestionsPopover(); + }, + ), + Follower.withOffset( + link: widget.selectedWordLink, + leaderAnchor: leaderAnchor, + followerAnchor: followerAnchor, + offset: offset, + boundary: const ScreenFollowerBoundary(), + child: child, + ), + ], + ); + default: + return Follower.withOffset( + link: widget.selectedWordLink, + leaderAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + offset: const Offset(0, 16), + boundary: const ScreenFollowerBoundary(), + child: child, + ); + } + } +} + +class SpellingErrorSuggestionLayout { + SpellingErrorSuggestionLayout({ + required this.documentLayout, + required this.selectedWordBounds, + required this.selectedWordRange, + required this.suggestions, + }); + + final DocumentLayout documentLayout; + final Rect? selectedWordBounds; + final DocumentRange? selectedWordRange; + final List suggestions; +} + +/// Builds a toolbar widget for showing spelling error suggestions. +/// +/// - [editorFocusNode]: The [FocusNode] attached to the editor. +/// - [editor]: The [Editor] instance, which can be used to fix spelling errors. +/// - [documentLayout]: The current layout of the document, which can be used to query information +/// about the selected word. +/// - [selectedWordRange]: The range of the selected word, which contains a spelling error. +/// - [suggestions]: A list of possible substitutions for the misspelled word. +/// - [onCancelPressed]: A callback to be called when the user attempts to close +/// the toolbar without applying a substitution. +/// - [closeToolbar]: A callback to be called to close the toolbar when the user +/// selects a substitution to be applied. +typedef SpellingErrorSuggestionToolbarBuilder = Widget Function( + BuildContext context, { + required FocusNode editorFocusNode, + required Editor editor, + required DocumentLayout documentLayout, + required DocumentRange selectedWordRange, + required List suggestions, + required VoidCallback onCancelPressed, + required VoidCallback closeToolbar, +}); + +/// Creates a spelling suggestion toolbar depending on the +/// current platform. +Widget defaultSpellingSuggestionToolbarBuilder( + BuildContext context, { + required FocusNode editorFocusNode, + required Editor editor, + required DocumentLayout documentLayout, + required DocumentRange selectedWordRange, + required List suggestions, + required VoidCallback onCancelPressed, + required VoidCallback closeToolbar, +}) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + return IosSpellingSuggestionToolbar( + editorFocusNode: editorFocusNode, + editor: editor, + documentLayout: documentLayout, + selectedWordRange: selectedWordRange, + suggestions: suggestions, + closeToolbar: closeToolbar, + ); + case TargetPlatform.android: + return AndroidSpellingSuggestionToolbar( + editorFocusNode: editorFocusNode, + editor: editor, + selectedWordRange: selectedWordRange, + suggestions: suggestions, + closeToolbar: closeToolbar, + ); + default: + return DesktopSpellingSuggestionToolbar( + editorFocusNode: editorFocusNode, + editor: editor, + selectedWordRange: selectedWordRange, + suggestions: suggestions, + onCancelPressed: onCancelPressed, + closeToolbar: closeToolbar, + ); + } +} + +Widget desktopSpellingSuggestionToolbarBuilder( + BuildContext context, { + required FocusNode editorFocusNode, + required Editor editor, + required DocumentRange selectedWordRange, + required List suggestions, + required VoidCallback onCancelPressed, + required VoidCallback closeToolbar, +}) { + return DesktopSpellingSuggestionToolbar( + editorFocusNode: editorFocusNode, + editor: editor, + selectedWordRange: selectedWordRange, + suggestions: suggestions, + onCancelPressed: onCancelPressed, + closeToolbar: closeToolbar, + ); +} + +/// A spelling suggestion toolbar, designed for desktop experiences, +/// which displays a list alternative spellings for a given mis-spelled +/// word. +/// +/// When the user clicks on a suggested spelling, the mis-spelled word +/// is replaced by selected word. +class DesktopSpellingSuggestionToolbar extends StatefulWidget { + const DesktopSpellingSuggestionToolbar({ + super.key, + required this.editorFocusNode, + this.tapRegionId, + required this.editor, + required this.selectedWordRange, + required this.suggestions, + required this.onCancelPressed, + required this.closeToolbar, + }); + + final FocusNode editorFocusNode; + final Object? tapRegionId; + final Editor editor; + final DocumentRange? selectedWordRange; + final List suggestions; + final VoidCallback onCancelPressed; + final VoidCallback closeToolbar; + + @override + State createState() => _DesktopSpellingSuggestionToolbarState(); +} + +class _DesktopSpellingSuggestionToolbarState extends State { + @override + void initState() { + super.initState(); + widget.editor.document.addListener(_onDocumentChange); + } + + @override + void didUpdateWidget(covariant DesktopSpellingSuggestionToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.editor.document != oldWidget.editor.document) { + oldWidget.editor.document.removeListener(_onDocumentChange); + widget.editor.document.addListener(_onDocumentChange); + } + } + + @override + void dispose() { + widget.editor.document.removeListener(_onDocumentChange); + super.dispose(); + } + + void _onDocumentChange(DocumentChangeLog changeLog) { + widget.closeToolbar(); + } + + void _applySpellingFix(String replacement) { + widget.editor.fixMisspelledWord(widget.selectedWordRange!, replacement); + widget.closeToolbar(); + } + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + + return Focus( + parentNode: widget.editorFocusNode, + child: TapRegion( + groupId: widget.tapRegionId, + child: Container( + decoration: BoxDecoration( + color: _getBackgroundColor(brightness), + border: Border.all(color: _getBorderColor(brightness)), + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(10), + right: Radius.circular(34), + ), + boxShadow: [ + BoxShadow( + offset: const Offset(0, 4), + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 6, + ), + ], + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final suggestion in widget.suggestions) ...[ + GestureDetector( + onTap: () => _applySpellingFix(suggestion), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text( + suggestion, + style: const TextStyle(fontSize: 12), + ), + ), + ), + VerticalDivider(width: 1, color: _getBorderColor(brightness)), + ], + const SizedBox(width: 6), + GestureDetector( + onTap: widget.onCancelPressed, + child: Icon( + Icons.cancel_outlined, + size: 12, + color: _getTextColor(brightness), + ), + ), + const SizedBox(width: 6), + ], + ), + ), + ), + ), + ), + ); + } + + Color _getBackgroundColor(Brightness brightness) { + switch (brightness) { + case Brightness.light: + return Colors.white; + case Brightness.dark: + return Colors.grey.shade900; + } + } + + Color _getBorderColor(Brightness brightness) { + switch (brightness) { + case Brightness.light: + return Colors.black; + case Brightness.dark: + return Colors.grey.shade700; + } + } + + Color _getTextColor(Brightness brightness) { + switch (brightness) { + case Brightness.light: + return Colors.black; + case Brightness.dark: + return Colors.white; + } + } +} + +/// A spelling suggestion toolbar, designed for the Android platform, +/// which displays a vertical list of possible substitutions for a given mis-spelled +/// word and an option to remove the miss-pelled word. +/// +/// When the user taps on a suggested substitution, the mis-spelled word +/// is replaced by selected word. +class AndroidSpellingSuggestionToolbar extends StatefulWidget { + const AndroidSpellingSuggestionToolbar({ + super.key, + required this.editorFocusNode, + this.tapRegionId, + required this.editor, + required this.selectedWordRange, + required this.suggestions, + required this.closeToolbar, + }); + + final FocusNode editorFocusNode; + final Object? tapRegionId; + final Editor editor; + final DocumentRange selectedWordRange; + final List suggestions; + final VoidCallback closeToolbar; + + @override + State createState() => _AndroidSpellingSuggestionToolbarState(); +} + +class _AndroidSpellingSuggestionToolbarState extends State { + @override + void initState() { + super.initState(); + widget.editor.document.addListener(_onDocumentChange); + } + + @override + void didUpdateWidget(AndroidSpellingSuggestionToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.editor.document != oldWidget.editor.document) { + oldWidget.editor.document.removeListener(_onDocumentChange); + widget.editor.document.addListener(_onDocumentChange); + } + } + + @override + void dispose() { + widget.editor.document.removeListener(_onDocumentChange); + super.dispose(); + } + + void _onDocumentChange(DocumentChangeLog changeLog) { + SuperEditorAndroidControlsScope.rootOf(context).allowSelectionHandles(); + widget.closeToolbar(); + } + + void _applySpellingFix(String replacement) { + widget.editor.fixMisspelledWord(widget.selectedWordRange, replacement); + } + + void _removeWord() { + widget.editor.removeMisspelledWord(widget.selectedWordRange); + } + + Color _getTextColor(Brightness brightness) { + switch (brightness) { + case Brightness.light: + return Colors.black; + case Brightness.dark: + return Colors.white; + } + } + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + + return Material( + elevation: 8, + borderRadius: BorderRadius.circular(4), + color: brightness == Brightness.light // + ? Colors.white + : Theme.of(context).canvasColor, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final suggestion in widget.suggestions) ...[ + _buildButton( + title: suggestion, + onPressed: () => _applySpellingFix(suggestion), + brightness: brightness, + ), + ], + _buildButton( + title: 'Delete', + onPressed: _removeWord, + brightness: brightness, + ), + ], + ), + ); + } + + Widget _buildButton({ + required String title, + required VoidCallback onPressed, + required Brightness brightness, + }) { + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension), + padding: EdgeInsets.zero, + foregroundColor: _getTextColor(brightness), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + title, + style: const TextStyle(fontSize: 14), + ), + ), + ); + } +} + +/// A spelling suggestion toolbar, designed for the iOS platform, +/// which displays a horizontal list alternative spellings for a given +/// mis-spelled word. +/// +/// When the user taps on a suggested spelling, the mis-spelled word +/// is replaced by selected word. +class IosSpellingSuggestionToolbar extends StatefulWidget { + const IosSpellingSuggestionToolbar({ + super.key, + required this.editorFocusNode, + this.tapRegionId, + required this.editor, + required this.documentLayout, + required this.selectedWordRange, + required this.suggestions, + required this.closeToolbar, + }); + + final FocusNode editorFocusNode; + final Object? tapRegionId; + final Editor editor; + final DocumentLayout documentLayout; + final DocumentRange selectedWordRange; + final List suggestions; + final VoidCallback closeToolbar; + + @override + State createState() => _IosSpellingSuggestionToolbarState(); +} + +class _IosSpellingSuggestionToolbarState extends State { + @override + void initState() { + super.initState(); + widget.editor.document.addListener(_onDocumentChange); + } + + @override + void didUpdateWidget(covariant IosSpellingSuggestionToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.editor.document != oldWidget.editor.document) { + oldWidget.editor.document.removeListener(_onDocumentChange); + widget.editor.document.addListener(_onDocumentChange); + } + } + + @override + void dispose() { + widget.editor.document.removeListener(_onDocumentChange); + super.dispose(); + } + + void _onDocumentChange(DocumentChangeLog changeLog) { + SuperEditorIosControlsScope.rootOf(context).allowSelectionHandles(); + widget.closeToolbar(); + } + + void _applySpellingFix(String replacement) { + widget.editor.fixMisspelledWord(widget.selectedWordRange, replacement); + } + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + + final selectedWordBounds = widget.documentLayout.getRectForSelection( + widget.selectedWordRange.start, + widget.selectedWordRange.end.copyWith( + nodePosition: TextNodePosition( + // +1 to make end exclusive. + offset: (widget.selectedWordRange.end.nodePosition as TextNodePosition).offset + 1, + ), + ), + ); + + if (selectedWordBounds == null) { + return const SizedBox(); + } + + return Focus( + parentNode: widget.editorFocusNode, + child: TapRegion( + groupId: widget.tapRegionId, + child: CupertinoPopoverToolbar( + focalPoint: StationaryMenuFocalPoint(selectedWordBounds.center), + backgroundColor: _getBackgroundColor(brightness), + activeButtonTextColor: brightness == Brightness.dark // + ? _iOSToolbarDarkArrowActiveColor + : _iOSToolbarLightArrowActiveColor, + inactiveButtonTextColor: brightness == Brightness.dark // + ? _iOSToolbarDarkArrowInactiveColor + : _iOSToolbarLightArrowInactiveColor, + elevation: 8.0, + children: [ + for (final suggestion in widget.suggestions) ...[ + _buildButton( + title: suggestion, + onPressed: () => _applySpellingFix(suggestion), + brightness: brightness, + ), + ], + ], + ), + ), + ); + } + + Color _getBackgroundColor(Brightness brightness) { + switch (brightness) { + case Brightness.light: + return Colors.white; + case Brightness.dark: + return Colors.grey.shade900; + } + } + + Color _getTextColor(Brightness brightness) { + switch (brightness) { + case Brightness.light: + return Colors.black; + case Brightness.dark: + return Colors.white; + } + } + + Widget _buildButton({ + required String title, + required VoidCallback onPressed, + required Brightness brightness, + }) { + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + minimumSize: const Size(kMinInteractiveDimension, 0), + padding: EdgeInsets.zero, + splashFactory: NoSplash.splashFactory, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: _getTextColor(brightness), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + title, + style: const TextStyle(fontSize: 14), + ), + ), + ); + } +} + +const _iOSToolbarLightArrowActiveColor = Color(0xFF000000); +const _iOSToolbarDarkArrowActiveColor = Color(0xFFFFFFFF); + +const _iOSToolbarLightArrowInactiveColor = Color(0xFF999999); +const _iOSToolbarDarkArrowInactiveColor = Color(0xFF757575); diff --git a/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestions.dart b/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestions.dart new file mode 100644 index 0000000000..2602c77ec9 --- /dev/null +++ b/super_editor_spellcheck/lib/src/super_editor/spelling_error_suggestions.dart @@ -0,0 +1,134 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:super_editor/super_editor.dart'; + +/// Spelling error correction suggestions for all mis-spelled words within +/// a [Document]. +/// +/// A [SpellingErrorSuggestions] is a repository for spelling error suggestions +/// shared between the [Document], [Editor], overlays, etc. +class SpellingErrorSuggestions with ChangeNotifier implements Editable { + /// A map from nodes to spelling error suggestions. + /// + /// Each spelling error suggestion is a map from the text range of the mis-spelled word + /// to the suggested replacements for that word. + final _suggestions = >{}; + + /// Returns spelling correction suggestions for the word at the given [offset], + /// or `null` if there's no spelling error at the given [offset], or no suggestions + /// for the mis-spelled word at the given [offset]. + SpellingError? getSuggestionsAtTextOffset(String nodeId, int offset) { + final suggestionsForNode = _suggestions[nodeId]; + if (suggestionsForNode == null) { + return null; + } + + final matchingRanges = suggestionsForNode.keys.where((range) => range.start <= offset && range.end >= offset); + if (matchingRanges.isEmpty) { + return null; + } + if (matchingRanges.length > 1) { + // It shouldn't be possible to have multiple spelling errors at the same + // text offset. We don't know what to do. Fizzle. + return null; + } + + final wordRange = matchingRanges.first; + return suggestionsForNode[wordRange]; + } + + /// Returns suggestions for the mis-spelled [word], which occupies the given [textRange], + /// within a node with the given [nodeId]. + /// + /// If the given mis-spelled [word] doesn't exist in [textRange], `null` is returned. + /// + /// If the given mis-spelled [word] isn't mis-spelled, or the spell checker has + /// no suggestions, then `null` is returned. + /// + /// If the spelling suggestions are still being obtained from the spell checker, + /// `null` is returned. + SpellingError? getSuggestionsForWord(String nodeId, TextRange range) { + _suggestions[nodeId] ??= {}; + return _suggestions[nodeId]![range]; + } + + /// Replaces all existing spelling suggestions for the node with the given [nodeId] with + /// the given [spellingSuggestions]. + void putSuggestions(String nodeId, Map spellingSuggestions) { + _suggestions[nodeId] ??= {}; + _suggestions[nodeId]! + ..clear() + ..addAll(spellingSuggestions); + + notifyListeners(); + } + + /// Clears all spelling suggestions for text within the node with the given [nodeId]. + void clearNode(String nodeId) { + if (_suggestions[nodeId] == null) { + return; + } + + _suggestions.remove(nodeId); + notifyListeners(); + } + + /// Clears all spelling suggestions for all text in the document. + void clear() { + if (_suggestions.isEmpty) { + return; + } + + _suggestions.clear(); + notifyListeners(); + } + + @override + void onTransactionEnd(List edits) {} + + @override + void onTransactionStart() {} + + @override + void reset() { + clear(); + } +} + +class SpellingError { + const SpellingError({ + required this.word, + required this.nodeId, + required this.range, + required this.suggestions, + }); + + final String word; + final String nodeId; + final TextRange range; + final List suggestions; + + DocumentRange get toDocumentRange => DocumentRange( + start: DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: range.start)), + end: DocumentPosition( + nodeId: nodeId, + nodePosition: TextNodePosition(offset: range.end - 1), + // -1 because range is exclusive and doc positions are inclusive + ), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpellingError && + runtimeType == other.runtimeType && + word == other.word && + nodeId == other.nodeId && + range == other.range && + const DeepCollectionEquality().equals(suggestions, other.suggestions); + + @override + int get hashCode => word.hashCode ^ nodeId.hashCode ^ range.hashCode ^ suggestions.hashCode; +} diff --git a/super_editor_spellcheck/lib/super_editor_spellcheck.dart b/super_editor_spellcheck/lib/super_editor_spellcheck.dart new file mode 100644 index 0000000000..4ab53ac11e --- /dev/null +++ b/super_editor_spellcheck/lib/super_editor_spellcheck.dart @@ -0,0 +1,9 @@ +export 'src/platform/messages.g.dart'; +export 'src/platform/spell_checker.dart'; +export 'src/platform/spell_checker_mac.dart'; + +export 'src/super_editor/spell_checker_popover_controller.dart'; +export 'src/super_editor/spellcheck_clock.dart'; +export 'src/super_editor/spelling_and_grammar_plugin.dart'; +export 'src/super_editor/spelling_error_suggestion_overlay.dart'; +export 'src/super_editor/spelling_error_suggestions.dart'; diff --git a/super_editor_spellcheck/macos/Classes/SuperEditorSpellcheckPlugin.swift b/super_editor_spellcheck/macos/Classes/SuperEditorSpellcheckPlugin.swift new file mode 100644 index 0000000000..45e0573742 --- /dev/null +++ b/super_editor_spellcheck/macos/Classes/SuperEditorSpellcheckPlugin.swift @@ -0,0 +1,233 @@ +import FlutterMacOS +import Foundation +import AppKit + +/// Plugin to check spelling errors in text using the native macOS spell checker. +public class SuperEditorSpellcheckPlugin: SpellCheckMac { + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = SuperEditorSpellcheckPlugin() + SpellCheckMacSetup.setUp(binaryMessenger: registrar.messenger, api: instance) + } + + /// A list containing all the available spell checking languages. The languages are ordered + /// in the user’s preferred order as set in the system preferences. + func availableLanguages() throws -> [String?] { + return NSSpellChecker.shared.availableLanguages; + } + + /// Returns a unique tag to identified this spell checked object. + /// + /// Use this method to generate tags to avoid collisions with other objects that can be spell checked. + func uniqueSpellDocumentTag() throws -> Int64 { + return Int64(NSSpellChecker.uniqueSpellDocumentTag()); + } + + /// Notifies the receiver that the user has finished with the tagged document. + /// + /// The spell checker will release any resources associated with the document, + /// including but not necessarily limited to, ignored words. + func closeSpellDocument(tag: Int64) throws { + let spellChecker = NSSpellChecker.shared; + + spellChecker.closeSpellDocument(withTag: Int(tag)); + } + + /// Starts the search for a misspelled word in [stringToCheck] starting at [startingOffset] + /// within the string object. + /// + /// - [stringToCheck]: The string object containing the words to spellcheck. + /// - [startingOffset]: The offset within the string object at which to start the spellchecking. + /// - [language]: The language of the words in the string. + /// - [wrap]: `true` to indicate that spell checking should continue at the beginning of the string + /// when the end of the string is reached; `false` to indicate that spellchecking should stop + /// at the end of the document. + /// - [inSpellDocumentWithTag]: An identifier unique within the application + /// used to inform the spell checker which document that text is associated, potentially + /// for many purposes, not necessarily just for ignored words. A value of 0 can be passed + /// in for text not associated with a particular document. + /// + /// Returns the range of the first misspelled word. + func checkSpelling(stringToCheck: String, startingOffset: Int64, language: String?, wrap: Bool, inSpellDocumentWithTag: Int64) throws -> PigeonRange { + let spellChecker = NSSpellChecker.shared; + + let result = spellChecker.checkSpelling( + of: stringToCheck, + startingAt: Int(startingOffset), + language: language, + wrap: wrap, + inSpellDocumentWithTag: Int(inSpellDocumentWithTag), + wordCount: nil + ); + + if (result.location == NSNotFound) { + return PigeonRange(start: -1, end: -1); + } + + return PigeonRange( + start: Int64(result.location), + end: Int64(result.location + result.length) + ); + } + + /// Returns an array of possible substitutions for the specified string. + /// + /// - [range]: The range of the string to check. + /// - [text]: The string to guess. + /// - [language]: The language of the string. + /// - [inSpellDocumentWithTag]: An identifier unique within the application + /// used to inform the spell checker which document that text is associated, potentially + /// for many purposes, not necessarily just for ignored words. A value of 0 can be passed + /// in for text not associated with a particular document. + /// + /// Returns an array of strings containing possible replacement words. + func guesses(text: String, range: PigeonRange, language: String?, inSpellDocumentWithTag: Int64) throws -> [String?]? { + let spellChecker = NSSpellChecker.shared; + + return spellChecker.guesses( + forWordRange: NSRange(location: Int(range.start), length: Int(range.end - range.start)), + in: text, + language: language, + inSpellDocumentWithTag: Int(inSpellDocumentWithTag) + ); + } + + /// Returns a single proposed correction if a word is mis-spelled. + /// + /// - [range]: The range, within the [text], for which a possible should be generated. + /// - [text]: The string containing the word/text for which the correction should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + func correction(text: String, range: PigeonRange, language: String, inSpellDocumentWithTag: Int64) throws -> String? { + let spellChecker = NSSpellChecker.shared; + + return spellChecker.correction( + forWordRange: NSRange(location: Int(range.start), length: Int(range.end - range.start)), + in: text, + language: language, + inSpellDocumentWithTag: Int(inSpellDocumentWithTag) + ); + } + + /// Initiates a grammatical analysis of a given string. + /// + /// - [stringToCheck]: The string to analyze. + /// - [startingOffset]: Location within string at which to start the analysis. + /// - [language]: Language to use in string. + /// - [wrap]: `true` to specify that the analysis continue to the beginning of string when + /// the end is reached. `false` to have the analysis stop at the end of string. + /// - [inSpellDocumentWithTag]: An identifier unique within the application + /// used to inform the spell checker which document that text is associated, potentially + /// for many purposes, not necessarily just for ignored words. A value of 0 can be passed + /// in for text not associated with a particular document. + func checkGrammar(stringToCheck: String, startingOffset: Int64, language: String?, wrap: Bool, inSpellDocumentWithTag: Int64) throws -> PigeonCheckGrammarResult { + let spellChecker = NSSpellChecker.shared; + + var details: NSArray?; + let grammarRange = spellChecker.checkGrammar( + of: stringToCheck, + startingAt: Int(startingOffset), + language: language, + wrap: wrap, + inSpellDocumentWithTag: Int(inSpellDocumentWithTag), + details: &details + ); + + if (grammarRange.location == NSNotFound || details == nil) { + return PigeonCheckGrammarResult( + firstError: PigeonRange(start: -1, end: -1), + details: [] + ); + } + + let grammarDetails = details as! [[String: Any]]; + let analysisDetails : [PigeonGrammaticalAnalysisDetail] = grammarDetails.compactMap{ (detail: [String: Any]) -> PigeonGrammaticalAnalysisDetail? in + let range = detail["NSGrammarRange"] as? NSRange; + let userDescription = detail["NSGrammarUserDescription"] as? String; + + if (range == nil || userDescription == nil) { + return nil; + + } + + return PigeonGrammaticalAnalysisDetail( + range: PigeonRange(start: Int64(range!.location), end: Int64(range!.location + range!.length)), + userDescription: userDescription! + ); + }; + + return PigeonCheckGrammarResult( + firstError: PigeonRange(start: Int64(grammarRange.location), end: Int64(grammarRange.location + grammarRange.length)), + details: analysisDetails + ); + } + + /// Returns the number of words in the specified string. + func countWords(text: String, language: String?) throws -> Int64 { + let spellChecker = NSSpellChecker.shared; + + return Int64(spellChecker.countWords(in: text, language: language)); + } + + /// Adds the [word] to the spell checker dictionary. + func learnWord(word: String) throws { + NSSpellChecker.shared.learnWord(word); + } + + /// Indicates whether the spell checker has learned a given word. + func hasLearnedWord(word: String) throws -> Bool { + return NSSpellChecker.shared.hasLearnedWord(word); + } + + /// Tells the spell checker to unlearn a given word. + func unlearnWord(word: String) throws { + NSSpellChecker.shared.unlearnWord(word); + } + + /// Instructs the spell checker to ignore all future occurrences of [word] in the document + /// identified by [documentTag]. + func ignoreWord(word: String, documentTag: Int64) throws { + NSSpellChecker.shared.ignoreWord(word, inSpellDocumentWithTag: Int(documentTag)); + } + + /// Returns the array of ignored words for a document identified by [documentTag]. + func ignoredWords(documentTag: Int64) throws -> [String]? { + return NSSpellChecker.shared.ignoredWords(inSpellDocumentWithTag: Int(documentTag)); + } + + /// Initializes the ignored-words document (a dictionary identified by [documentTag] with [words]), + /// an array of words to ignore. + func setIgnoredWords(words: [String], documentTag: Int64) throws { + NSSpellChecker.shared.setIgnoredWords(words, inSpellDocumentWithTag: Int(documentTag)); + } + + /// Returns the dictionary used when replacing words. + func userReplacementsDictionary() throws -> [String: String] { + return NSSpellChecker.shared.userReplacementsDictionary; + } + + /// Provides a list of complete words that the user might be trying to type based on a + /// partial word in a given string. + /// + /// - [partialWordRange] - Range that identifies a partial word in string. + /// - [text] - String with the partial word from which to generate the result. + /// - [language]: Language to use in string. + /// - [inSpellDocumentWithTag]: An identifier unique within the application + /// used to inform the spell checker which document that text is associated, potentially + /// for many purposes, not necessarily just for ignored words. A value of 0 can be passed + /// in for text not associated with a particular document. + /// + /// Returns the list of complete words from the spell checker dictionary in the order + /// they should be presented to the user. + func completions(partialWordRange: PigeonRange, text: String, language: String?, inSpellDocumentWithTag: Int64) throws -> [String]? { + let spellChecker = NSSpellChecker.shared; + + return spellChecker.completions( + forPartialWordRange: NSRange(location: Int(partialWordRange.start), length: Int(partialWordRange.end - partialWordRange.start)), + in: text, + language: language, + inSpellDocumentWithTag: Int(inSpellDocumentWithTag) + ); + } +} \ No newline at end of file diff --git a/super_editor_spellcheck/macos/Classes/messages.g.swift b/super_editor_spellcheck/macos/Classes/messages.g.swift new file mode 100644 index 0000000000..c26fafb0fe --- /dev/null +++ b/super_editor_spellcheck/macos/Classes/messages.g.swift @@ -0,0 +1,699 @@ +// Autogenerated from Pigeon (v21.2.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Any? + + init(code: String, message: String?, details: Any?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +/// A range of characters in a string of text. +/// +/// The text included in the range includes the character at [start], but not +/// the one at [end]. +/// +/// This is used because we can't use `TextRange` in pigeon. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct PigeonRange { + var start: Int64 + var end: Int64 + + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> PigeonRange? { + let start = pigeonVar_list[0] is Int64 ? pigeonVar_list[0] as! Int64 : Int64(pigeonVar_list[0] as! Int32) + let end = pigeonVar_list[1] is Int64 ? pigeonVar_list[1] as! Int64 : Int64(pigeonVar_list[1] as! Int32) + + return PigeonRange( + start: start, + end: end + ) + } + func toList() -> [Any?] { + return [ + start, + end, + ] + } +} + +/// The result of a grammatical analysis. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct PigeonCheckGrammarResult { + /// The range of the first error found in the text or `null` if no errors were found. + var firstError: PigeonRange? = nil + /// A list of details about the grammatical errors found in the text or `null` + /// if no errors were found. + var details: [PigeonGrammaticalAnalysisDetail?]? = nil + + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> PigeonCheckGrammarResult? { + let firstError: PigeonRange? = nilOrValue(pigeonVar_list[0]) + let details: [PigeonGrammaticalAnalysisDetail?]? = nilOrValue(pigeonVar_list[1]) + + return PigeonCheckGrammarResult( + firstError: firstError, + details: details + ) + } + func toList() -> [Any?] { + return [ + firstError, + details, + ] + } +} + +/// A detail about a grammatical error found in a text. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct PigeonGrammaticalAnalysisDetail { + /// The range of the grammatical error in the text. + var range: PigeonRange + /// A description of the grammatical error. + var userDescription: String + + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> PigeonGrammaticalAnalysisDetail? { + let range = pigeonVar_list[0] as! PigeonRange + let userDescription = pigeonVar_list[1] as! String + + return PigeonGrammaticalAnalysisDetail( + range: range, + userDescription: userDescription + ) + } + func toList() -> [Any?] { + return [ + range, + userDescription, + ] + } +} + +private class messagesPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return PigeonRange.fromList(self.readValue() as! [Any?]) + case 130: + return PigeonCheckGrammarResult.fromList(self.readValue() as! [Any?]) + case 131: + return PigeonGrammaticalAnalysisDetail.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class messagesPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? PigeonRange { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? PigeonCheckGrammarResult { + super.writeByte(130) + super.writeValue(value.toList()) + } else if let value = value as? PigeonGrammaticalAnalysisDetail { + super.writeByte(131) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class messagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return messagesPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return messagesPigeonCodecWriter(data: data) + } +} + +class messagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = messagesPigeonCodec(readerWriter: messagesPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol SpellCheckMac { + /// {@template mac_spell_checker_available_languages} + /// A list containing all the available spell checking languages. + /// + /// The languages are ordered in the user’s preferred order as set in the + /// system preferences. + /// {@endtemplate} + func availableLanguages() throws -> [String?] + /// {@template mac_spell_checker_unique_spell_document_tag} + /// Returns a unique tag to partition stateful operations in the spell checking system. + /// + /// Use the tag returned by this method in the spell checking methods when there are different + /// texts being spell checked. + /// + /// For example, if there are two texts being spell checked, with tags `1` and `2`, + /// the spell checker will keep the state of the ignored words separate for each one. If an + /// ignored word is added to the tag `1`, it won't be seen as misspelled for tag `1`, but it + /// will be for the tag `2`. + /// + /// Call [closeSpellDocument] when you are done with the tag to release resources. + /// {@endtemplate} + func uniqueSpellDocumentTag() throws -> Int64 + /// {@template mac_spell_checker_close_spell_document} + /// Notifies the spell checking system that the user has finished with the tagged document. + /// + /// The spell checker will release any resources associated with the document, + /// including but not necessarily limited to, ignored words. + /// {@endtemplate} + func closeSpellDocument(tag: Int64) throws + /// {@template mac_spell_checker_check_spelling} + /// Searches for a misspelled word in [stringToCheck], starting at [startingOffset], and returns the + /// [TextRange] surrounding the misspelled word. + /// + /// If no misspelled word is found, a [TextRange] is returned with bounds of `-1`, which can also be + /// queried more conveniently with [TextRange.isValid]. + /// + /// To find all (or multiple) misspelled words in a given string, call this + /// method repeatedly, passing in different values for [startingOffset]. + /// {@endtemplate} + func checkSpelling(stringToCheck: String, startingOffset: Int64, language: String?, wrap: Bool, inSpellDocumentWithTag: Int64) throws -> PigeonRange + /// {@template mac_spell_checker_guesses} + /// Returns possible substitutions for the specified misspelled word at [range] inside the [text]. + /// + /// - [range]: The range, within the [text], for which possible substitutions should be generated. + /// - [text]: The string containing the word/text for which substitutions should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// {@endtemplate} + func guesses(text: String, range: PigeonRange, language: String?, inSpellDocumentWithTag: Int64) throws -> [String?]? + /// {@template mac_spell_checker_correction} + /// Returns a single proposed correction if a word is mis-spelled. + /// + /// - [range]: The range, within the [text], for which a possible should be generated. + /// - [text]: The string containing the word/text for which the correction should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// {@endtemplate} + func correction(text: String, range: PigeonRange, language: String, inSpellDocumentWithTag: Int64) throws -> String? + /// {@template mac_spell_checker_check_grammar} + /// Performs a grammatical analysis of [stringToCheck], starting at [startingOffset]. + /// + /// - [stringToCheck]: The string containing the text to be analyzed. + /// - [startingOffset]: Location within the text at which the analysis should start. + /// - [wrap]: `true` to specify that the analysis continue to the beginning of the text when + /// the end is reached. `false` to have the analysis stop at the end of the text. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [stringToCheck] in isolation, without connection to any given document. + /// {@endtemplate} + func checkGrammar(stringToCheck: String, startingOffset: Int64, language: String?, wrap: Bool, inSpellDocumentWithTag: Int64) throws -> PigeonCheckGrammarResult + /// {@template mac_spell_checker_completions} + /// Provides a list of complete words that the user might be trying to type based on a partial word + /// at [partialWordRange] in the given [text]. + /// + /// - [partialWordRange] - The range, within the [text], for which possible completions should be generated. + /// - [text] - The string containing the partial word for which completions should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// + /// The items of the list are in the order they should be presented to the user. + /// {@endtemplate} + func completions(partialWordRange: PigeonRange, text: String, language: String?, inSpellDocumentWithTag: Int64) throws -> [String]? + /// {@template mac_spell_checker_count_words} + /// Returns the number of words in the specified string. + /// {@endtemplate} + func countWords(text: String, language: String?) throws -> Int64 + /// {@template mac_spell_checker_learn_word} + /// Adds the [word] to the spell checker dictionary. + /// {@endtemplate} + func learnWord(word: String) throws + /// {@template mac_spell_checker_has_learned_word} + /// Indicates whether the spell checker has learned a given word. + /// {@endtemplate} + func hasLearnedWord(word: String) throws -> Bool + /// {@template mac_spell_checker_unlearn_word} + /// Tells the spell checker to unlearn a given word. + /// {@endtemplate} + func unlearnWord(word: String) throws + /// {@template mac_spell_checker_ignore_word} + /// Instructs the spell checker to ignore all future occurrences of [word] in the document + /// identified by [documentTag]. + /// {@endtemplate} + func ignoreWord(word: String, documentTag: Int64) throws + /// {@template mac_spell_checker_ignored_words} + /// Returns the array of ignored words for a document identified by [documentTag]. + /// {@endtemplate} + func ignoredWords(documentTag: Int64) throws -> [String]? + /// {@template mac_spell_checker_set_ignored_words} + /// Updates the ignored-words document (a dictionary identified by [documentTag] with [words]) + /// with a list of [words] to ignore. + /// {@endtemplate} + func setIgnoredWords(words: [String], documentTag: Int64) throws + /// {@template mac_spell_checker_user_replacements_dictionary} + /// Returns the dictionary used when replacing words, as defined by the user in the system preferences. + /// + /// This can be used to create an UI with replacement options when the user types a certain + /// combination of characters. For example, the user might want to automatically replace + /// "omw" with "on my way". When the user types "omw", an UI should display "on my way" as + /// a possible replacement. + /// {@endtemplate} + func userReplacementsDictionary() throws -> [String: String] +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class SpellCheckMacSetup { + static var codec: FlutterStandardMessageCodec { messagesPigeonCodec.shared } + /// Sets up an instance of `SpellCheckMac` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: SpellCheckMac?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// {@template mac_spell_checker_available_languages} + /// A list containing all the available spell checking languages. + /// + /// The languages are ordered in the user’s preferred order as set in the + /// system preferences. + /// {@endtemplate} + let availableLanguagesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.availableLanguages\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + availableLanguagesChannel.setMessageHandler { _, reply in + do { + let result = try api.availableLanguages() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + availableLanguagesChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_unique_spell_document_tag} + /// Returns a unique tag to partition stateful operations in the spell checking system. + /// + /// Use the tag returned by this method in the spell checking methods when there are different + /// texts being spell checked. + /// + /// For example, if there are two texts being spell checked, with tags `1` and `2`, + /// the spell checker will keep the state of the ignored words separate for each one. If an + /// ignored word is added to the tag `1`, it won't be seen as misspelled for tag `1`, but it + /// will be for the tag `2`. + /// + /// Call [closeSpellDocument] when you are done with the tag to release resources. + /// {@endtemplate} + let uniqueSpellDocumentTagChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.uniqueSpellDocumentTag\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + uniqueSpellDocumentTagChannel.setMessageHandler { _, reply in + do { + let result = try api.uniqueSpellDocumentTag() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + uniqueSpellDocumentTagChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_close_spell_document} + /// Notifies the spell checking system that the user has finished with the tagged document. + /// + /// The spell checker will release any resources associated with the document, + /// including but not necessarily limited to, ignored words. + /// {@endtemplate} + let closeSpellDocumentChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.closeSpellDocument\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + closeSpellDocumentChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let tagArg = args[0] is Int64 ? args[0] as! Int64 : Int64(args[0] as! Int32) + do { + try api.closeSpellDocument(tag: tagArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + closeSpellDocumentChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_check_spelling} + /// Searches for a misspelled word in [stringToCheck], starting at [startingOffset], and returns the + /// [TextRange] surrounding the misspelled word. + /// + /// If no misspelled word is found, a [TextRange] is returned with bounds of `-1`, which can also be + /// queried more conveniently with [TextRange.isValid]. + /// + /// To find all (or multiple) misspelled words in a given string, call this + /// method repeatedly, passing in different values for [startingOffset]. + /// {@endtemplate} + let checkSpellingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.checkSpelling\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + checkSpellingChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let stringToCheckArg = args[0] as! String + let startingOffsetArg = args[1] is Int64 ? args[1] as! Int64 : Int64(args[1] as! Int32) + let languageArg: String? = nilOrValue(args[2]) + let wrapArg = args[3] as! Bool + let inSpellDocumentWithTagArg = args[4] is Int64 ? args[4] as! Int64 : Int64(args[4] as! Int32) + do { + let result = try api.checkSpelling(stringToCheck: stringToCheckArg, startingOffset: startingOffsetArg, language: languageArg, wrap: wrapArg, inSpellDocumentWithTag: inSpellDocumentWithTagArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + checkSpellingChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_guesses} + /// Returns possible substitutions for the specified misspelled word at [range] inside the [text]. + /// + /// - [range]: The range, within the [text], for which possible substitutions should be generated. + /// - [text]: The string containing the word/text for which substitutions should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// {@endtemplate} + let guessesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.guesses\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + guessesChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let textArg = args[0] as! String + let rangeArg = args[1] as! PigeonRange + let languageArg: String? = nilOrValue(args[2]) + let inSpellDocumentWithTagArg = args[3] is Int64 ? args[3] as! Int64 : Int64(args[3] as! Int32) + do { + let result = try api.guesses(text: textArg, range: rangeArg, language: languageArg, inSpellDocumentWithTag: inSpellDocumentWithTagArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + guessesChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_correction} + /// Returns a single proposed correction if a word is mis-spelled. + /// + /// - [range]: The range, within the [text], for which a possible should be generated. + /// - [text]: The string containing the word/text for which the correction should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// {@endtemplate} + let correctionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.correction\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + correctionChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let textArg = args[0] as! String + let rangeArg = args[1] as! PigeonRange + let languageArg = args[2] as! String + let inSpellDocumentWithTagArg = args[3] is Int64 ? args[3] as! Int64 : Int64(args[3] as! Int32) + do { + let result = try api.correction(text: textArg, range: rangeArg, language: languageArg, inSpellDocumentWithTag: inSpellDocumentWithTagArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + correctionChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_check_grammar} + /// Performs a grammatical analysis of [stringToCheck], starting at [startingOffset]. + /// + /// - [stringToCheck]: The string containing the text to be analyzed. + /// - [startingOffset]: Location within the text at which the analysis should start. + /// - [wrap]: `true` to specify that the analysis continue to the beginning of the text when + /// the end is reached. `false` to have the analysis stop at the end of the text. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [stringToCheck] in isolation, without connection to any given document. + /// {@endtemplate} + let checkGrammarChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.checkGrammar\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + checkGrammarChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let stringToCheckArg = args[0] as! String + let startingOffsetArg = args[1] is Int64 ? args[1] as! Int64 : Int64(args[1] as! Int32) + let languageArg: String? = nilOrValue(args[2]) + let wrapArg = args[3] as! Bool + let inSpellDocumentWithTagArg = args[4] is Int64 ? args[4] as! Int64 : Int64(args[4] as! Int32) + do { + let result = try api.checkGrammar(stringToCheck: stringToCheckArg, startingOffset: startingOffsetArg, language: languageArg, wrap: wrapArg, inSpellDocumentWithTag: inSpellDocumentWithTagArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + checkGrammarChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_completions} + /// Provides a list of complete words that the user might be trying to type based on a partial word + /// at [partialWordRange] in the given [text]. + /// + /// - [partialWordRange] - The range, within the [text], for which possible completions should be generated. + /// - [text] - The string containing the partial word for which completions should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// + /// The items of the list are in the order they should be presented to the user. + /// {@endtemplate} + let completionsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.completions\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + completionsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let partialWordRangeArg = args[0] as! PigeonRange + let textArg = args[1] as! String + let languageArg: String? = nilOrValue(args[2]) + let inSpellDocumentWithTagArg = args[3] is Int64 ? args[3] as! Int64 : Int64(args[3] as! Int32) + do { + let result = try api.completions(partialWordRange: partialWordRangeArg, text: textArg, language: languageArg, inSpellDocumentWithTag: inSpellDocumentWithTagArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + completionsChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_count_words} + /// Returns the number of words in the specified string. + /// {@endtemplate} + let countWordsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.countWords\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + countWordsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let textArg = args[0] as! String + let languageArg: String? = nilOrValue(args[1]) + do { + let result = try api.countWords(text: textArg, language: languageArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + countWordsChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_learn_word} + /// Adds the [word] to the spell checker dictionary. + /// {@endtemplate} + let learnWordChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.learnWord\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + learnWordChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let wordArg = args[0] as! String + do { + try api.learnWord(word: wordArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + learnWordChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_has_learned_word} + /// Indicates whether the spell checker has learned a given word. + /// {@endtemplate} + let hasLearnedWordChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.hasLearnedWord\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + hasLearnedWordChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let wordArg = args[0] as! String + do { + let result = try api.hasLearnedWord(word: wordArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + hasLearnedWordChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_unlearn_word} + /// Tells the spell checker to unlearn a given word. + /// {@endtemplate} + let unlearnWordChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.unlearnWord\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + unlearnWordChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let wordArg = args[0] as! String + do { + try api.unlearnWord(word: wordArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + unlearnWordChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_ignore_word} + /// Instructs the spell checker to ignore all future occurrences of [word] in the document + /// identified by [documentTag]. + /// {@endtemplate} + let ignoreWordChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.ignoreWord\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + ignoreWordChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let wordArg = args[0] as! String + let documentTagArg = args[1] is Int64 ? args[1] as! Int64 : Int64(args[1] as! Int32) + do { + try api.ignoreWord(word: wordArg, documentTag: documentTagArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + ignoreWordChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_ignored_words} + /// Returns the array of ignored words for a document identified by [documentTag]. + /// {@endtemplate} + let ignoredWordsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.ignoredWords\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + ignoredWordsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let documentTagArg = args[0] is Int64 ? args[0] as! Int64 : Int64(args[0] as! Int32) + do { + let result = try api.ignoredWords(documentTag: documentTagArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + ignoredWordsChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_set_ignored_words} + /// Updates the ignored-words document (a dictionary identified by [documentTag] with [words]) + /// with a list of [words] to ignore. + /// {@endtemplate} + let setIgnoredWordsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.setIgnoredWords\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setIgnoredWordsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let wordsArg = args[0] as! [String] + let documentTagArg = args[1] is Int64 ? args[1] as! Int64 : Int64(args[1] as! Int32) + do { + try api.setIgnoredWords(words: wordsArg, documentTag: documentTagArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setIgnoredWordsChannel.setMessageHandler(nil) + } + /// {@template mac_spell_checker_user_replacements_dictionary} + /// Returns the dictionary used when replacing words, as defined by the user in the system preferences. + /// + /// This can be used to create an UI with replacement options when the user types a certain + /// combination of characters. For example, the user might want to automatically replace + /// "omw" with "on my way". When the user types "omw", an UI should display "on my way" as + /// a possible replacement. + /// {@endtemplate} + let userReplacementsDictionaryChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.super_editor_spellcheck.SpellCheckMac.userReplacementsDictionary\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + userReplacementsDictionaryChannel.setMessageHandler { _, reply in + do { + let result = try api.userReplacementsDictionary() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + userReplacementsDictionaryChannel.setMessageHandler(nil) + } + } +} diff --git a/super_editor_spellcheck/macos/super_editor_spellcheck.podspec b/super_editor_spellcheck/macos/super_editor_spellcheck.podspec new file mode 100644 index 0000000000..33823f121d --- /dev/null +++ b/super_editor_spellcheck/macos/super_editor_spellcheck.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint super_editor_spellcheck.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'super_editor_spellcheck' + s.version = '0.0.1' + s.summary = 'A plugin for running spellcheck against arbitrary text.' + s.description = <<-DESC +A plugin for running spellcheck against arbitrary text. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' +end diff --git a/super_editor_spellcheck/pigeon/messages.dart b/super_editor_spellcheck/pigeon/messages.dart new file mode 100644 index 0000000000..db47645c76 --- /dev/null +++ b/super_editor_spellcheck/pigeon/messages.dart @@ -0,0 +1,219 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/messages.g.dart', + swiftOut: 'macos/Classes/messages.g.swift', +)) +@HostApi() +abstract class SpellCheckMac { + /// {@template mac_spell_checker_available_languages} + /// A list containing all the available spell checking languages. + /// + /// The languages are ordered in the user’s preferred order as set in the + /// system preferences. + /// {@endtemplate} + List availableLanguages(); + + /// {@template mac_spell_checker_unique_spell_document_tag} + /// Returns a unique tag to partition stateful operations in the spell checking system. + /// + /// Use the tag returned by this method in the spell checking methods when there are different + /// texts being spell checked. + /// + /// For example, if there are two texts being spell checked, with tags `1` and `2`, + /// the spell checker will keep the state of the ignored words separate for each one. If an + /// ignored word is added to the tag `1`, it won't be seen as misspelled for tag `1`, but it + /// will be for the tag `2`. + /// + /// Call [closeSpellDocument] when you are done with the tag to release resources. + /// {@endtemplate} + int uniqueSpellDocumentTag(); + + /// {@template mac_spell_checker_close_spell_document} + /// Notifies the spell checking system that the user has finished with the tagged document. + /// + /// The spell checker will release any resources associated with the document, + /// including but not necessarily limited to, ignored words. + /// {@endtemplate} + void closeSpellDocument(int tag); + + /// {@template mac_spell_checker_check_spelling} + /// Searches for a misspelled word in [stringToCheck], starting at [startingOffset], and returns the + /// [TextRange] surrounding the misspelled word. + /// + /// If no misspelled word is found, a [TextRange] is returned with bounds of `-1`, which can also be + /// queried more conveniently with [TextRange.isValid]. + /// + /// To find all (or multiple) misspelled words in a given string, call this + /// method repeatedly, passing in different values for [startingOffset]. + /// {@endtemplate} + PigeonRange checkSpelling({ + required String stringToCheck, + required int startingOffset, + String? language, + bool wrap = false, + int inSpellDocumentWithTag = 0, + }); + + /// {@template mac_spell_checker_guesses} + /// Returns possible substitutions for the specified misspelled word at [range] inside the [text]. + /// + /// - [range]: The range, within the [text], for which possible substitutions should be generated. + /// - [text]: The string containing the word/text for which substitutions should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// {@endtemplate} + List? guesses({ + required String text, + required PigeonRange range, + String? language, + int inSpellDocumentWithTag = 0, + }); + + /// {@template mac_spell_checker_correction} + /// Returns a single proposed correction if a word is mis-spelled. + /// + /// - [range]: The range, within the [text], for which a possible should be generated. + /// - [text]: The string containing the word/text for which the correction should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// {@endtemplate} + String? correction({ + required String text, + required PigeonRange range, + required String language, + int inSpellDocumentWithTag = 0, + }); + + /// {@template mac_spell_checker_check_grammar} + /// Performs a grammatical analysis of [stringToCheck], starting at [startingOffset]. + /// + /// - [stringToCheck]: The string containing the text to be analyzed. + /// - [startingOffset]: Location within the text at which the analysis should start. + /// - [wrap]: `true` to specify that the analysis continue to the beginning of the text when + /// the end is reached. `false` to have the analysis stop at the end of the text. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [stringToCheck] in isolation, without connection to any given document. + /// {@endtemplate} + PigeonCheckGrammarResult checkGrammar({ + required String stringToCheck, + required int startingOffset, + String? language, + bool wrap = false, + int inSpellDocumentWithTag = 0, + }); + + /// {@template mac_spell_checker_completions} + /// Provides a list of complete words that the user might be trying to type based on a partial word + /// at [partialWordRange] in the given [text]. + /// + /// - [partialWordRange] - The range, within the [text], for which possible completions should be generated. + /// - [text] - The string containing the partial word for which completions should be generated. + /// - [inSpellDocumentWithTag]: The (optional) ID of the loaded document that contains the given [text], + /// which is used to provide additional context to the substitution guesses. A value of '0' instructs + /// the guessing system to consider the [text] in isolation, without connection to any given document. + /// + /// The items of the list are in the order they should be presented to the user. + /// {@endtemplate} + List? completions({ + required PigeonRange partialWordRange, + required String text, + String? language, + int inSpellDocumentWithTag = 0, + }); + + /// {@template mac_spell_checker_count_words} + /// Returns the number of words in the specified string. + /// {@endtemplate} + int countWords({required String text, String? language}); + + /// {@template mac_spell_checker_learn_word} + /// Adds the [word] to the spell checker dictionary. + /// {@endtemplate} + void learnWord(String word); + + /// {@template mac_spell_checker_has_learned_word} + /// Indicates whether the spell checker has learned a given word. + /// {@endtemplate} + bool hasLearnedWord(String word); + + /// {@template mac_spell_checker_unlearn_word} + /// Tells the spell checker to unlearn a given word. + /// {@endtemplate} + void unlearnWord(String word); + + /// {@template mac_spell_checker_ignore_word} + /// Instructs the spell checker to ignore all future occurrences of [word] in the document + /// identified by [documentTag]. + /// {@endtemplate} + void ignoreWord({required String word, required int documentTag}); + + /// {@template mac_spell_checker_ignored_words} + /// Returns the array of ignored words for a document identified by [documentTag]. + /// {@endtemplate} + List? ignoredWords(int documentTag); + + /// {@template mac_spell_checker_set_ignored_words} + /// Updates the ignored-words document (a dictionary identified by [documentTag] with [words]) + /// with a list of [words] to ignore. + /// {@endtemplate} + void setIgnoredWords({required List words, required int documentTag}); + + /// {@template mac_spell_checker_user_replacements_dictionary} + /// Returns the dictionary used when replacing words, as defined by the user in the system preferences. + /// + /// This can be used to create an UI with replacement options when the user types a certain + /// combination of characters. For example, the user might want to automatically replace + /// "omw" with "on my way". When the user types "omw", an UI should display "on my way" as + /// a possible replacement. + /// {@endtemplate} + Map userReplacementsDictionary(); +} + +/// A range of characters in a string of text. +/// +/// The text included in the range includes the character at [start], but not +/// the one at [end]. +/// +/// This is used because we can't use `TextRange` in pigeon. +class PigeonRange { + PigeonRange({ + required this.start, + required this.end, + }); + + final int start; + final int end; +} + +/// The result of a grammatical analysis. +class PigeonCheckGrammarResult { + PigeonCheckGrammarResult({ + this.firstError, + this.details, + }); + + /// The range of the first error found in the text or `null` if no errors were found. + final PigeonRange? firstError; + + /// A list of details about the grammatical errors found in the text or `null` + /// if no errors were found. + final List? details; +} + +/// A detail about a grammatical error found in a text. +class PigeonGrammaticalAnalysisDetail { + PigeonGrammaticalAnalysisDetail({ + required this.range, + required this.userDescription, + }); + + /// The range of the grammatical error in the text. + final PigeonRange range; + + /// A description of the grammatical error. + final String userDescription; +} diff --git a/super_editor_spellcheck/pubspec.yaml b/super_editor_spellcheck/pubspec.yaml new file mode 100644 index 0000000000..2087a7e0f6 --- /dev/null +++ b/super_editor_spellcheck/pubspec.yaml @@ -0,0 +1,90 @@ +name: super_editor_spellcheck +description: "A plugin for running spellcheck against arbitrary text." +version: 0.0.1 +homepage: + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.3.0" + +dependencies: + clock: ^1.1.2 + collection: ^1.18.0 + flutter: + sdk: flutter + follow_the_leader: ^0.5.1 + overlord: ^0.4.2 + plugin_platform_interface: ^2.0.2 + + super_editor: ^0.3.0-dev.38 + super_text_layout: ^0.1.12 + +dependency_overrides: + super_editor: + path: ../super_editor + super_text_layout: + path: ../super_text_layout + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + pigeon: ^26.1.2 + flutter_test_goldens: + git: + url: https://github.com/flutter-bounty-hunters/flutter_test_goldens + ref: e35c5d0ebb0cbf24bf33090199ec576671ebed26 + flutter_test_runners: ^0.0.4 + flutter_test_robots: ^0.0.24 + golden_bricks: ^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 packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + macos: + pluginClass: SuperEditorSpellcheckPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your plugin package, 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 in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/super_editor_spellcheck/test/spellcheck_ignore_rules_test.dart b/super_editor_spellcheck/test/spellcheck_ignore_rules_test.dart new file mode 100644 index 0000000000..09d727181f --- /dev/null +++ b/super_editor_spellcheck/test/spellcheck_ignore_rules_test.dart @@ -0,0 +1,420 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor_spellcheck/super_editor_spellcheck.dart'; + +void main() { + group('SuperEditor spellcheck >', () { + group('ignore rules >', () { + testWidgetsOnArbitraryDesktop('ignores by block type', (tester) async { + final spellCheckerService = _FakeSpellChecker(); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + 'This is a paragraph', + ), + ), + ParagraphNode( + id: "2", + text: AttributedText( + 'This is a code block', + ), + metadata: const { + NodeMetadata.blockType: codeAttribution, + }, + ), + ParagraphNode( + id: "3", + text: AttributedText( + 'This is another paragraph', + ), + ), + ParagraphNode( + id: "4", + text: AttributedText( + 'This is a blockquote', + ), + metadata: const { + NodeMetadata.blockType: blockquoteAttribution, + }, + ), + ], + ), + ignoreRules: [ + SpellingIgnoreRules.byBlockType(codeAttribution), + SpellingIgnoreRules.byBlockType(blockquoteAttribution), + ], + spellCheckerService: spellCheckerService, + ); + + // Ensure the spell checker service was queried for the paragraphs but + // not for the code block or blockquote. + expect(spellCheckerService.queriedTexts, [ + 'This is a paragraph', + 'This is another paragraph', + ]); + }); + + testWidgetsOnArbitraryDesktop('ignores by pattern', (tester) async { + final spellCheckerService = _FakeSpellChecker(); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'An user @mention and @another one', + ), + ), + ], + ), + ignoreRules: [ + // Ignores user mentions, like "@mention". + SpellingIgnoreRules.byPattern(RegExp(r'@\w+')), + ], + spellCheckerService: spellCheckerService, + ); + + // Ensure the spell checker service was queried without the text + // that matches the pattern. + expect(spellCheckerService.queriedTexts, ['An user and one']); + }); + + testWidgetsOnArbitraryDesktop('ignores by attribution', (tester) async { + final spellCheckerService = _FakeSpellChecker(); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'A bold text and another bold text', + AttributedSpans( + attributions: [ + // First "bold" word. + const SpanMarker( + attribution: boldAttribution, + offset: 2, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 5, + markerType: SpanMarkerType.end, + ), + // Second "bold" word. + const SpanMarker( + attribution: boldAttribution, + offset: 24, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: boldAttribution, + offset: 27, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ), + ], + ), + ignoreRules: [ + SpellingIgnoreRules.byAttribution(boldAttribution), + ], + spellCheckerService: spellCheckerService, + ); + + // Ensure the spell checker service was queried without the text + // that contains the bold attribution. + expect(spellCheckerService.queriedTexts, ['A text and another text']); + }); + + testWidgetsOnArbitraryDesktop('ignores by attribution filter', (tester) async { + final spellCheckerService = _FakeSpellChecker(); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'A link and another link', + AttributedSpans( + attributions: [ + // First link. + const SpanMarker( + attribution: LinkAttribution('https://www.google.com'), + offset: 2, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: LinkAttribution('https://www.google.com'), + offset: 5, + markerType: SpanMarkerType.end, + ), + // Second link. + const SpanMarker( + attribution: LinkAttribution('https://www.youtube.com'), + offset: 19, + markerType: SpanMarkerType.start, + ), + const SpanMarker( + attribution: LinkAttribution('https://www.youtube.com'), + offset: 22, + markerType: SpanMarkerType.end, + ), + ], + ), + ), + ), + ], + ), + ignoreRules: [ + SpellingIgnoreRules.byAttributionFilter((attribution) => attribution is LinkAttribution), + ], + spellCheckerService: spellCheckerService, + ); + + // Ensure the spell checker service was queried without the text + // that contains the link attribution. + expect(spellCheckerService.queriedTexts, ['A and another ']); + }); + + testWidgetsOnArbitraryDesktop('allows overlapping rules', (tester) async { + final spellCheckerService = _FakeSpellChecker(); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: Editor.createNodeId(), + text: AttributedText( + 'The first text and the second text', + ), + ), + ], + ), + ignoreRules: [ + (TextNode node) { + // The first text and the second text + // ^^^^^^^^ + return const [TextRange(start: 10, end: 18)]; + }, + // The first text and the second text + // ^^^^^^^^^^^^^^ + (TextNode node) { + return const [TextRange(start: 15, end: 29)]; + } + ], + spellCheckerService: spellCheckerService, + ); + + // Ensure the spell checker service was queried without the text + // of the overlapping ranges. + expect(spellCheckerService.queriedTexts, ['The first text']); + }); + + testWidgetsOnArbitraryDesktop( + 'does not run spell check when converting from paragraph to ignored block type (no delay)', + (tester) async { + final spellCheckerService = _FakeSpellChecker(); + + final editor = await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText(''), + ), + ], + ), + ignoreRules: [ + SpellingIgnoreRules.byBlockType(codeAttribution), + SpellingIgnoreRules.byBlockType(blockquoteAttribution), + ], + spellCheckerService: spellCheckerService, + ); + + // Place the caret in the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Trigger a regular spell check. + await tester.typeImeText("H"); + + // Ensure one spell check was run. + expect(spellCheckerService.queriedTexts, [ + 'H', + ]); + + // Convert paragraph to blockquote. + editor.execute([ + ChangeParagraphBlockTypeRequest(nodeId: "1", blockType: blockquoteAttribution), + ]); + await tester.pump(); + + // Type more text. + await tester.typeImeText("l"); + + // Ensure no additional spell checks were run. + expect(spellCheckerService.queriedTexts, [ + 'H', + ]); + + // Convert back to a paragraph. + editor.execute([ + ChangeParagraphBlockTypeRequest(nodeId: "1", blockType: paragraphAttribution), + ]); + await tester.pump(); + + // Type more text. + await tester.typeImeText("l"); + + // Ensure spell check was run after conversion, and after typing new text. + expect(spellCheckerService.queriedTexts, [ + // In original paragraph. + 'H', + // After converting from blockquote back to paragraph. + 'Hl', + // After inserting 'l' in paragraph that was converted from blockquote. + 'Hll', + ]); + + // Convert paragraph to a code block + editor.execute([ + ChangeParagraphBlockTypeRequest(nodeId: "1", blockType: codeAttribution), + ]); + await tester.pump(); + + // Type more text. + await tester.typeImeText("o"); + + // Ensure no further spell checks were run upon conversion or new text typed. + expect(spellCheckerService.queriedTexts, [ + // In original paragraph. + 'H', + // After converting from blockquote back to paragraph. + 'Hl', + // After inserting 'l' in paragraph that was converted from blockquote. + 'Hll', + ]); + }, + ); + + testWidgetsOnArbitraryDesktop( + 'does not run spell check when converting from paragraph to ignored block type (with delay)', + (tester) async { + final testClock = SpellcheckClock.forTesting(tester); + final spellCheckerService = _FakeSpellChecker(); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText(''), + ), + ], + ), + spellCheckDelay: const Duration(seconds: 1), + ignoreRules: [ + SpellingIgnoreRules.byBlockType(codeAttribution), + SpellingIgnoreRules.byBlockType(blockquoteAttribution), + ], + spellCheckerService: spellCheckerService, + clock: testClock, + ); + + // Place the caret in the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Type text that should be spell checked after a delay. + // + // Don't let the test clock pump frames - otherwise it will pump until the spellcheck + // timer goes off, and then we can't verify whether the check happened immediately, or + // after the intended delay. + testClock.pauseAutomaticFramePumping(); + await tester.typeImeText("H"); + + // Ensure spell check doesn't run immediately. + expect(spellCheckerService.queriedTexts, [ + // empty. + ]); + + // Simulate a delay. + await tester.pump(const Duration(seconds: 1)); + + // Ensure spell check was run after delay. + expect(spellCheckerService.queriedTexts, [ + "H", + ]); + }, + ); + }); + }); +} + +Future _pumpTestApp( + WidgetTester tester, { + required MutableDocument document, + List ignoreRules = const [], + SpellCheckService? spellCheckerService, + Duration spellCheckDelay = Duration.zero, + SpellcheckClock? clock, +}) async { + final editor = createDefaultDocumentEditor( + document: document, + composer: MutableDocumentComposer(), + ); + + final plugin = SpellingAndGrammarPlugin( + ignoreRules: ignoreRules, + spellCheckService: spellCheckerService, + spellCheckDelayAfterEdit: spellCheckDelay, + clock: clock, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + plugins: {plugin}, + ), + ), + ), + ); + + return editor; +} + +/// A [SpellCheckService] that records the texts that were queried and returns +/// an empty list of suggestions for each query. +class _FakeSpellChecker extends SpellCheckService { + List get queriedTexts => UnmodifiableListView(_queriedTexts); + final List _queriedTexts = []; + + @override + Future?> fetchSpellCheckSuggestions(Locale locale, String text) async { + _queriedTexts.add(text); + return const []; + } +} diff --git a/super_editor_spellcheck/test/spellcheck_timing_test.dart b/super_editor_spellcheck/test/spellcheck_timing_test.dart new file mode 100644 index 0000000000..ead6f55040 --- /dev/null +++ b/super_editor_spellcheck/test/spellcheck_timing_test.dart @@ -0,0 +1,171 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor_spellcheck/src/super_editor/spellcheck_clock.dart'; +import 'package:super_editor_spellcheck/super_editor_spellcheck.dart'; + +void main() { + group('SuperEditor spellcheck > timing >', () { + testWidgetsOnArbitraryDesktop( + 'waits for a specified delay before running spellcheck', + (tester) async { + final testClock = SpellcheckClock.forTesting(tester); + final spellCheckerService = _FakeSpellChecker(); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText(''), + ), + ], + ), + spellCheckDelay: const Duration(seconds: 5), + // ^ Make sure delay is longer than the simulated typing speed. + ignoreRules: [ + SpellingIgnoreRules.byBlockType(codeAttribution), + SpellingIgnoreRules.byBlockType(blockquoteAttribution), + ], + spellCheckerService: spellCheckerService, + clock: testClock, + ); + + // Place the caret in the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Type text that should be spell checked after a delay. + // + // Don't let the test clock pump frames - otherwise it will pump until the spellcheck + // timer goes off, and then we can't verify whether the check happened immediately, or + // after the intended delay. + testClock.pauseAutomaticFramePumping(); + await tester.typeImeText("Hllo"); + + // Ensure spell check doesn't run immediately. + expect(spellCheckerService.queriedTexts, [ + // empty. + ]); + + // Simulate a delay. + await tester.pump(const Duration(seconds: 5)); + + // Ensure spell check was run after delay. + expect(spellCheckerService.queriedTexts, [ + "Hllo", + ]); + }, + ); + + testWidgetsOnArbitraryDesktop( + 'resets timer as user types', + (tester) async { + final testClock = SpellcheckClock.forTesting(tester); + final spellCheckerService = _FakeSpellChecker(); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText(''), + ), + ], + ), + spellCheckDelay: const Duration(seconds: 5), + // ^ Make sure delay is longer than the simulated typing speed. + ignoreRules: [ + SpellingIgnoreRules.byBlockType(codeAttribution), + SpellingIgnoreRules.byBlockType(blockquoteAttribution), + ], + spellCheckerService: spellCheckerService, + clock: testClock, + ); + + // Place the caret in the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Don't let the test clock pump frames - otherwise it will pump until the spellcheck + // timer goes off, and then we can't verify whether the check happened immediately, or + // after the intended delay. + testClock.pauseAutomaticFramePumping(); + + // Type one character at a time, fast enough to never trigger the timer, but slow enough + // that if the timer wasn't resetting itself, the first timer would go off and trigger + // a spell check. + await tester.typeImeText("H"); + await tester.pump(const Duration(seconds: 2)); + + await tester.typeImeText("l"); + await tester.pump(const Duration(seconds: 2)); + + await tester.typeImeText("l"); + await tester.pump(const Duration(seconds: 2)); + + await tester.typeImeText("o"); + await tester.pump(const Duration(seconds: 2)); + + // Ensure spell check didn't run. Even though we took much longer than 5 seconds + // in total, we never waited 5 seconds before inserting more text. Therefore, no + // spell check should have been triggered. + expect(spellCheckerService.queriedTexts, [ + // empty. + ]); + }, + ); + }); +} + +Future _pumpTestApp( + WidgetTester tester, { + required MutableDocument document, + List ignoreRules = const [], + SpellCheckService? spellCheckerService, + Duration spellCheckDelay = Duration.zero, + SpellcheckClock? clock, +}) async { + final editor = createDefaultDocumentEditor( + document: document, + composer: MutableDocumentComposer(), + ); + + final plugin = SpellingAndGrammarPlugin( + ignoreRules: ignoreRules, + spellCheckService: spellCheckerService, + spellCheckDelayAfterEdit: spellCheckDelay, + clock: clock, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + plugins: {plugin}, + ), + ), + ), + ); + + return editor; +} + +/// A [SpellCheckService] that records the texts that were queried and returns +/// an empty list of suggestions for each query. +class _FakeSpellChecker extends SpellCheckService { + List get queriedTexts => UnmodifiableListView(_queriedTexts); + final List _queriedTexts = []; + + @override + Future?> fetchSpellCheckSuggestions(Locale locale, String text) async { + _queriedTexts.add(text); + return const []; + } +} diff --git a/super_editor_spellcheck/test/spelling_and_grammar_plugin_registration_test.dart b/super_editor_spellcheck/test/spelling_and_grammar_plugin_registration_test.dart new file mode 100644 index 0000000000..7a83b7d393 --- /dev/null +++ b/super_editor_spellcheck/test/spelling_and_grammar_plugin_registration_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_spellcheck/super_editor_spellcheck.dart'; + +void main() { + group("Spelling and grammar plugin > registration >", () { + testWidgets("handles replacement of one Editor with another", (tester) async { + // Create Editor and SpellingAndGrammarPlugin explicitly, so that we can be + // sure about which instance is being used in a pump. + var editor = createDefaultDocumentEditor( + document: MutableDocument.empty(), + composer: MutableDocumentComposer(), + ); + final plugin = SpellingAndGrammarPlugin( + androidControlsController: SuperEditorAndroidControlsController(), + ); + + // Pump the initial UI. + await _pumpScaffold(tester, editor: editor, plugin: plugin); + + // Let things settle. We don't really care what's happening here. + await tester.pumpAndSettle(); + + // Replace the original Editor with a new one, which simulates something + // like one document being replaced by another document in the same UI. + editor = createDefaultDocumentEditor( + document: MutableDocument.empty(), + composer: MutableDocumentComposer(), + ); + await _pumpScaffold(tester, editor: editor, plugin: plugin); + + // Let things settle. We don't really care what's happening here. + await tester.pumpAndSettle(); + + // If we got here without an error, it means that the `Editor` and the `EditContext` + // were replaced without generating any errors in the `SpellingAndGrammarPlugin`. + }); + }); +} + +Future _pumpScaffold( + WidgetTester tester, { + Editor? editor, + SpellingAndGrammarPlugin? plugin, +}) async { + editor ??= createDefaultDocumentEditor( + document: MutableDocument.empty(), + composer: MutableDocumentComposer(), + ); + + plugin ??= SpellingAndGrammarPlugin(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + plugins: {plugin}, + ), + ), + ), + ); + + return editor; +} diff --git a/super_editor_spellcheck/test_goldens/spellcheck_timing_test.dart b/super_editor_spellcheck/test_goldens/spellcheck_timing_test.dart new file mode 100644 index 0000000000..339e69dcae --- /dev/null +++ b/super_editor_spellcheck/test_goldens/spellcheck_timing_test.dart @@ -0,0 +1,323 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/flutter_test_goldens.dart'; +import 'package:flutter_test_goldens/golden_bricks.dart'; +import 'package:flutter_test_robots/flutter_test_robots.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor_spellcheck/super_editor_spellcheck.dart'; + +void main() { + group('SuperEditor spellcheck > timing >', () { + testGoldenSceneOnAndroid( + // ^ Test on mobile to prevent an attempt to talk to the native Mac spelling and grammar system. + 'does not automatically re-apply spellcheck underline after deleting misspelled word', + (tester) async { + final clock = SpellcheckClock.forTesting(tester); + + await FilmStrip( + tester, + goldenName: "spelling-error-underline-reset_${spellcheckDelayVariant.currentValue!.fileNameQualifier}", + layout: SceneLayout.column, + ) + .setup((tester) async { + final spellCheckerService = _FakeSpellChecker({ + "Hllo": ["Hello"], + "Flutt": ["Flutter"], + }); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText(''), + ), + ], + ), + spellCheckerService: spellCheckerService, + spellCheckDelay: spellcheckDelayVariant.currentValue == SpellcheckDelayVariant.withDelay + ? const Duration(seconds: 3) + : Duration.zero, + clock: clock, + ); + + // Place the caret in the paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Stop our clock from pumping frames so that as we delete and insert + // characters, we don't wait for the spellcheck before deleting or + // inserting more characters. + clock.pauseAutomaticFramePumping(); + + // Type a misspelled word. + await tester.typeImeText("Hllo"); + + // Wait long enough to trigger a spell check. + await tester.pump(const Duration(seconds: 4)); + + // One extra pump to render the underline. + await tester.pump(); + }) + .takePhoto("Original Error", find.byType(ParagraphComponent)) + .modifyScene((tester, testContext) async { + await tester.pressBackspace(); + }) + .takePhoto("3 Characters", find.byType(ParagraphComponent)) + .modifyScene((tester, testContext) async { + await tester.typeImeText("__"); + }) + .takePhoto("Add back and more", find.byType(ParagraphComponent)) + .modifyScene((tester, testContext) async { + await tester.pressBackspace(); + await tester.pressBackspace(); + await tester.pressBackspace(); + }) + .takePhoto("2 Characters", find.byType(ParagraphComponent)) + .modifyScene((tester, testContext) async { + await tester.pressBackspace(); + }) + .takePhoto("1 Character", find.byType(ParagraphComponent)) + .modifyScene((tester, testContext) async { + await tester.pressBackspace(); + }) + .takePhoto("Empty", find.byType(ParagraphComponent)) + .modifyScene((tester, testContext) async { + await tester.typeImeText("F"); + }) + .takePhoto("Insert Character", find.byType(ParagraphComponent)) + .modifyScene((tester, testContext) async { + await tester.typeImeText("lutt"); + + // Wait for the spell check to kick in. + await tester.pump(const Duration(seconds: 4)); + + // One extra pump to render the underline. + await tester.pump(); + }) + .takePhoto("Report New Error", find.byType(ParagraphComponent)) + .renderOrCompareGolden(); + }, + variant: spellcheckDelayVariant, + ); + + testGoldenSceneOnAndroid( + // ^ Test on mobile to prevent an attempt to talk to the native Mac spelling and grammar system. + 'immediately adjusts downstream spellcheck underlines when typing new characters', + (tester) async { + final clock = SpellcheckClock.forTesting(tester); + + await FilmStrip( + tester, + goldenName: + "spelling-error-underlines-after-upstream-typing_${spellcheckDelayVariant.currentValue!.fileNameQualifier}", + layout: SceneLayout.column, + ) + .setup((tester) async { + final spellCheckerService = _FakeSpellChecker({ + "msplled": ["misspelled"], + "mlutpel": ["multiple"], + "bnodes": ["bounds"], + }); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + 'Paragraph with msplled words at mlutpel places to check bnodes', + ), + ), + ], + ), + spellCheckerService: spellCheckerService, + spellCheckDelay: spellcheckDelayVariant.currentValue == SpellcheckDelayVariant.withDelay + ? const Duration(seconds: 3) + : Duration.zero, + clock: clock, + ); + + // One more pump to paint underlines. + await tester.pump(); + }) + .takePhoto("Errors at start", find.byType(ParagraphComponent)) + .modifyScene((tester, testContext) async { + // Place caret somewhere after the first misspelled word, and before the others. + await tester.placeCaretInParagraph("1", 23); + + // Pause the clock's automatic ticker, so that we don't immediately + // pump multiple seconds of frames and trigger a spellcheck update. + clock.pauseAutomaticFramePumping(); + + // Type some new characters. + await tester.typeImeText("new "); + }) + .takePhoto("After typing characters", find.byType(ParagraphComponent)) + .modifyScene((tester, testContext) async { + // Delete some of the characters we just added. + await tester.pressBackspace(); + await tester.pressBackspace(); + }) + .takePhoto("After deleting characters", find.byType(ParagraphComponent)) + .renderOrCompareGolden(); + }, + variant: spellcheckDelayVariant, + ); + + testGoldenSceneOnAndroid( + // ^ Test on mobile to prevent an attempt to talk to the native Mac spelling and grammar system. + 'immediately adjusts downstream spellcheck underlines when replacing upstream word', + (tester) async { + final clock = SpellcheckClock.forTesting(tester); + + await FilmStrip( + tester, + goldenName: + "spelling-error-underlines-after-upstream-replacement_${spellcheckDelayVariant.currentValue!.fileNameQualifier}", + layout: SceneLayout.column, + ) + .setup((tester) async { + final spellCheckerService = _FakeSpellChecker({ + "msplled": ["misspelled"], + "mlutpel": ["multiple"], + "bnodes": ["bounds"], + }); + + await _pumpTestApp( + tester, + document: MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText( + 'Paragraph with msplled words at mlutpel places to check bnodes', + ), + ), + ], + ), + spellCheckerService: spellCheckerService, + spellCheckDelay: spellcheckDelayVariant.currentValue == SpellcheckDelayVariant.withDelay + ? const Duration(seconds: 3) + : Duration.zero, + clock: clock, + ); + + // One more pump to paint underlines. + await tester.pump(); + }) + .takePhoto("Errors at start", find.byType(ParagraphComponent)) + .modifyScene((tester, testContext) async { + await tester.placeCaretInParagraph("1", 18); + + // With the current implementation of flutter_test_goldens, we won't + // be able to see the popover toolbar. So check for it with a Finder + // and then tap the suggestion. + expect(find.byType(AndroidSpellingSuggestionToolbar), findsOne); + await tester.tap(find.text("misspelled")); + await tester.pump(); + }) + .takePhoto("After correcting first word", find.byType(ParagraphComponent)) + .renderOrCompareGolden(); + }, + variant: spellcheckDelayVariant, + ); + }); +} + +Future _pumpTestApp( + WidgetTester tester, { + required MutableDocument document, + List ignoreRules = const [], + SpellCheckService? spellCheckerService, + Duration spellCheckDelay = Duration.zero, + SpellcheckClock? clock, +}) async { + final editor = createDefaultDocumentEditor( + document: document, + composer: MutableDocumentComposer(), + ); + + final plugin = SpellingAndGrammarPlugin( + ignoreRules: ignoreRules, + spellCheckService: spellCheckerService, + spellCheckDelayAfterEdit: spellCheckDelay, + clock: clock ?? SpellcheckClock.forTesting(tester), + androidControlsController: SuperEditorAndroidControlsController(), + iosControlsController: SuperEditorIosControlsController(), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SuperEditor( + editor: editor, + stylesheet: _stylesheet, + plugins: {plugin}, + ), + ), + ), + ); + + return editor; +} + +final _stylesheet = defaultStylesheet.copyWith( + inlineTextStyler: (Set attributions, TextStyle baseStyle) { + return defaultStylesheet.inlineTextStyler(attributions, baseStyle).copyWith( + fontFamily: goldenBricks, + ); + }, +); + +/// A [SpellCheckService] that records the texts that were queried and returns +/// a pre-canned list of suggestions for each query. +class _FakeSpellChecker extends SpellCheckService { + _FakeSpellChecker([this._replacements = const {}]); + + final Map> _replacements; + + List get queriedTexts => UnmodifiableListView(_queriedTexts); + final List _queriedTexts = []; + + @override + Future?> fetchSpellCheckSuggestions(Locale locale, String text) async { + _queriedTexts.add(text); + + final fakeSuggestions = []; + for (final misspelledWord in _replacements.keys) { + int nextMisspelledWord = 0; + do { + nextMisspelledWord = text.indexOf(misspelledWord, nextMisspelledWord); + if (nextMisspelledWord >= 0) { + fakeSuggestions.add( + SuggestionSpan( + TextRange(start: nextMisspelledWord, end: nextMisspelledWord + misspelledWord.length), + _replacements[misspelledWord]!, + ), + ); + nextMisspelledWord += 1; + } + } while (nextMisspelledWord >= 0); + } + + return fakeSuggestions; + } +} + +final spellcheckDelayVariant = ValueVariant(SpellcheckDelayVariant.values.toSet()); + +enum SpellcheckDelayVariant { + noDelay, + withDelay; + + String get fileNameQualifier => switch (this) { + SpellcheckDelayVariant.noDelay => "no-delay", + SpellcheckDelayVariant.withDelay => "with-delay", + }; +} diff --git a/super_editor_spellcheck/test_goldens/spelling-error-underline-reset_no-delay.png b/super_editor_spellcheck/test_goldens/spelling-error-underline-reset_no-delay.png new file mode 100644 index 0000000000..f3a605b33f Binary files /dev/null and b/super_editor_spellcheck/test_goldens/spelling-error-underline-reset_no-delay.png differ diff --git a/super_editor_spellcheck/test_goldens/spelling-error-underline-reset_with-delay.png b/super_editor_spellcheck/test_goldens/spelling-error-underline-reset_with-delay.png new file mode 100644 index 0000000000..f3a605b33f Binary files /dev/null and b/super_editor_spellcheck/test_goldens/spelling-error-underline-reset_with-delay.png differ diff --git a/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-replacement_no-delay.png b/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-replacement_no-delay.png new file mode 100644 index 0000000000..5fa9f9433b Binary files /dev/null and b/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-replacement_no-delay.png differ diff --git a/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-replacement_with-delay.png b/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-replacement_with-delay.png new file mode 100644 index 0000000000..5fa9f9433b Binary files /dev/null and b/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-replacement_with-delay.png differ diff --git a/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-typing_no-delay.png b/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-typing_no-delay.png new file mode 100644 index 0000000000..f36923b68f Binary files /dev/null and b/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-typing_no-delay.png differ diff --git a/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-typing_with-delay.png b/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-typing_with-delay.png new file mode 100644 index 0000000000..f36923b68f Binary files /dev/null and b/super_editor_spellcheck/test_goldens/spelling-error-underlines-after-upstream-typing_with-delay.png differ diff --git a/super_keyboard/.gitignore b/super_keyboard/.gitignore new file mode 100644 index 0000000000..ac5aa9893e --- /dev/null +++ b/super_keyboard/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# 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/ +build/ diff --git a/super_keyboard/.metadata b/super_keyboard/.metadata new file mode 100644 index 0000000000..bf1e3f94b9 --- /dev/null +++ b/super_keyboard/.metadata @@ -0,0 +1,36 @@ +# 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: "80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819" + channel: "[user-branch]" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + - platform: android + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + - platform: ios + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + - platform: web + create_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + base_revision: 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_keyboard/CHANGELOG.md b/super_keyboard/CHANGELOG.md new file mode 100644 index 0000000000..2cdc64c730 --- /dev/null +++ b/super_keyboard/CHANGELOG.md @@ -0,0 +1,4 @@ +## [0.1.0] - [DATE] +Initial release: + * iOS: Reports keyboard closed, opening, open, and closing. No keyboard height. + * Android: Reports keyboard closed, opening, open, and closing, as well as keyboard height. diff --git a/super_keyboard/LICENSE b/super_keyboard/LICENSE new file mode 100644 index 0000000000..df5ad7ba31 --- /dev/null +++ b/super_keyboard/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2021 Superlist, SuperDeclarative! and the contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/super_keyboard/README.md b/super_keyboard/README.md new file mode 100644 index 0000000000..4b8c5e1f89 --- /dev/null +++ b/super_keyboard/README.md @@ -0,0 +1,63 @@ +# Super Keyboard +A plugin that reports keyboard visibility and size. + +## Support Platforms +This plugin supports iOS and Android. + +## Unified API +For users that don't care about differences between how iOS and Android report +keyboard information, the easiest way to use `super_keyboard` is through the +unified (lowest common denominator) API. + +Build a widget subtree based on the keyboard state: +```dart +@override +Widget build(BuildContext context) { + return SuperKeyboardBuilder( + builder: (context, keyboardGeometry) { + // TODO: do something with the keyboard state and size. + return const SizedBox(); + } + ); +} +``` + +Directly listen for changes to the keyboard state: +```dart +void startListeningToKeyboardState() { + SuperKeyboard.instance.geometry.addListener(_onKeyboardChange); +} + +void stopListeningToKeyboardState() { + SuperKeyboard.instance.geometry.removeListener(_onKeyboardChange); +} + +void _onKeyboardChange(MobileWindowGeometry geometry) { + // TODO: do something with the new keyboard state and size. +} +``` + +**Note:** This plugin is limited by the APIs of the underlying platform. For example, +iOS does not provide any direct means of querying the keyboard height as it opens and +closes. Therefore, on iOS, while this plugin does notify clients about the keyboard +opening and closing, the reported keyboard height during those transitions won't match +the visual keyboard on screen. + +Activate logs: +```dart +SuperKeyboard.startLogging(); +``` + +## iOS and Android +Platform-specific APIs are also available. The unified `SuperKeyboard` API +delegates to the platform-specific APIs under the hood. + +iOS is available in `SuperKeyboardIOS`. + +Android is available in `SuperKeyboardAndroid`. + +Per-platform APIs are made available because each platform reports keyboard +state and height in different ways. Those reporting methods may, or may not +be compatible with each in general. Also, one platform might report more +keyboard information than the other. We want to provide the maximum information +possible to users, which can't be done with a lowest common denominator API. \ No newline at end of file diff --git a/super_keyboard/analysis_options.yaml b/super_keyboard/analysis_options.yaml new file mode 100644 index 0000000000..ce17390693 --- /dev/null +++ b/super_keyboard/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - doc/** + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/super_keyboard/android/.gitignore b/super_keyboard/android/.gitignore new file mode 100644 index 0000000000..161bdcdaf8 --- /dev/null +++ b/super_keyboard/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/super_keyboard/android/build.gradle b/super_keyboard/android/build.gradle new file mode 100644 index 0000000000..7ec1e8cdc4 --- /dev/null +++ b/super_keyboard/android/build.gradle @@ -0,0 +1,68 @@ +group = "com.flutterbountyhunters.superkeyboard.super_keyboard" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "1.7.10" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.1.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + if (project.android.hasProperty("namespace")) { + namespace = "com.flutterbountyhunters.superkeyboard.super_keyboard" + } + + compileSdk = 34 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} diff --git a/super_keyboard/android/settings.gradle b/super_keyboard/android/settings.gradle new file mode 100644 index 0000000000..c49919a88b --- /dev/null +++ b/super_keyboard/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'super_keyboard' diff --git a/super_keyboard/android/src/main/AndroidManifest.xml b/super_keyboard/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..545ea100f9 --- /dev/null +++ b/super_keyboard/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/super_keyboard/android/src/main/kotlin/com/flutterbountyhunters/superkeyboard/super_keyboard/SuperKeyboardLog.kt b/super_keyboard/android/src/main/kotlin/com/flutterbountyhunters/superkeyboard/super_keyboard/SuperKeyboardLog.kt new file mode 100644 index 0000000000..41e6767bf4 --- /dev/null +++ b/super_keyboard/android/src/main/kotlin/com/flutterbountyhunters/superkeyboard/super_keyboard/SuperKeyboardLog.kt @@ -0,0 +1,79 @@ +package com.flutterbountyhunters.superkeyboard.super_keyboard + +import android.util.Log +import io.flutter.plugin.common.MethodChannel + +object SuperKeyboardLog { + private var isLoggingEnabled: Boolean = false + private var reportTo: MethodChannel? = null + + fun enable(reportTo: MethodChannel?) { + isLoggingEnabled = true + this.reportTo = reportTo + } + + fun disable() { + isLoggingEnabled = false + reportTo = null + } + + fun v(tag: String, message: String) { + if (isLoggingEnabled) { + if (reportTo == null) { + Log.v(tag, message) + } else { + reportToDart("v", message); + } + } + } + + fun d(tag: String, message: String) { + if (isLoggingEnabled) { + if (reportTo == null) { + Log.d(tag, message) + } else { + reportToDart("d", message); + } + } + } + + fun i(tag: String, message: String) { + if (isLoggingEnabled) { + if (reportTo == null) { + Log.i(tag, message) + } else { + reportToDart("i", message); + } + } + } + + fun w(tag: String, message: String) { + if (isLoggingEnabled) { + if (reportTo == null) { + Log.w(tag, message) + } else { + reportToDart("w", message); + } + } + } + + fun e(tag: String, message: String, throwable: Throwable? = null) { + if (isLoggingEnabled) { + if (reportTo == null) { + Log.e(tag, message, throwable) + } else { + reportToDart("e", message); + } + } + } + + private fun reportToDart(level: String, message: String) { + reportTo!!.invokeMethod( + "log", + mapOf( + "level" to level, + "message" to message, + ) + ) + } +} \ No newline at end of file diff --git a/super_keyboard/android/src/main/kotlin/com/flutterbountyhunters/superkeyboard/super_keyboard/SuperKeyboardPlugin.kt b/super_keyboard/android/src/main/kotlin/com/flutterbountyhunters/superkeyboard/super_keyboard/SuperKeyboardPlugin.kt new file mode 100644 index 0000000000..4f0ffeaa0b --- /dev/null +++ b/super_keyboard/android/src/main/kotlin/com/flutterbountyhunters/superkeyboard/super_keyboard/SuperKeyboardPlugin.kt @@ -0,0 +1,353 @@ +package com.flutterbountyhunters.superkeyboard.super_keyboard + +import android.app.Activity +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.embedding.engine.plugins.lifecycle.FlutterLifecycleAdapter +import io.flutter.plugin.common.MethodChannel +import kotlin.math.roundToInt + + +/** + * Plugin that reports software keyboard state changes, and (maybe) keyboard height changes. + * + * Tracking keyboard height is more difficult. There may be some platforms that don't + * support height tracking. + * + * Android Docs: https://developer.android.com/develop/ui/views/layout/sw-keyboard + */ +class SuperKeyboardPlugin: FlutterPlugin, ActivityAware, DefaultLifecycleObserver, OnApplyWindowInsetsListener { + private lateinit var channel : MethodChannel + + private var binding: ActivityPluginBinding? = null + + // The Activity's lifecycle, which reports things like when the Android + // app comes into the foreground from the background. + private var lifecycle: Lifecycle? = null + + // The root view within the Android Activity. + private var mainView: View? = null + + // The manager for text input for the Android Activity. + private lateinit var ime: InputMethodManager + + // The most recent known state of the software keyboard. + private var keyboardState: KeyboardState = KeyboardState.Closed + + // The device's DPI, used to map to logical pixels before sending the + // keyboard height to Flutter. + private var dpi: Float = 1.0f + + // The most recent measurement of the keyboard height. + private var imeHeightInDpi: Float = 0f + // The most recent measurement of the gesture area at the bottom of the screen. + private var bottomPaddingInDpi: Float = 0f + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + SuperKeyboardLog.d("super_keyboard", "Attached to Flutter engine") + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "super_keyboard_android") + + channel.setMethodCallHandler { call, result -> + when (call.method) { + "startLogging" -> { + val forwardToDart = call.argument("sendPlatformLogsToDart") ?: false + SuperKeyboardLog.enable(if (forwardToDart) channel else null) + result.success(null) + } + "stopLogging" -> { + SuperKeyboardLog.disable() + result.success(null) + } + else -> result.notImplemented() + } + } + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + SuperKeyboardLog.d("super_keyboard", "Attached to Flutter Activity") + this.binding = binding + this.dpi = binding.activity.resources.displayMetrics.density; + startListeningToActivityLifecycle() + } + + override fun onResume(owner: LifecycleOwner) { + SuperKeyboardLog.d("super_keyboard", "Activity Resumed - keyboard state: $keyboardState") + startListeningForKeyboardChanges(binding!!) + + // It's possible that, while paused, the keyboard went from closed to open, or open to closed. + // In practice, it's far more common to go from open to closed. However, while debugging some + // buggy lifecycle fluctuations on Android API 35 on a Pixel 9 Pro, it was found that it's also + // possible to pause while closed, and resume with the keyboard open. + measureInsets() + + SuperKeyboardLog.v("super_keyboard", "Insets at time of resume are - Keyboard: $imeHeightInDpi, Bottom Padding: $bottomPaddingInDpi") + if (imeHeightInDpi.roundToInt() == 0 && keyboardState != KeyboardState.Closed) { + SuperKeyboardLog.d("super_keyboard", "Keyboard closed while paused - sending keyboardClosed message to Flutter."); + keyboardState = KeyboardState.Closed + sendMessageKeyboardClosed() + } else if (imeHeightInDpi > 0 && keyboardState != KeyboardState.Open) { + SuperKeyboardLog.d("super_keyboard", "Keyboard opened while paused - sending keyboardOpened message to Flutter."); + keyboardState = KeyboardState.Open + sendMessageKeyboardOpened() + } else { + SuperKeyboardLog.d("super_keyboard", "Reporting latest metrics to Flutter, just in case they got out of sync."); + sendMessageMetricsUpdate() + } + } + + override fun onPause(owner: LifecycleOwner) { + SuperKeyboardLog.d("super_keyboard", "Activity Paused - keyboard state: $keyboardState") + stopListeningForKeyboardChanges() + } + + override fun onDetachedFromActivityForConfigChanges() { + SuperKeyboardLog.v("super_keyboard", "Detaching from Activity for config changes") + stopListeningToActivityLifecycle() + this.binding = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + SuperKeyboardLog.v("super_keyboard", "Re-attaching to Activity for config changes") + startListeningToActivityLifecycle() + this.binding = binding + } + + override fun onDetachedFromActivity() { + SuperKeyboardLog.d("super_keyboard", "Detached from Flutter activity") + stopListeningToActivityLifecycle() + this.binding = null + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + SuperKeyboardLog.d("super_keyboard", "Detached from Flutter engine") + SuperKeyboardLog.disable() + this.binding = null + } + + private fun startListeningToActivityLifecycle() { + SuperKeyboardLog.v("super_keyboard", "Starting to listen to Activity lifecycle") + lifecycle = FlutterLifecycleAdapter.getActivityLifecycle(binding!!) + lifecycle!!.addObserver(this) + } + + private fun stopListeningToActivityLifecycle() { + SuperKeyboardLog.v("super_keyboard", "Stopping listening to Activity lifecycle") + lifecycle!!.removeObserver(this); + } + + private fun startListeningForKeyboardChanges(binding: ActivityPluginBinding) { + SuperKeyboardLog.v("super_keyboard", "Starting to listen for keyboard changes") + val activity = binding.activity + + mainView = activity.findViewById(android.R.id.content) + ime = activity.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + if (mainView == null) { + // This should never happen. If it does, we just fizzle. + return; + } + + // Track keyboard opening and closing. + ViewCompat.setOnApplyWindowInsetsListener(mainView!!, this) + + // Track keyboard fully open, fully closed, and height. + ViewCompat.setWindowInsetsAnimationCallback( + mainView!!, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + override fun onPrepare( + animation: WindowInsetsAnimationCompat + ) { + // no-op + SuperKeyboardLog.v("super_keyboard", "Insets animation callback - onPrepare() - current keyboard state: $keyboardState") + } + + override fun onStart( + animation: WindowInsetsAnimationCompat, + bounds: WindowInsetsAnimationCompat.BoundsCompat + ): WindowInsetsAnimationCompat.BoundsCompat { + // no-op + SuperKeyboardLog.v("super_keyboard", "Insets animation callback - onStart() - current keyboard state: $keyboardState") + return bounds + } + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: MutableList + ): WindowInsetsCompat { + SuperKeyboardLog.v("super_keyboard", "Insets animation callback - onProgress() - current keyboard state: $keyboardState") + + // Update our cached measurements. + imeHeightInDpi = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom / dpi + bottomPaddingInDpi = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()).bottom / dpi + SuperKeyboardLog.v("super_keyboard", "On progress keyboard height: $imeHeightInDpi, is IME visible: ${insets.isVisible(WindowInsetsCompat.Type.ime())}") + + // Report our newly cached measurements to Flutter. + sendMessageKeyboardProgress() + + return insets + } + + override fun onEnd( + animation: WindowInsetsAnimationCompat + ) { + // Report whether the keyboard has fully opened or fully closed. + SuperKeyboardLog.i("super_keyboard", "Insets animation callback - onEnd - current keyboard state: $keyboardState") + if (keyboardState == KeyboardState.Opening) { + // It was discovered that on at least some Samsung devices, such as Galaxy S24 on API 14 + // and API 16, `onEnd()` runs a frame before the final keyboard inset height is applied. + // Therefore, we always wait a frame to report the completion of the keyboard opening. + Handler(Looper.getMainLooper()).post { + SuperKeyboardLog.i("super_keyboard", "Sending new keyboard state: open") + + // Update our cached measurements. + val insets = ViewCompat.getRootWindowInsets(binding.activity.window.decorView) + if (insets != null) { + // Note: I don't ever expect insets to be null here, but technically it's possible. + imeHeightInDpi = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom / dpi + bottomPaddingInDpi = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()).bottom / dpi + } + + keyboardState = KeyboardState.Open + sendMessageKeyboardOpened() + } + } else if (keyboardState == KeyboardState.Closing) { + SuperKeyboardLog.i("super_keyboard", "Sending new keyboard state: closing") + keyboardState = KeyboardState.Closed + sendMessageKeyboardClosed() + } + } + } + ) + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + SuperKeyboardLog.d("super_keyboard", "onApplyWindowInsets() - current keyboard state: $keyboardState") + if (lifecycle!!.currentState == Lifecycle.State.CREATED) { + // For at least Android API 34, we receive conflicting reports about IME visibility + // when the app is being backgrounded. First we're told the IME isn't visible, then + // we're told that it is. In theory, the IME should never be visible when in the CREATED + // state, so we explicitly tell the app that the keyboard is closed here. + if (keyboardState != KeyboardState.Closed) { + SuperKeyboardLog.d("super_keyboard", "Activity is in CREATED state - telling app that keyboard is closed") + keyboardState = KeyboardState.Closed + sendMessageKeyboardClosed() + } + + return insets + } + + val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + SuperKeyboardLog.d("super_keyboard", "Is IME visible? $imeVisible") + SuperKeyboardLog.d("super_keyboard", "Lifecycle state: ${lifecycle!!.currentState}") + + SuperKeyboardLog.d("super_keyboard", "Insets: ${insets.getInsets(WindowInsetsCompat.Type.ime()).bottom}") + + // Note: We primarily only identify opening/closing here. The opened/closed completion + // is identified by the window insets animation callback. + // + // The exception is that when the Activity resumes, the keyboard might jump immediately + // to "closed". We catch that situation by looking for a `0` bottom inset. + if (imeVisible && keyboardState != KeyboardState.Opening && keyboardState != KeyboardState.Open) { + SuperKeyboardLog.d("super_keyboard", "Setting keyboard state to Opening") + sendMessageKeyboardOpening() + keyboardState = KeyboardState.Opening + } else if (!imeVisible && keyboardState != KeyboardState.Closing && keyboardState != KeyboardState.Closed) { + if (insets.getInsets(WindowInsetsCompat.Type.ime()).bottom == 0) { + SuperKeyboardLog.d("super_keyboard", "Setting keyboard state to Closed") + + // The keyboard height should be zero at this point. But just in case something got messed + // up with Android timing, we set the height to zero explicitly. + if (imeHeightInDpi.roundToInt() != 0) { + SuperKeyboardLog.w("super_keyboard", "Setting keyboard state to Closed, but our most recent measured keyboard height is: $imeHeightInDpi") + } + imeHeightInDpi = 0f; + + sendMessageKeyboardClosed() + keyboardState = KeyboardState.Closed + } else { + SuperKeyboardLog.d("super_keyboard", "Setting keyboard state to Closing") + sendMessageKeyboardClosing() + keyboardState = KeyboardState.Closing + } + } + + return insets + } + + private fun stopListeningForKeyboardChanges() { + SuperKeyboardLog.v("super_keyboard", "Stopping listening for keyboard changes") + if (mainView == null) { + SuperKeyboardLog.w("super_keyboard", "Our mainView is null in onPause. This isn't expected.") + return; + } + + ViewCompat.setOnApplyWindowInsetsListener(mainView!!, null) + ViewCompat.setWindowInsetsAnimationCallback(mainView!!, null) + + mainView = null + } + + // Queries the current IME and gesture insets and updates our local record of those + // values. + // + // This method can be used to synchronize our understanding of these insets at times + // when Android's lifecycle might screw up. However, it's recommended that we measure + // these values in response to Android hooks as much as possible, rather than query + // directly. For example, we should prefer to update these values within a + // WindowsInsetsAnimationCallback. + private fun measureInsets() { + val insets = ViewCompat.getRootWindowInsets(mainView!!) ?: return + imeHeightInDpi = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom / dpi + bottomPaddingInDpi = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()).bottom / dpi + } + + private fun sendMessageKeyboardOpened() { + channel.invokeMethod("keyboardOpened", createMetricsPayload()) + } + + private fun sendMessageKeyboardOpening() { + channel.invokeMethod("keyboardOpening", createMetricsPayload()) + } + + private fun sendMessageKeyboardProgress() { + channel.invokeMethod("onProgress", createMetricsPayload()) + } + + private fun sendMessageKeyboardClosed() { + channel.invokeMethod("keyboardClosed", createMetricsPayload()) + } + + private fun sendMessageKeyboardClosing() { + channel.invokeMethod("keyboardClosing", createMetricsPayload()) + } + + private fun sendMessageMetricsUpdate() { + channel.invokeMethod("metricsUpdate", createMetricsPayload()) + } + + private fun createMetricsPayload(): Map { + return mapOf( + "keyboardHeight" to imeHeightInDpi, + "bottomPadding" to bottomPaddingInDpi, + ) + } +} + +private enum class KeyboardState { + Closed, + Opening, + Open, + Closing; +} \ No newline at end of file diff --git a/super_keyboard/example/.gitignore b/super_keyboard/example/.gitignore new file mode 100644 index 0000000000..29a3a5017f --- /dev/null +++ b/super_keyboard/example/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# 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 +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/super_keyboard/example/.metadata b/super_keyboard/example/.metadata new file mode 100644 index 0000000000..8af9f156b4 --- /dev/null +++ b/super_keyboard/example/.metadata @@ -0,0 +1,30 @@ +# 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: "17025dd88227cd9532c33fa78f5250d548d87e9a" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + - platform: android + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/super_keyboard/example/README.md b/super_keyboard/example/README.md new file mode 100644 index 0000000000..30ebd3be05 --- /dev/null +++ b/super_keyboard/example/README.md @@ -0,0 +1,3 @@ +# Super Keyboard Example + +Demonstrates how to use the super_keyboard plugin. diff --git a/super_keyboard/example/analysis_options.yaml b/super_keyboard/example/analysis_options.yaml new file mode 100644 index 0000000000..0d2902135c --- /dev/null +++ b/super_keyboard/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# 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.dev/lints. + # + # 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 diff --git a/super_keyboard/example/android/.gitignore b/super_keyboard/example/android/.gitignore new file mode 100644 index 0000000000..d8248b6202 --- /dev/null +++ b/super_keyboard/example/android/.gitignore @@ -0,0 +1,15 @@ +app/.cxx/ + +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/super_keyboard/example/android/app/build.gradle b/super_keyboard/example/android/app/build.gradle new file mode 100644 index 0000000000..a12bd68c61 --- /dev/null +++ b/super_keyboard/example/android/app/build.gradle @@ -0,0 +1,44 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "com.flutterbountyhunters.superkeyboard.super_keyboard_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.flutterbountyhunters.superkeyboard.super_keyboard_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/super_keyboard/example/android/app/src/debug/AndroidManifest.xml b/super_keyboard/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_keyboard/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_keyboard/example/android/app/src/main/AndroidManifest.xml b/super_keyboard/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..9c0637d21e --- /dev/null +++ b/super_keyboard/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/super_keyboard/example/android/app/src/main/kotlin/com/flutterbountyhunters/superkeyboard/super_keyboard_example/MainActivity.kt b/super_keyboard/example/android/app/src/main/kotlin/com/flutterbountyhunters/superkeyboard/super_keyboard_example/MainActivity.kt new file mode 100644 index 0000000000..309b8269ff --- /dev/null +++ b/super_keyboard/example/android/app/src/main/kotlin/com/flutterbountyhunters/superkeyboard/super_keyboard_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.flutterbountyhunters.superkeyboard.super_keyboard_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/super_keyboard/example/android/app/src/main/res/drawable-v21/launch_background.xml b/super_keyboard/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000000..f74085f3f6 --- /dev/null +++ b/super_keyboard/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_keyboard/example/android/app/src/main/res/drawable/launch_background.xml b/super_keyboard/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..304732f884 --- /dev/null +++ b/super_keyboard/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/super_keyboard/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/super_keyboard/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..db77bb4b7b Binary files /dev/null and b/super_keyboard/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/super_keyboard/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/super_keyboard/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..17987b79bb Binary files /dev/null and b/super_keyboard/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/super_keyboard/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/super_keyboard/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..09d4391482 Binary files /dev/null and b/super_keyboard/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/super_keyboard/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/super_keyboard/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..d5f1c8d34e Binary files /dev/null and b/super_keyboard/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/super_keyboard/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/super_keyboard/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..4d6372eebd Binary files /dev/null and b/super_keyboard/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/super_keyboard/example/android/app/src/main/res/values-night/styles.xml b/super_keyboard/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..06952be745 --- /dev/null +++ b/super_keyboard/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_keyboard/example/android/app/src/main/res/values/styles.xml b/super_keyboard/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..cb1ef88056 --- /dev/null +++ b/super_keyboard/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/super_keyboard/example/android/app/src/profile/AndroidManifest.xml b/super_keyboard/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..399f6981d5 --- /dev/null +++ b/super_keyboard/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/super_keyboard/example/android/build.gradle b/super_keyboard/example/android/build.gradle new file mode 100644 index 0000000000..d2ffbffa4c --- /dev/null +++ b/super_keyboard/example/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/super_keyboard/example/android/gradle.properties b/super_keyboard/example/android/gradle.properties new file mode 100644 index 0000000000..2597170821 --- /dev/null +++ b/super_keyboard/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/super_keyboard/example/android/gradle/wrapper/gradle-wrapper.properties b/super_keyboard/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..09523c0e54 --- /dev/null +++ b/super_keyboard/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/super_keyboard/example/android/settings.gradle b/super_keyboard/example/android/settings.gradle new file mode 100644 index 0000000000..cd5e766360 --- /dev/null +++ b/super_keyboard/example/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version '8.6.0' apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false +} + +include ":app" diff --git a/super_keyboard/example/ios/.gitignore b/super_keyboard/example/ios/.gitignore new file mode 100644 index 0000000000..7a7f9873ad --- /dev/null +++ b/super_keyboard/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/super_keyboard/example/ios/Flutter/AppFrameworkInfo.plist b/super_keyboard/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..7c56964006 --- /dev/null +++ b/super_keyboard/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/super_keyboard/example/ios/Flutter/Debug.xcconfig b/super_keyboard/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..ec97fc6f30 --- /dev/null +++ b/super_keyboard/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/super_keyboard/example/ios/Flutter/Release.xcconfig b/super_keyboard/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..c4855bfe20 --- /dev/null +++ b/super_keyboard/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/super_keyboard/example/ios/Podfile b/super_keyboard/example/ios/Podfile new file mode 100644 index 0000000000..d97f17e223 --- /dev/null +++ b/super_keyboard/example/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# 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', '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 Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/super_keyboard/example/ios/Podfile.lock b/super_keyboard/example/ios/Podfile.lock new file mode 100644 index 0000000000..a160d6c5be --- /dev/null +++ b/super_keyboard/example/ios/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - Flutter (1.0.0) + - integration_test (0.0.1): + - Flutter + - super_keyboard (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + - super_keyboard (from `.symlinks/plugins/super_keyboard/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + super_keyboard: + :path: ".symlinks/plugins/super_keyboard/ios" + +SPEC CHECKSUMS: + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + super_keyboard: 016de6ce9ab826f9a0b185608209d6a3b556d577 + +PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 + +COCOAPODS: 1.16.2 diff --git a/super_keyboard/example/ios/Runner.xcodeproj/project.pbxproj b/super_keyboard/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..a1c85c8eac --- /dev/null +++ b/super_keyboard/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,728 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 85B39FB580D675688835B438 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9C1E1F2F8D24A1D5DD1C5264 /* Pods_Runner.framework */; }; + 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 */; }; + A12D3A9BB66DF164974571B4 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4EFDA78E32E712C5D1BE2F0A /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 10B9FDA75586173963B8EB8F /* 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 = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 44FFA1350DD6D19D2ECB809C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 4EFDA78E32E712C5D1BE2F0A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 72A070FD9738D482E839119E /* 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78AA13D825ACBF093F6C31E1 /* 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 = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9C1E1F2F8D24A1D5DD1C5264 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BC74956F75DE51149BFF0386 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + BC9B897EB3C25830A18E35C1 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 07CE555E0179B6199470FE20 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A12D3A9BB66DF164974571B4 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 85B39FB580D675688835B438 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 30E634ACC6EAFA4531FC9419 /* Pods */ = { + isa = PBXGroup; + children = ( + 72A070FD9738D482E839119E /* Pods-Runner.debug.xcconfig */, + 78AA13D825ACBF093F6C31E1 /* Pods-Runner.release.xcconfig */, + 10B9FDA75586173963B8EB8F /* Pods-Runner.profile.xcconfig */, + BC9B897EB3C25830A18E35C1 /* Pods-RunnerTests.debug.xcconfig */, + 44FFA1350DD6D19D2ECB809C /* Pods-RunnerTests.release.xcconfig */, + BC74956F75DE51149BFF0386 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 30E634ACC6EAFA4531FC9419 /* Pods */, + CF5F937805FA43FD2F5BFF1C /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + CF5F937805FA43FD2F5BFF1C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9C1E1F2F8D24A1D5DD1C5264 /* Pods_Runner.framework */, + 4EFDA78E32E712C5D1BE2F0A /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + A9F352D0FA2204A94AC87F0A /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 07CE555E0179B6199470FE20 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 7781B65B4C30BAD292E9A9CF /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + EE3FF589CBC6C9962CFF0BD1 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 7781B65B4C30BAD292E9A9CF /* [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; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + A9F352D0FA2204A94AC87F0A /* [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-RunnerTests-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; + }; + EE3FF589CBC6C9962CFF0BD1 /* [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; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.superkeyboard.superKeyboardExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BC9B897EB3C25830A18E35C1 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.superkeyboard.superKeyboardExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 44FFA1350DD6D19D2ECB809C /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.superkeyboard.superKeyboardExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BC74956F75DE51149BFF0386 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.superkeyboard.superKeyboardExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.superkeyboard.superKeyboardExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.flutterbountyhunters.superkeyboard.superKeyboardExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/super_keyboard/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/super_keyboard/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/super_keyboard/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/super_keyboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_keyboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_keyboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_keyboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_keyboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_keyboard/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_keyboard/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/super_keyboard/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..15cada4838 --- /dev/null +++ b/super_keyboard/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_keyboard/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/super_keyboard/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/super_keyboard/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/super_keyboard/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/super_keyboard/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/super_keyboard/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/super_keyboard/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/super_keyboard/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..f9b0d7c5ea --- /dev/null +++ b/super_keyboard/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/super_keyboard/example/ios/Runner/AppDelegate.swift b/super_keyboard/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..626664468b --- /dev/null +++ b/super_keyboard/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d36b1fab2d --- /dev/null +++ b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000..dc9ada4725 Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000000..7353c41ecf Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000..6ed2d933e1 Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000..4cd7b0099c Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000..fe730945a0 Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000000..321773cd85 Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000000..797d452e45 Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000..502f463a9b Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000..0ec3034392 Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000..e9f5fea27c Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000..84ac32ae7d Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000000..8953cba090 Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000..0467bf12aa Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000000..0bedcf2fd4 --- /dev/null +++ b/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000..9da19eacad Binary files /dev/null and b/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000000..89c2725b70 --- /dev/null +++ b/super_keyboard/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/super_keyboard/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/super_keyboard/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..f2e259c7c9 --- /dev/null +++ b/super_keyboard/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_keyboard/example/ios/Runner/Base.lproj/Main.storyboard b/super_keyboard/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..f3c28516fb --- /dev/null +++ b/super_keyboard/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/super_keyboard/example/ios/Runner/Info.plist b/super_keyboard/example/ios/Runner/Info.plist new file mode 100644 index 0000000000..366c03aa3f --- /dev/null +++ b/super_keyboard/example/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Super Keyboard + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + super_keyboard_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/super_keyboard/example/ios/Runner/Runner-Bridging-Header.h b/super_keyboard/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..308a2a560b --- /dev/null +++ b/super_keyboard/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/super_keyboard/example/ios/RunnerTests/RunnerTests.swift b/super_keyboard/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000000..5cc9c0e27f --- /dev/null +++ b/super_keyboard/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,27 @@ +import Flutter +import UIKit +import XCTest + + +@testable import super_keyboard + +// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. +// +// See https://developer.apple.com/documentation/xctest for more information about using XCTest. + +class RunnerTests: XCTestCase { + + func testGetPlatformVersion() { + let plugin = SuperKeyboardPlugin() + + let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + +} diff --git a/super_keyboard/example/lib/main.dart b/super_keyboard/example/lib/main.dart new file mode 100644 index 0000000000..9d652df506 --- /dev/null +++ b/super_keyboard/example/lib/main.dart @@ -0,0 +1,232 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:super_keyboard/super_keyboard.dart'; + +void main() { + runApp(const SuperKeyboardDemoApp()); +} + +class SuperKeyboardDemoApp extends StatefulWidget { + const SuperKeyboardDemoApp({super.key}); + + @override + State createState() => _SuperKeyboardDemoAppState(); +} + +class _SuperKeyboardDemoAppState extends State { + final _textFieldFocusNode = FocusNode(debugLabel: "demo-textfield"); + + bool _closeOnOutsideTap = true; + bool _isFlutterLoggingEnabled = false; + bool _isPlatformLoggingEnabled = false; + + @override + void initState() { + super.initState(); + + initSuperKeyboard(); + } + + Future initSuperKeyboard() async { + if (_isFlutterLoggingEnabled) { + SKLog.startLogging(); + } + } + + @override + void dispose() { + _textFieldFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: defaultTargetPlatform != TargetPlatform.android, + body: Stack( + children: [ + // Placeholder "X" behind content to show what we think is above the keyboard. + SuperKeyboardBuilder(builder: (context, keyboardState) { + final keyboardHeight = keyboardState.keyboardHeight ?? 0; + + return Positioned( + left: 0, + right: 0, + top: 0, + bottom: keyboardHeight > 0 ? keyboardHeight - MediaQuery.paddingOf(context).bottom : 0, + child: const Opacity( + opacity: 0.1, + child: Placeholder(), + ), + ); + }), + Positioned.fill( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildKeyboardStateIcon(), + const SizedBox(height: 12), + SuperKeyboardBuilder( + builder: (context, keyboardState) { + return Text("Keyboard state: $_keyboardState"); + }, + ), + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: SuperKeyboard.instance.mobileGeometry, + builder: (context, value, child) { + return Text( + "Keyboard height: ${value.keyboardHeight != null ? "${value.keyboardHeight!.toInt()}" : "???"}"); + }, + ), + const SizedBox(height: 48), + TextField( + focusNode: _textFieldFocusNode, + decoration: const InputDecoration( + hintText: "Type some text", + ), + onTapOutside: (_) { + if (_closeOnOutsideTap) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + // ignore: avoid_print + print("Requesting text field focus"); + _textFieldFocusNode.requestFocus(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + // ignore: avoid_print + print("Unfocusing text field"); + _textFieldFocusNode.unfocus(); + }); + }, + child: const Text("Open/Close Rapidly"), + ), + _buildCloseOnFocusOption(), + _buildFlutterLoggingOption(), + _buildPlatformLoggingOption(), + ValueListenableBuilder( + valueListenable: SuperKeyboard.instance.mobileGeometry, + builder: (context, value, child) { + if (value.keyboardHeight == null) { + return const SizedBox(); + } + + return SizedBox(height: value.keyboardHeight! / MediaQuery.of(context).devicePixelRatio); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildKeyboardStateIcon() { + return ValueListenableBuilder( + valueListenable: SuperKeyboard.instance.mobileGeometry, + builder: (context, value, child) { + final icon = switch (value.keyboardState) { + KeyboardState.closed => Icons.border_bottom, + KeyboardState.opening => Icons.upload_sharp, + KeyboardState.open => Icons.border_top, + KeyboardState.closing => Icons.download_sharp, + null => Icons.question_mark, + }; + + return Icon( + icon, + size: 24, + ); + }, + ); + } + + String? get _keyboardState { + return switch (SuperKeyboard.instance.mobileGeometry.value.keyboardState) { + KeyboardState.closed => "Closed", + KeyboardState.opening => "Opening", + KeyboardState.open => "Open", + KeyboardState.closing => "Closing", + _ => null, + }; + } + + Widget _buildCloseOnFocusOption() { + return Row( + spacing: 8, + children: [ + const Expanded( + child: Text('Close keyboard on outside tap'), + ), + Switch( + value: _closeOnOutsideTap, + onChanged: (newValue) { + setState(() { + _closeOnOutsideTap = newValue; + }); + }, + ), + ], + ); + } + + Widget _buildFlutterLoggingOption() { + return Row( + spacing: 8, + children: [ + const Expanded( + child: Text('Enable flutter logs'), + ), + Switch( + value: _isFlutterLoggingEnabled, + onChanged: (newValue) { + setState(() { + _isFlutterLoggingEnabled = newValue; + + if (_isFlutterLoggingEnabled) { + SKLog.startLogging(); + } else { + SKLog.stopLogging(); + } + }); + }, + ), + ], + ); + } + + Widget _buildPlatformLoggingOption() { + return Row( + spacing: 8, + children: [ + const Expanded( + child: Text('Enable platform logs'), + ), + Switch( + value: _isPlatformLoggingEnabled, + onChanged: (newValue) { + setState(() { + _isPlatformLoggingEnabled = newValue; + SuperKeyboard.instance.enablePlatformLogging(_isPlatformLoggingEnabled); + }); + }, + ), + ], + ); + } +} diff --git a/super_keyboard/example/pubspec.lock b/super_keyboard/example/pubspec.lock new file mode 100644 index 0000000000..b6b738508b --- /dev/null +++ b/super_keyboard/example/pubspec.lock @@ -0,0 +1,307 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" + url: "https://pub.dev" + source: hosted + version: "2.0.27" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_test_runners: + dependency: transitive + description: + name: flutter_test_runners + sha256: cc575117ed66a79185a26995399d7048341517a1bd21188cb43753739627832d + url: "https://pub.dev" + source: hosted + version: "0.0.4" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + super_keyboard: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.3.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.dev" + source: hosted + version: "14.3.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + url: "https://pub.dev" + source: hosted + version: "3.0.3" +sdks: + dart: ">=3.10.0-0 <4.0.0" + flutter: ">=3.27.0" diff --git a/super_keyboard/example/pubspec.yaml b/super_keyboard/example/pubspec.yaml new file mode 100644 index 0000000000..01df7f78db --- /dev/null +++ b/super_keyboard/example/pubspec.yaml @@ -0,0 +1,85 @@ +name: super_keyboard_example +description: "Demonstrates how to use the super_keyboard 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: ^3.5.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 + + super_keyboard: + # When depending on this package from a real application you should use: + # super_keyboard: ^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.8 + +dev_dependencies: + integration_test: + sdk: flutter + 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: ^4.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 packages. +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/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # 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/to/font-from-package diff --git a/super_keyboard/golden_tester.Dockerfile b/super_keyboard/golden_tester.Dockerfile new file mode 100644 index 0000000000..b61f350cbd --- /dev/null +++ b/super_keyboard/golden_tester.Dockerfile @@ -0,0 +1,24 @@ +FROM ubuntu:latest + +ENV FLUTTER_HOME=${HOME}/sdks/flutter +ENV PATH ${PATH}:${FLUTTER_HOME}/bin:${FLUTTER_HOME}/bin/cache/dart-sdk/bin + +USER root + +RUN apt update + +RUN apt install -y git curl unzip + +# Print the Ubuntu version. Useful when there are failing tests. +RUN cat /etc/lsb-release + +# Invalidate the cache when flutter pushes a new commit. +ADD https://api.github.com/repos/flutter/flutter/git/refs/heads/master ./flutter-latest-master + +RUN git clone https://github.com/flutter/flutter.git ${FLUTTER_HOME} + +RUN flutter doctor + +# Copy the whole repo. +# We need this because we use local dependencies. +COPY ./ /golden_tester diff --git a/super_keyboard/ios/.gitignore b/super_keyboard/ios/.gitignore new file mode 100644 index 0000000000..034771fc9c --- /dev/null +++ b/super_keyboard/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/super_keyboard/ios/Assets/.gitkeep b/super_keyboard/ios/Assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/super_keyboard/ios/Classes/SuperKeyboardPlugin.swift b/super_keyboard/ios/Classes/SuperKeyboardPlugin.swift new file mode 100644 index 0000000000..bdee7fbce1 --- /dev/null +++ b/super_keyboard/ios/Classes/SuperKeyboardPlugin.swift @@ -0,0 +1,238 @@ +import Flutter +import UIKit + +public class SuperKeyboardPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let plugin = SuperKeyboardPlugin(binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(plugin, channel: plugin.channel!) + } + + private var channel: FlutterMethodChannel? + +// private var displayLink: CADisplayLink? + private weak var window: UIWindow? + private var keyboardType: KeyboardType = .unknown + private var keyboardFrame: CGRect = .zero +// private var keyboardTimer: Timer? +// private var isAnimating = false + + init(binaryMessenger: FlutterBinaryMessenger) { + super.init() + + channel = FlutterMethodChannel(name: "super_keyboard_ios", binaryMessenger: binaryMessenger) + + // Register for keyboard notifications + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow(_:)), name: UIResponder.keyboardDidShowNotification, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "startLogging": + let sendPlatformLogsToDart = (call.arguments as? [String: Any])?["sendPlatformLogsToDart"] as? Bool ?? false + + SuperKeyboardLog.enable(reportTo: sendPlatformLogsToDart ? channel : nil) + + result(nil) + case "stopLogging": + SuperKeyboardLog.disable() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + @objc private func keyboardWillShow(_ notification: Notification) { + SuperKeyboardLog.log(message: "Keyboard will show") + channel!.invokeMethod("keyboardWillShow", arguments: nil) + } + + @objc private func keyboardDidShow(_ notification: Notification) { + guard let window = window else { +// stopTrackingKeyboard() + return + } + + // Calculate the current keyboard height + let screenHeight = window.bounds.height + let keyboardHeight = max(0, screenHeight - keyboardFrame.origin.y) + + SuperKeyboardLog.log(message: "Keyboard Did Show") + SuperKeyboardLog.log(message: "Keyboard height: \(keyboardFrame.height)") + + channel!.invokeMethod("keyboardDidShow", arguments: [ + "keyboardHeight": keyboardHeight + ]) + } + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { + return + } + SuperKeyboardLog.log(message: "Keyboard will change frame") + + // Set the final keyboard frame and track its position during animation + keyboardFrame = endFrame + window = UIApplication.shared.windows.first + + switch keyboardFrame { + case let r where r.height <= 0: + keyboardType = .unknown + case let r where r.height < 100: + keyboardType = .minimized + default: + keyboardType = .full + } + + SuperKeyboardLog.log(message: "New target keyboard height: \(keyboardFrame.height)") + channel!.invokeMethod("keyboardWillChangeFrame", arguments: [ + "keyboardType": keyboardType.description, + "targetKeyboardHeight": keyboardFrame.height + ]) + +// if (!isAnimating) { +// startTrackingKeyboard(userInfo: userInfo) +// } + } + +// private func startTrackingKeyboard(userInfo: [AnyHashable: Any]) { +// print("startTrackingKeyboard()") +// guard let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double, +// let curveRawValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int else { +// return +// } +// +// let curve = UIView.AnimationCurve(rawValue: curveRawValue) ?? .easeInOut +// +// // Start a timer to poll the keyboard height at intervals +// isAnimating = true +// keyboardTimer = Timer.scheduledTimer( +// timeInterval: 0.016, // Approximately 60 FPS +// target: self, +// selector: #selector(pollKeyboardHeight), +// userInfo: nil, +// repeats: true +// ) +// } +// +// @objc private func pollKeyboardHeight() { +// print("pollKeyboardHeight") +// guard let window = window else { +// stopTrackingKeyboard() +// return +// } +// +// // Calculate the current keyboard height +// let screenHeight = window.bounds.height +// let keyboardHeight = max(0, screenHeight - keyboardFrame.origin.y) +// print("keyboardHeight: ", keyboardHeight, ", frame height: ", keyboardFrame.height) +// +// channel!.invokeMethod("keyboardGeometry", arguments: [ +// "y": keyboardFrame.origin.y, +// "height": keyboardHeight +// ]) +// +// // Stop polling if the keyboard is fully open or hidden +// print("Is height = 0? ", (keyboardHeight == 0)) +// if !isAnimating || keyboardHeight == 0 || keyboardHeight == keyboardFrame.height { +// stopTrackingKeyboard() +// } +// } +// +// @objc private func updateKeyboardFrame() { +// print("updateKeyboardFrame") +// channel!.invokeMethod("updatKeyboardFrame", arguments: nil) +// guard let window = window else { return } +// +// let screenHeight = window.bounds.height +// let keyboardHeight = max(0, screenHeight - keyboardFrame.origin.y) +// +// channel!.invokeMethod("keyboardGeometry", arguments: [ +// "y": keyboardFrame.origin.y, +// "height": keyboardHeight +// ]) +// +// // Stop tracking when the animation completes +// if keyboardHeight == 0 || keyboardHeight == keyboardFrame.height { +// stopTrackingKeyboard() +// } +// } +// +// private func stopTrackingKeyboard() { +// print("stopTrackingKeyboard(), timer: ", keyboardTimer) +// isAnimating = false +// keyboardTimer?.invalidate() +// keyboardTimer = nil +// } + + @objc private func keyboardWillHide(_ notification: Notification) { + SuperKeyboardLog.log(message: "Keyboard will hide") + channel!.invokeMethod("keyboardWillHide", arguments: nil) + } + + @objc private func keyboardDidHide(_ notification: Notification) { + SuperKeyboardLog.log(message: "Keyboard did hide - reporting height: 0") + channel!.invokeMethod("keyboardDidHide", arguments: [ + "keyboardHeight": 0 + ]) +// stopTrackingKeyboard() + } +} + +enum KeyboardType { + case unknown + case full + case minimized + + var description: String { + switch self { + case .unknown: + "unknown" + case .full: + "full" + case .minimized: + "minimized" + } + } +} + +public class SuperKeyboardLog { + static private var isLoggingEnabled: Bool = false + static private var reportTo: FlutterMethodChannel? = nil + + static func enable(reportTo: FlutterMethodChannel?) { + isLoggingEnabled = true + self.reportTo = reportTo + } + + static func disable() { + isLoggingEnabled = false + reportTo = nil + } + + // TODO: Add log levels - not sure what's typical for iOS. Android is V, D, I, W, E. + static func log(message: String) { + if (isLoggingEnabled) { + if (reportTo != nil) { + reportToDart(message) + } else { + print(message) + } + } + } + + static private func reportToDart(_ message: String) { + reportTo!.invokeMethod( + "log", + arguments: [ + "message": message, + ] + ) + } +} diff --git a/super_keyboard/ios/Resources/PrivacyInfo.xcprivacy b/super_keyboard/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..a34b7e2e60 --- /dev/null +++ b/super_keyboard/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/super_keyboard/ios/super_keyboard.podspec b/super_keyboard/ios/super_keyboard.podspec new file mode 100644 index 0000000000..aad3acf70e --- /dev/null +++ b/super_keyboard/ios/super_keyboard.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint super_keyboard.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'super_keyboard' + s.version = '0.0.1' + s.summary = 'A plugin that reports keyboard visibility and size.' + s.description = <<-DESC +A plugin that reports keyboard visibility and size. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'super_keyboard_privacy' => ['Resources/PrivacyInfo.xcprivacy']} +end diff --git a/super_keyboard/lib/src/keyboard.dart b/super_keyboard/lib/src/keyboard.dart new file mode 100644 index 0000000000..caddf4d0ed --- /dev/null +++ b/super_keyboard/lib/src/keyboard.dart @@ -0,0 +1,75 @@ +/// Mobile application window geometry as reported by the `super_keyboard` plugin. +/// +/// This geometry includes values that are deemed relevant to keyboard behavior, but excludes +/// geometry that's unrelated to keyboards, such as gesture areas on the left/right/top of the +/// screen, and the space taken up by the camera at the top of the screen. +class MobileWindowGeometry { + const MobileWindowGeometry({ + this.keyboardState, + this.keyboardHeight, + this.bottomPadding, + }); + + /// The current state of the software keyboard, e.g., open, opening, closed, closing. + final KeyboardState? keyboardState; + + /// The current height of the software keyboard. + /// + /// This height might reflect a keyboard that's completely open, completely closed, + /// in the process of opening, or in the process of closing. + final double? keyboardHeight; + + /// Bottom padding that the OS expects apps to respect when the keyboard is closed. + /// + /// This gap might represent, for example, an OS draggable area at the bottom of the screen, + /// which is used to open the app switcher. + final double? bottomPadding; + + /// Returns a copy of this [MobileWindowGeometry] with the [newValues] applied + /// on top, i.e., replaces values in this [MobileWindowGeometry] with values from + /// the given [newValues]. + MobileWindowGeometry updateWith(MobileWindowGeometry newValues) { + return copyWith( + keyboardState: newValues.keyboardState, + keyboardHeight: newValues.keyboardHeight, + bottomPadding: newValues.bottomPadding, + ); + } + + /// Returns a copy of [baseValues] with values from this [MobileWindowGeometry] applied on top, i.e., + /// the values in this [MobileWindowGeometry] replace values in [baseValues]. + MobileWindowGeometry applyTo(MobileWindowGeometry baseValues) { + return baseValues.updateWith(this); + } + + MobileWindowGeometry copyWith({ + KeyboardState? keyboardState, + double? keyboardHeight, + double? bottomPadding, + }) { + return MobileWindowGeometry( + keyboardState: keyboardState ?? this.keyboardState, + keyboardHeight: keyboardHeight ?? this.keyboardHeight, + bottomPadding: bottomPadding ?? this.bottomPadding, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MobileWindowGeometry && + runtimeType == other.runtimeType && + keyboardState == other.keyboardState && + keyboardHeight == other.keyboardHeight && + bottomPadding == other.bottomPadding; + + @override + int get hashCode => keyboardState.hashCode ^ keyboardHeight.hashCode ^ bottomPadding.hashCode; +} + +enum KeyboardState { + closed, + opening, + open, + closing; +} diff --git a/super_keyboard/lib/src/logging.dart b/super_keyboard/lib/src/logging.dart new file mode 100644 index 0000000000..b83f746ca0 --- /dev/null +++ b/super_keyboard/lib/src/logging.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; + +/// Loggers for Super Keyboard, which can be activated by log level and by focal +/// area, and can also print to a given [LogPrinter]. +abstract class SKLog { + static final superKeyboard = Logger("super_keyboard"); + static final unified = Logger("super_keyboard.unified"); + static final ios = Logger("super_keyboard.ios"); + static final android = Logger("super_keyboard.android"); + + static StreamSubscription? _logRecordSubscription; + + static void startLogging([Level level = Level.ALL, LogPrinter? printer]) { + if (_logRecordSubscription != null) { + _logRecordSubscription!.cancel(); + _logRecordSubscription = null; + } + + hierarchicalLoggingEnabled = true; + superKeyboard.level = level; + _logRecordSubscription = superKeyboard.onRecord.listen(printer ?? defaultLogPrinter); + } + + static void stopLogging() { + superKeyboard.level = Level.OFF; + + if (_logRecordSubscription != null) { + _logRecordSubscription!.cancel(); + _logRecordSubscription = null; + } + } +} + +void defaultLogPrinter(LogRecord record) { + // ignore: avoid_print + print('${record.level.name}: ${record.time.toLogTime()}: ${record.message}'); +} + +typedef LogPrinter = void Function(LogRecord); + +extension on DateTime { + String toLogTime() { + String h = _twoDigits(hour); + String min = _twoDigits(minute); + String sec = _twoDigits(second); + String ms = _threeDigits(millisecond); + if (isUtc) { + return "$h:$min:$sec.$ms"; + } else { + return "$h:$min:$sec.$ms"; + } + } + + String _threeDigits(int n) { + if (n >= 100) return "$n"; + if (n >= 10) return "0$n"; + return "00$n"; + } + + String _twoDigits(int n) { + if (n >= 10) return "$n"; + return "0$n"; + } +} diff --git a/super_keyboard/lib/src/super_keyboard_android.dart b/super_keyboard/lib/src/super_keyboard_android.dart new file mode 100644 index 0000000000..06b3f9ee2f --- /dev/null +++ b/super_keyboard/lib/src/super_keyboard_android.dart @@ -0,0 +1,202 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_keyboard/super_keyboard.dart'; + +class SuperKeyboardAndroidBuilder extends StatefulWidget { + const SuperKeyboardAndroidBuilder({ + super.key, + required this.builder, + }); + + final Widget Function(BuildContext, MobileWindowGeometry) builder; + + @override + State createState() => _SuperKeyboardAndroidBuilderState(); +} + +class _SuperKeyboardAndroidBuilderState extends State + implements SuperKeyboardAndroidListener { + @override + void initState() { + super.initState(); + SuperKeyboardAndroid.instance.addListener(this); + } + + @override + void dispose() { + SuperKeyboardAndroid.instance.removeListener(this); + super.dispose(); + } + + @override + void onKeyboardOpen() { + setState(() {}); + } + + @override + void onKeyboardOpening() { + setState(() {}); + } + + @override + void onKeyboardClosing() { + setState(() {}); + } + + @override + void onKeyboardClosed() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return widget.builder( + context, + SuperKeyboardAndroid.instance.geometry.value, + ); + } +} + +class SuperKeyboardAndroid { + static SuperKeyboardAndroid? _instance; + static SuperKeyboardAndroid get instance { + _instance ??= SuperKeyboardAndroid._(); + return _instance!; + } + + SuperKeyboardAndroid._() { + assert( + defaultTargetPlatform == TargetPlatform.android, + "You shouldn't initialize SuperKeyboardAndroid when not on an Android platform. Current: $defaultTargetPlatform", + ); + _methodChannel.setMethodCallHandler(_onPlatformMessage); + } + + final _methodChannel = const MethodChannel('super_keyboard_android'); + + /// Enable platform-side logging, e.g., Android logs. + /// + /// Optionally, log messages on the platform side can be forwarded to Dart + /// so that they can be printed by the current [SKLog]. To do this, pass + /// `true` for [sendPlatformLogsToDart]. Defaults to `false`. + Future enablePlatformLogging({bool sendPlatformLogsToDart = false}) async { + await _methodChannel.invokeMethod("startLogging", {"sendPlatformLogsToDart": sendPlatformLogsToDart}); + } + + /// Disable platform-side logging, e.g., Android logs. + Future disablePlatformLogging() async { + await _methodChannel.invokeMethod("stopLogging"); + } + + ValueListenable get geometry => _geometry; + final _geometry = ValueNotifier(const MobileWindowGeometry()); + + final _listeners = {}; + void addListener(SuperKeyboardAndroidListener listener) => _listeners.add(listener); + void removeListener(SuperKeyboardAndroidListener listener) => _listeners.remove(listener); + + Future _onPlatformMessage(MethodCall message) async { + switch (message.method) { + case "keyboardOpening": + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardState: KeyboardState.opening, + keyboardHeight: (message.arguments["keyboardHeight"] as num?)?.toDouble(), + bottomPadding: (message.arguments["bottomPadding"] as num?)?.toDouble(), + ), + ); + + for (final listener in _listeners) { + listener.onKeyboardOpening(); + } + break; + case "keyboardOpened": + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardState: KeyboardState.open, + keyboardHeight: (message.arguments["keyboardHeight"] as num?)?.toDouble(), + bottomPadding: (message.arguments["bottomPadding"] as num?)?.toDouble(), + ), + ); + + for (final listener in _listeners) { + listener.onKeyboardOpen(); + } + break; + case "keyboardClosing": + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardState: KeyboardState.closing, + keyboardHeight: (message.arguments["keyboardHeight"] as num?)?.toDouble(), + bottomPadding: (message.arguments["bottomPadding"] as num?)?.toDouble(), + ), + ); + + for (final listener in _listeners) { + listener.onKeyboardClosing(); + } + break; + case "keyboardClosed": + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardState: KeyboardState.closed, + // Just in case the height got out of sync, perhaps due to Activity + // lifecycle changes, explicitly set the keyboard height to zero. + keyboardHeight: 0, + bottomPadding: (message.arguments["bottomPadding"] as num?)?.toDouble(), + ), + ); + + for (final listener in _listeners) { + listener.onKeyboardClosed(); + } + break; + case "onProgress": + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardHeight: (message.arguments["keyboardHeight"] as num?)?.toDouble(), + bottomPadding: (message.arguments["bottomPadding"] as num?)?.toDouble(), + ), + ); + break; + case "metricsUpdate": + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardHeight: (message.arguments["keyboardHeight"] as num?)?.toDouble(), + bottomPadding: (message.arguments["bottomPadding"] as num?)?.toDouble(), + ), + ); + break; + case "log": + _printAndroidLog(message); + break; + default: + SKLog.android.warning("Unknown Android plugin platform message: $message"); + } + } + + void _printAndroidLog(MethodCall channelMessage) { + final level = channelMessage.arguments["level"] as String?; + final logMessage = "SK Android: ${channelMessage.arguments["message"] ?? "EMPTY MESSAGE"}"; + switch (level) { + case "v": + SKLog.android.finer(logMessage); + case "d": + SKLog.android.fine(logMessage); + case "i": + SKLog.android.info(logMessage); + case "w": + SKLog.android.warning(logMessage); + case "e": + SKLog.android.shout(logMessage); + } + } +} + +abstract interface class SuperKeyboardAndroidListener { + void onKeyboardOpening(); + void onKeyboardOpen(); + void onKeyboardClosing(); + void onKeyboardClosed(); +} diff --git a/super_keyboard/lib/src/super_keyboard_ios.dart b/super_keyboard/lib/src/super_keyboard_ios.dart new file mode 100644 index 0000000000..377d9a7b7e --- /dev/null +++ b/super_keyboard/lib/src/super_keyboard_ios.dart @@ -0,0 +1,180 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_keyboard/src/keyboard.dart'; +import 'package:super_keyboard/src/logging.dart'; + +class SuperKeyboardIOSBuilder extends StatefulWidget { + const SuperKeyboardIOSBuilder({ + super.key, + required this.builder, + }); + + final Widget Function(BuildContext, MobileWindowGeometry) builder; + + @override + State createState() => _SuperKeyboardIOSBuilderState(); +} + +class _SuperKeyboardIOSBuilderState extends State implements SuperKeyboardIOSListener { + @override + void initState() { + super.initState(); + SuperKeyboardIOS.instance.addListener(this); + } + + @override + void dispose() { + SuperKeyboardIOS.instance.removeListener(this); + super.dispose(); + } + + @override + void onKeyboardWillShow() { + setState(() {}); + } + + @override + void onKeyboardDidShow() { + setState(() {}); + } + + @override + void onKeyboardWillHide() { + setState(() {}); + } + + @override + void onKeyboardDidHide() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return widget.builder( + context, + SuperKeyboardIOS.instance.geometry.value, + ); + } +} + +class SuperKeyboardIOS { + static SuperKeyboardIOS? _instance; + static SuperKeyboardIOS get instance { + _instance ??= SuperKeyboardIOS._(); + return _instance!; + } + + SuperKeyboardIOS._() { + SKLog.ios.info("Initializing iOS plugin for super_keyboard"); + assert( + defaultTargetPlatform == TargetPlatform.iOS, + "You shouldn't initialize SuperKeyboardIOS when not on an iOS platform. Current: $defaultTargetPlatform", + ); + _methodChannel.setMethodCallHandler(_onPlatformMessage); + } + + final _methodChannel = const MethodChannel('super_keyboard_ios'); + + /// Enable platform-side logging, e.g., iOS logs. + /// + /// Optionally, log messages on the platform side can be forwarded to Dart + /// so that they can be printed by the current [SKLog]. To do this, pass + /// `true` for [sendPlatformLogsToDart]. When `false`, platform logs are + /// printed on the platform side using whatever the standard logger is, e.g., + /// `Log` on Android and `print` on iOS. Defaults to `false`. + Future enablePlatformLogging({bool sendPlatformLogsToDart = false}) async { + await _methodChannel.invokeMethod("startLogging", {"sendPlatformLogsToDart": sendPlatformLogsToDart}); + } + + /// Disable platform-side logging, e.g., iOS logs. + Future disablePlatformLogging() async { + await _methodChannel.invokeMethod("stopLogging"); + } + + ValueListenable get geometry => _geometry; + final _geometry = ValueNotifier(const MobileWindowGeometry()); + + final _listeners = {}; + void addListener(SuperKeyboardIOSListener listener) => _listeners.add(listener); + void removeListener(SuperKeyboardIOSListener listener) => _listeners.remove(listener); + + Future _onPlatformMessage(MethodCall message) async { + // assert(() { + // SKLog.ios.fine("iOS platform message: '${message.method}', args: ${message.arguments}"); + // return true; + // }()); + + switch (message.method) { + case "keyboardWillShow": + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardState: KeyboardState.opening, + keyboardHeight: (message.arguments?["keyboardHeight"] as num?)?.toDouble(), + bottomPadding: (message.arguments?["bottomPadding"] as num?)?.toDouble(), + ), + ); + + for (final listener in _listeners) { + listener.onKeyboardWillShow(); + } + break; + case "keyboardDidShow": + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardState: KeyboardState.open, + keyboardHeight: (message.arguments?["keyboardHeight"] as num?)?.toDouble(), + bottomPadding: (message.arguments?["bottomPadding"] as num?)?.toDouble(), + ), + ); + + for (final listener in _listeners) { + listener.onKeyboardDidShow(); + } + break; + case "keyboardWillChangeFrame": + break; + case "keyboardWillHide": + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardState: KeyboardState.closing, + keyboardHeight: (message.arguments?["keyboardHeight"] as num?)?.toDouble(), + bottomPadding: (message.arguments?["bottomPadding"] as num?)?.toDouble(), + ), + ); + + for (final listener in _listeners) { + listener.onKeyboardWillHide(); + } + break; + case "keyboardDidHide": + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardState: KeyboardState.closed, + keyboardHeight: (message.arguments?["keyboardHeight"] as num?)?.toDouble(), + bottomPadding: (message.arguments?["bottomPadding"] as num?)?.toDouble(), + ), + ); + + for (final listener in _listeners) { + listener.onKeyboardDidHide(); + } + break; + case "log": + _printIOSLog(message); + break; + } + } + + void _printIOSLog(MethodCall channelMessage) { + final logMessage = "SK iOS: ${channelMessage.arguments["message"] ?? "EMPTY MESSAGE"}"; + SKLog.ios.info(logMessage); + } +} + +abstract interface class SuperKeyboardIOSListener { + void onKeyboardWillShow(); + void onKeyboardDidShow(); + void onKeyboardWillHide(); + void onKeyboardDidHide(); +} diff --git a/super_keyboard/lib/src/super_keyboard_unified.dart b/super_keyboard/lib/src/super_keyboard_unified.dart new file mode 100644 index 0000000000..27266dffe4 --- /dev/null +++ b/super_keyboard/lib/src/super_keyboard_unified.dart @@ -0,0 +1,108 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_keyboard/src/keyboard.dart'; +import 'package:super_keyboard/src/logging.dart'; +import 'package:super_keyboard/src/super_keyboard_android.dart'; +import 'package:super_keyboard/src/super_keyboard_ios.dart'; + +/// A widget that rebuilds whenever the window geometry changes in a way that's +/// relevant to the software keyboard. +class SuperKeyboardBuilder extends StatefulWidget { + const SuperKeyboardBuilder({ + super.key, + required this.builder, + }); + + final Widget Function(BuildContext, MobileWindowGeometry) builder; + + @override + State createState() => _SuperKeyboardBuilderState(); +} + +class _SuperKeyboardBuilderState extends State { + @override + void initState() { + super.initState(); + SuperKeyboard.instance.mobileGeometry.addListener(_onKeyboardStateChange); + } + + @override + void dispose() { + SuperKeyboard.instance.mobileGeometry.removeListener(_onKeyboardStateChange); + super.dispose(); + } + + void _onKeyboardStateChange() { + setState(() { + // Re-build. + }); + } + + @override + Widget build(BuildContext context) { + return widget.builder( + context, + SuperKeyboard.instance.mobileGeometry.value, + ); + } +} + +/// A unified API for tracking the software keyboard status, regardless of platform. +class SuperKeyboard { + static SuperKeyboard? _instance; + static SuperKeyboard get instance { + _instance ??= SuperKeyboard._(); + return _instance!; + } + + @visibleForTesting + static set testInstance(SuperKeyboard? testInstance) => _instance = testInstance; + + SuperKeyboard._() { + _init(); + } + + void _init() { + SKLog.unified.info("Initializing SuperKeyboard"); + if (defaultTargetPlatform == TargetPlatform.iOS) { + SKLog.unified.fine("SuperKeyboard - Initializing for iOS"); + SuperKeyboardIOS.instance.geometry.addListener(_onIOSWindowGeometryChange); + } else if (defaultTargetPlatform == TargetPlatform.android) { + SKLog.unified.fine("SuperKeyboard - Initializing for Android"); + SuperKeyboardAndroid.instance.geometry.addListener(_onAndroidWindowGeometryChange); + } + } + + /// Enable/disable platform-side logging, e.g., Android or iOS logs. + /// + /// These logs are distinct from Flutter-side logs, which are controlled + /// by [startLogging]. + Future enablePlatformLogging(bool isEnabled) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + SKLog.unified.fine("SuperKeyboard - ${isEnabled ? "Enabling" : "Disabling"} logs for iOS."); + if (isEnabled) { + await SuperKeyboardIOS.instance.enablePlatformLogging(sendPlatformLogsToDart: true); + } else { + await SuperKeyboardIOS.instance.disablePlatformLogging(); + } + } else if (defaultTargetPlatform == TargetPlatform.android) { + SKLog.unified.fine("SuperKeyboard - ${isEnabled ? "Enabling" : "Disabling"} logs for Android."); + if (isEnabled) { + await SuperKeyboardAndroid.instance.enablePlatformLogging(sendPlatformLogsToDart: true); + } else { + await SuperKeyboardAndroid.instance.disablePlatformLogging(); + } + } + } + + ValueListenable get mobileGeometry => _mobileGeometry; + final _mobileGeometry = ValueNotifier(const MobileWindowGeometry()); + + void _onIOSWindowGeometryChange() { + _mobileGeometry.value = SuperKeyboardIOS.instance.geometry.value; + } + + void _onAndroidWindowGeometryChange() { + _mobileGeometry.value = SuperKeyboardAndroid.instance.geometry.value; + } +} diff --git a/super_keyboard/lib/super_keyboard.dart b/super_keyboard/lib/super_keyboard.dart new file mode 100644 index 0000000000..13b29d42cd --- /dev/null +++ b/super_keyboard/lib/super_keyboard.dart @@ -0,0 +1,5 @@ +export 'src/keyboard.dart'; +export 'src/logging.dart'; +export 'src/super_keyboard_unified.dart'; +export 'src/super_keyboard_ios.dart'; +export 'src/super_keyboard_android.dart'; diff --git a/super_keyboard/lib/super_keyboard_test.dart b/super_keyboard/lib/super_keyboard_test.dart new file mode 100644 index 0000000000..a8ce576b45 --- /dev/null +++ b/super_keyboard/lib/super_keyboard_test.dart @@ -0,0 +1 @@ +export 'test/keyboard_simulator.dart'; diff --git a/super_keyboard/lib/test/keyboard_simulator.dart b/super_keyboard/lib/test/keyboard_simulator.dart new file mode 100644 index 0000000000..03f033f18e --- /dev/null +++ b/super_keyboard/lib/test/keyboard_simulator.dart @@ -0,0 +1,545 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show Icons, Colors; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_keyboard/src/keyboard.dart'; +import 'package:super_keyboard/src/super_keyboard_unified.dart'; + +/// A widget that simulates the software keyboard appearance and disappearance. +/// +/// This works by listening to messages sent from Flutter to the platform that show/hide +/// the software keyboard. In response to those messages, [SuperKeyboard] emits +/// notifications for the keyboard opening, opened, closing, closed. The timing of those +/// messages are based on an animation in this widget, simulating actual keyboard expansion +/// and collapse. Similarly, this widget installs a `MediaQuery`, which sets its bottom +/// offsets equal to the simulated keyboard height, which reflects how Flutter actually +/// reports keyboard height to Flutter apps. +/// +/// Place this widget above the `Scaffold` in the widget tree. +class SoftwareKeyboardHeightSimulator extends StatefulWidget { + const SoftwareKeyboardHeightSimulator({ + super.key, + this.isEnabled = true, + this.enableForAllPlatforms = false, + this.initialKeyboardState = KeyboardState.closed, + this.keyboardHeight = _defaultKeyboardHeight, + this.animateKeyboard = false, + this.renderSimulatedKeyboard = false, + required this.child, + }); + + /// Whether or not to enable the simulated software keyboard insets. + /// + /// This property is provided so that clients don't need to conditionally add/remove + /// this widget from the tree. Instead this flag can be flipped, as needed. + final bool isEnabled; + + /// Whether to simulate software keyboard insets for all platforms (`true`), or whether to + /// only simulate software keyboard insets for mobile platforms, e.g., Android, iOS (`false`). + /// + /// The value for this property should remain constant within a single test. Don't + /// attempt to enable and then disable keyboard simulation. That behavior is undefined. + final bool enableForAllPlatforms; + + /// The state of the keyboard, e.g., open, opening, closed, closing. + final KeyboardState initialKeyboardState; + + /// The vertical space, in logical pixels, to occupy at the bottom of the screen to simulate the appearance + /// of a keyboard. + final double keyboardHeight; + + /// Whether to simulate keyboard open/closing animations. + /// + /// These animations change the keyboard insets over time, similar to how a real + /// software keyboard slides up/down. However, this also means that clients need to + /// `pumpAndSettle()` to ensure the animation is complete. If you want to avoid `pumpAndSettle()` + /// and you don't care about the animation, then pass `false` to disable the animations. + final bool animateKeyboard; + + /// Whether a fake software keyboard should be displayed in the widget tree, + /// on top of the [child], simulating a real OS software keyboard. + final bool renderSimulatedKeyboard; + + final Widget child; + + @override + State createState() => _SoftwareKeyboardHeightSimulatorState(); +} + +class _SoftwareKeyboardHeightSimulatorState extends State + with SingleTickerProviderStateMixin { + static int _nextTestKeyboardId = 1; + + late final String _testKeyboardId; + + @override + void initState() { + super.initState(); + + _testKeyboardId = "$_nextTestKeyboardId"; + _nextTestKeyboardId += 1; + + if (widget.isEnabled) { + TestSuperKeyboard.install( + id: _testKeyboardId, + vsync: this, + initialKeyboardState: widget.initialKeyboardState, + fakeKeyboardHeight: widget.keyboardHeight, + keyboardAnimationTime: widget.animateKeyboard ? const Duration(milliseconds: 600) : Duration.zero, + ); + } + } + + @override + void didUpdateWidget(covariant SoftwareKeyboardHeightSimulator oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.animateKeyboard != oldWidget.animateKeyboard || widget.keyboardHeight != oldWidget.keyboardHeight) { + TestSuperKeyboard.install( + id: _testKeyboardId, + vsync: this, + initialKeyboardState: widget.initialKeyboardState, + fakeKeyboardHeight: widget.keyboardHeight, + keyboardAnimationTime: widget.animateKeyboard ? const Duration(milliseconds: 600) : Duration.zero, + ); + } + + if (widget.isEnabled && !oldWidget.isEnabled) { + throw Exception( + "You initially built a SoftwareKeyboardHeightSimulator disabled, then you enabled it. This mode needs to remain constant throughout a test."); + } else if (!widget.isEnabled && oldWidget.isEnabled) { + throw Exception( + "You initially built a SoftwareKeyboardHeightSimulator enabled, then you disabled it. This mode needs to remain constant throughout a test."); + } + } + + @override + void dispose() { + TestSuperKeyboard.uninstall(_testKeyboardId); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: SuperKeyboard.instance.mobileGeometry, + builder: (context, geometry, child) { + final realMediaQuery = MediaQuery.of(context); + final isRelevantPlatform = widget.enableForAllPlatforms || + (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); + final shouldSimulate = widget.isEnabled && isRelevantPlatform; + if (!shouldSimulate) { + return widget.child; + } + + return Directionality( + // For some reason a Stack needs Directionality. + textDirection: TextDirection.ltr, + child: Stack( + children: [ + Positioned.fill( + child: MediaQuery( + data: realMediaQuery.copyWith( + viewInsets: realMediaQuery.viewInsets.copyWith( + bottom: geometry.keyboardHeight ?? 0.0, + ), + ), + child: widget.child, + ), + ), + // Display a placeholder where the keyboard would go so we + // can verify the keyboard size in golden tests. + if (widget.renderSimulatedKeyboard) // + Positioned( + left: 0, + right: 0, + bottom: 0, + height: geometry.keyboardHeight ?? 0, + child: OverflowBox( + alignment: Alignment.topCenter, + maxHeight: widget.keyboardHeight, + child: SoftwareKeyboard( + height: widget.keyboardHeight, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class TestSuperKeyboard implements SuperKeyboard { + static void install({ + required String id, + required TickerProvider vsync, + KeyboardState initialKeyboardState = KeyboardState.closed, + double fakeKeyboardHeight = _defaultKeyboardHeight, + Duration keyboardAnimationTime = const Duration(milliseconds: 600), + }) { + if (_instance != null) { + forceUninstall(); + } + + _instance = TestSuperKeyboard( + id: id, + vsync: vsync, + initialKeyboardState: initialKeyboardState, + fakeKeyboardHeight: fakeKeyboardHeight, + keyboardAnimationTime: keyboardAnimationTime, + ); + + SuperKeyboard.testInstance = _instance; + } + + static void uninstall(String id) { + if (_instance == null || _instance!.id != id) { + return; + } + + _instance!.dispose(); + _instance = null; + + SuperKeyboard.testInstance = null; + } + + static void forceUninstall() { + if (_instance == null) { + return; + } + + uninstall(_instance!.id); + } + + static TestSuperKeyboard? _instance; + + TestSuperKeyboard({ + required this.id, + required TickerProvider vsync, + KeyboardState initialKeyboardState = KeyboardState.closed, + this.fakeKeyboardHeight = 400.0, + Duration keyboardAnimationTime = const Duration(milliseconds: 600), + }) { + _interceptPlatformChannel(); + + _geometry.value = MobileWindowGeometry( + keyboardState: initialKeyboardState, + keyboardHeight: initialKeyboardState == KeyboardState.open ? fakeKeyboardHeight : null, + ); + + _keyboardHeightController = AnimationController( + duration: keyboardAnimationTime, + vsync: vsync, + ) + ..addListener(() { + _geometry.value = _geometry.value.updateWith( + MobileWindowGeometry( + keyboardHeight: _keyboardHeightController.value * fakeKeyboardHeight, + ), + ); + }) + ..addStatusListener(_onKeyboardAnimationStatusChange); + } + + void _interceptPlatformChannel() { + TestDefaultBinaryMessengerBinding.instance.interceptChannel(SystemChannels.textInput.name) // + ..interceptMethod( + 'TextInput.setClient', + (methodCall) { + _simulatePlatformOpeningKeyboard(); + return null; + }, + ) + ..interceptMethod( + 'TextInput.show', + (methodCall) { + _simulatePlatformOpeningKeyboard(); + return null; + }, + ) + ..interceptMethod( + 'TextInput.hide', + (methodCall) { + _simulatePlatformClosingKeyboard(); + return null; + }, + ) + ..interceptMethod( + 'TextInput.clearClient', + (methodCall) { + _simulatePlatformClosingKeyboard(); + return null; + }, + ); + } + + void dispose() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(SystemChannels.textInput.name, null); + _keyboardHeightController.dispose(); + } + + @override + Future enablePlatformLogging(bool isEnabled) async { + // no-op + } + + /// An ID for this specific test keyboard instance, which is used primarily to + /// ensure that one test keyboard doesn't accidentally uninstall some other + /// test keyboard. + final String id; + + final double fakeKeyboardHeight; + + late final AnimationController _keyboardHeightController; + + @override + ValueListenable get mobileGeometry => _geometry; + final _geometry = ValueNotifier(const MobileWindowGeometry()); + + void _simulatePlatformOpeningKeyboard() { + _keyboardHeightController.forward(); + } + + void _simulatePlatformClosingKeyboard() { + _keyboardHeightController.reverse(); + } + + void _onKeyboardAnimationStatusChange(AnimationStatus status) { + switch (status) { + case AnimationStatus.forward: + _geometry.value = MobileWindowGeometry( + keyboardState: KeyboardState.opening, + keyboardHeight: fakeKeyboardHeight / 2, + bottomPadding: 48, + ); + case AnimationStatus.completed: + _geometry.value = MobileWindowGeometry( + keyboardState: KeyboardState.open, + keyboardHeight: fakeKeyboardHeight, + bottomPadding: 48, + ); + case AnimationStatus.reverse: + _geometry.value = MobileWindowGeometry( + keyboardState: KeyboardState.closing, + keyboardHeight: fakeKeyboardHeight / 2, + bottomPadding: 48, + ); + case AnimationStatus.dismissed: + _geometry.value = const MobileWindowGeometry( + keyboardState: KeyboardState.closed, + keyboardHeight: 0, + bottomPadding: 48, + ); + } + } +} + +class SoftwareKeyboard extends StatelessWidget { + static const double keySpacing = 8; + + const SoftwareKeyboard({ + super.key, + double? height, + }) : height = height ?? _defaultKeyboardHeight; + + final double height; + + @override + Widget build(BuildContext context) { + const letterButtonBackgroundColor = Color(0xFF6D6D6E); + const letterButtonForegroundColor = Colors.white; + const controlButtonBackgroundColor = Color(0xFF4A4A4B); + const controlButtonForegroundColor = Colors.white; + + return Container( + height: height, + color: const Color(0xDD2B2B2D), + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: _buildCharacterKeys( + ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], + buttonColor: letterButtonBackgroundColor, + characterColor: letterButtonForegroundColor, + ), + ), + Row( + children: _buildCharacterKeys( + ['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'], + buttonColor: letterButtonBackgroundColor, + characterColor: letterButtonForegroundColor, + ), + ), + Row(children: [ + const Padding( + padding: EdgeInsets.only(right: 12), + child: _SoftwareKeyboardButton( + backgroundColor: controlButtonBackgroundColor, + child: Icon( + Icons.keyboard_capslock, + color: controlButtonForegroundColor, + size: 16, + ), + ), + ), + ..._buildCharacterKeys( + ['Z', 'X', 'C', 'V', 'B', 'N', 'M'], + buttonColor: letterButtonBackgroundColor, + characterColor: letterButtonForegroundColor, + ), + const Padding( + padding: EdgeInsets.only(right: keySpacing), + child: _SoftwareKeyboardButton( + backgroundColor: controlButtonBackgroundColor, + child: Icon( + Icons.backspace, + color: controlButtonForegroundColor, + size: 12, + ), + ), + ), + ]), + const Row( + children: [ + Padding( + padding: EdgeInsets.only(right: keySpacing), + child: _SoftwareKeyboardButton( + backgroundColor: controlButtonBackgroundColor, + child: Text( + '123', + style: TextStyle( + fontSize: 10, + color: controlButtonForegroundColor, + ), + ), + ), + ), + Padding( + padding: EdgeInsets.only(right: keySpacing), + child: _SoftwareKeyboardButton( + backgroundColor: controlButtonBackgroundColor, + child: Icon( + Icons.insert_emoticon, + color: controlButtonForegroundColor, + size: 16, + ), + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.only(right: keySpacing), + child: _SoftwareKeyboardButton( + backgroundColor: controlButtonBackgroundColor, + child: Text( + 'Space', + style: TextStyle( + fontSize: 14, + color: controlButtonForegroundColor, + ), + ), + ), + ), + ), + Padding( + padding: EdgeInsets.only(right: keySpacing), + child: _SoftwareKeyboardButton( + backgroundColor: controlButtonBackgroundColor, + child: Text( + 'Return', + style: TextStyle( + fontSize: 10, + color: controlButtonForegroundColor, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + List _buildCharacterKeys( + List characters, { + required Color buttonColor, + required Color characterColor, + }) { + return characters + .map( + (x) => Expanded( + child: Padding( + padding: const EdgeInsets.only(right: keySpacing), + child: _SoftwareKeyboardButton( + backgroundColor: buttonColor, + child: Text( + x, + style: TextStyle( + fontSize: 10, + color: characterColor, + ), + ), + ), + ), + ), + ) + .toList(); + } +} + +/// A [SoftwareKeyboardScaffold] button, e.g., a character, space bar, action button. +class _SoftwareKeyboardButton extends StatelessWidget { + const _SoftwareKeyboardButton({ + // ignore: unused_element_parameter + super.key, + required this.backgroundColor, + // ignore: unused_element_parameter + this.borderRadius = const BorderRadius.all(Radius.circular(4)), + // ignore: unused_element_parameter + this.padding = const EdgeInsets.symmetric( + vertical: 14, + horizontal: 6, + ), + required this.child, + }); + + final Color backgroundColor; + final EdgeInsets padding; + final BorderRadius borderRadius; + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: borderRadius, + ), + padding: padding, + alignment: Alignment.center, + child: child, + ); + } +} + +const _defaultKeyboardHeight = 300.0; + +extension on TestDefaultBinaryMessengerBinding { + PlatformMessageHandler interceptChannel(String channel) { + final handler = PlatformMessageHandler(); + + defaultBinaryMessenger.setMockMessageHandler(channel, (message) async { + return await handler.handleMessage(message); + }); + + return handler; + } +} diff --git a/super_keyboard/pubspec.yaml b/super_keyboard/pubspec.yaml new file mode 100644 index 0000000000..6b2a87df89 --- /dev/null +++ b/super_keyboard/pubspec.yaml @@ -0,0 +1,89 @@ +name: super_keyboard +description: "A plugin that reports keyboard visibility and size." +version: 0.1.0+1 +homepage: https://github.com/superlistapp/super_editor +funding: + - https://flutterbountyhunters.com + - https://github.com/sponsors/matthew-carroll +topics: + - software-keyboard + - keyboard + - rich-text-editor + - editor + +environment: + sdk: ^3.5.0 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + + plugin_platform_interface: ^2.0.2 + # For accessing Activity lifecycle within the Android plugin implementation. + # References: + # * https://api.flutter.dev/javadoc/io/flutter/embedding/engine/plugins/activity/ActivityPluginBinding.html#getLifecycle() + # * https://github.com/flutter/plugins/tree/master_archive/packages/flutter_plugin_android_lifecycle + flutter_plugin_android_lifecycle: ^2.0.27 + + logging: ^1.3.0 + + # So that we can expose test tools to apps. + flutter_test: + sdk: flutter + flutter_test_runners: ^0.0.4 + +dev_dependencies: + flutter_lints: ^4.0.0 + + flutter_test_goldens: 0.0.5 + +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.flutterbountyhunters.superkeyboard.super_keyboard + pluginClass: SuperKeyboardPlugin + ios: + pluginClass: SuperKeyboardPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, 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 in packages, see + # https://flutter.dev/to/font-from-package diff --git a/super_keyboard/test/keyboard_simulation_test.dart b/super_keyboard/test/keyboard_simulation_test.dart new file mode 100644 index 0000000000..c0ec2f5e7d --- /dev/null +++ b/super_keyboard/test/keyboard_simulation_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_keyboard/super_keyboard.dart'; +import 'package:super_keyboard/super_keyboard_test.dart'; + +void main() { + group("Super Keyboard Test Tools > keyboard simulation >", () { + testWidgets("opens and closes", (tester) async { + final screenKey = GlobalKey(); + final contentKey = GlobalKey(); + await _pumpScaffold( + tester, + screenKey: screenKey, + contentKey: contentKey, + ); + + // Ensure the keyboard is closed, initially. + expect(SuperKeyboard.instance.mobileGeometry.value.keyboardState, KeyboardState.closed); + expect(_calculateKeyboardHeight(screenKey, contentKey), 0.0); + + // Focus the text field to open the keyboard. + await tester.tap(find.byType(TextField)); + + // Pump a couple frames and ensure the keyboard is opening. + // Note: If we don't explicitly pass a duration, the animation doesn't + // move forward. I don't know why. + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + expect(SuperKeyboard.instance.mobileGeometry.value.keyboardState, KeyboardState.opening); + expect(_calculateKeyboardHeight(screenKey, contentKey), lessThan(_keyboardHeight)); + expect(_calculateKeyboardHeight(screenKey, contentKey), greaterThan(0)); + + // Let the keyboard finish opening. + await tester.pumpAndSettle(); + + // Ensure that the keyboard is fully open. + expect(SuperKeyboard.instance.mobileGeometry.value.keyboardState, KeyboardState.open); + expect(_calculateKeyboardHeight(screenKey, contentKey), _keyboardHeight); + + // Tap outside the text field to unfocus it. + await tester.tapAt(const Offset(200, 100)); + + // Pump a couple frames and ensure the keyboard is closing. + // Note: If we don't explicitly pass a duration, the animation doesn't + // move forward. I don't know why. + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + expect(SuperKeyboard.instance.mobileGeometry.value.keyboardState, KeyboardState.closing); + expect(_calculateKeyboardHeight(screenKey, contentKey), lessThan(_keyboardHeight)); + expect(_calculateKeyboardHeight(screenKey, contentKey), greaterThan(0)); + + // Let the keyboard finish closing. + await tester.pumpAndSettle(); + + // Ensure that the keyboard is fully closed. + expect(SuperKeyboard.instance.mobileGeometry.value.keyboardState, KeyboardState.closed); + expect(_calculateKeyboardHeight(screenKey, contentKey), 0.0); + }); + + testWidgetsOnMobile("enabled by default on mobile", (tester) async { + final screenKey = GlobalKey(); + final contentKey = GlobalKey(); + await _pumpScaffold( + tester, + screenKey: screenKey, + contentKey: contentKey, + ); + + // Ensure the keyboard is closed, initially. + expect(_calculateKeyboardHeight(screenKey, contentKey), 0.0); + + // Focus the text field to open the keyboard. + await tester.tap(find.byType(TextField)); + + // Let the keyboard animate up. + await tester.pumpAndSettle(); + + // Ensure the keyboard is open. + expect(_calculateKeyboardHeight(screenKey, contentKey), _keyboardHeight); + }); + + testWidgetsOnDesktop("disabled by default on desktop", (tester) async { + final screenKey = GlobalKey(); + final contentKey = GlobalKey(); + await _pumpScaffold( + tester, + screenKey: screenKey, + contentKey: contentKey, + ); + + // Ensure the keyboard is closed, initially. + expect(_calculateKeyboardHeight(screenKey, contentKey), 0.0); + + // Focus the text field to open the keyboard. + await tester.tap(find.byType(TextField)); + + // Give the keyboard a chance to animate up (it shouldn't). + await tester.pumpAndSettle(); + + // Ensure the keyboard is still closed. + expect(_calculateKeyboardHeight(screenKey, contentKey), 0.0); + }); + }); +} + +Future _pumpScaffold( + WidgetTester tester, { + GlobalKey? screenKey, + GlobalKey? contentKey, +}) async { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + tester.view.physicalSize = const Size(1170, 2532); // iPhone 13 Pro + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + // Use default test window size for desktop. + } + + await tester.pumpWidget( + SizedBox( + key: screenKey, + child: SoftwareKeyboardHeightSimulator( + keyboardHeight: _keyboardHeight, + animateKeyboard: true, + child: MaterialApp( + home: Scaffold( + body: Center( + key: contentKey, + child: TextField( + onTapOutside: (event) { + // Remove focus on tap outside. + FocusManager.instance.primaryFocus?.unfocus(); + }, + ), + ), + ), + ), + ), + ), + ); +} + +double _calculateKeyboardHeight(GlobalKey screenKey, GlobalKey contentKey) { + final screenBox = screenKey.currentContext!.findRenderObject() as RenderBox; + final contentBox = contentKey.currentContext!.findRenderObject() as RenderBox; + + return screenBox.size.height - contentBox.size.height; +} + +const _keyboardHeight = 300.0; diff --git a/super_keyboard/test_goldens/goldens/keyboard-tools_keyboard-widget_opens-and-closes.png b/super_keyboard/test_goldens/goldens/keyboard-tools_keyboard-widget_opens-and-closes.png new file mode 100644 index 0000000000..30d22d8041 Binary files /dev/null and b/super_keyboard/test_goldens/goldens/keyboard-tools_keyboard-widget_opens-and-closes.png differ diff --git a/super_keyboard/test_goldens/software_keyboard_tools_test.dart b/super_keyboard/test_goldens/software_keyboard_tools_test.dart new file mode 100644 index 0000000000..4fe397830c --- /dev/null +++ b/super_keyboard/test_goldens/software_keyboard_tools_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/flutter_test_goldens.dart'; +import 'package:super_keyboard/super_keyboard_test.dart'; + +void main() { + group("Super Keyboard > software keyboard > tools >", () { + testGoldenSceneOnIOS("keyboard widget opens and closes", (tester) async { + await Timeline( + "Software Keyboard Opens/Closes", + fileName: "keyboard-tools_keyboard-widget_opens-and-closes", + layout: const AnimationTimelineSceneLayout( + rowBreakPolicy: AnimationTimelineRowBreak.beforeItemDescription("Start"), + ), + // Size of an iPhone 16 (DIP). + windowSize: const Size(393, 852), + itemScaffold: minimalTimelineItemScaffold, + ) // + .setupWithWidget(_buildKeyboardSimulatorScaffold(tester)) + // Open the keyboard. + .takePhoto("Start") + .tap(find.byType(TextField)) + .modifyScene((tester, _) async { + await tester.pump(); + }) + .takePhotos(10, const Duration(milliseconds: 60)) + .takePhoto("Open") + // Close the keyboard. + .takePhoto("Start") + .modifyScene((tester, _) async { + await tester.tapAt(const Offset(50, 50)); + }) + .modifyScene((tester, _) async { + await tester.pump(); + }) + .takePhotos(10, const Duration(milliseconds: 60)) + .takePhoto("Closed") + .run(tester); + }); + }); +} + +Widget _buildKeyboardSimulatorScaffold(WidgetTester tester) { + return MaterialApp( + home: SoftwareKeyboardHeightSimulator( + animateKeyboard: true, + renderSimulatedKeyboard: true, + child: Scaffold( + body: Builder(builder: (context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + behavior: HitTestBehavior.opaque, + child: const Center( + child: SizedBox( + width: 250, + child: TextField(), + ), + ), + ); + }), + ), + ), + debugShowCheckedModeBanner: false, + ); +} diff --git a/super_text_layout/.gitignore b/super_text_layout/.gitignore index d9b4c8d030..96bdf203af 100644 --- a/super_text_layout/.gitignore +++ b/super_text_layout/.gitignore @@ -1,5 +1,6 @@ # Golden failures test/**/failures/ +test_goldens/**/failures/ # Miscellaneous *.class diff --git a/super_text_layout/CHANGELOG.md b/super_text_layout/CHANGELOG.md index c1f567ac3c..784f490ca6 100644 --- a/super_text_layout/CHANGELOG.md +++ b/super_text_layout/CHANGELOG.md @@ -1,22 +1,68 @@ -## [0.1.4] - Oct, 2022 +## [0.1.15] - Nov, 2024 + * [FIX] - Fix layout error when text in `SuperText` is empty. -Fixed a `NullPointerException` in `SuperTextLayout`. +## [0.1.14] - Sept, 2024 + * [FIX] - Flutter was reporting 1px height different between empty text and non-empty text. + We introduced a temporary hack fix to keep the reported height consistent until Flutter + ships its own fix. -## [0.1.3] - July, 2022 +## [0.1.13] - Sept, 2024 + * [FIX] - Caret size is as expected when placed at end of paragraph with preceding space. + This bug was caused by Flutter and we introduced a temporary hack to solve it until + Flutter ships its own fix. -Upgraded the dependency on `attributed_text` from `0.1.3` to `0.2.0`. +## [0.1.12] - Aug, 2024 + * Package metadata update - no functional changes. -## [0.1.2] - July, 2022 - DEPRECATED +## [0.1.11] - Aug, 2024 + * [BREAKING] - Replaced singular `UnderlineStyle` in `TextUnderlineLayer` with a styler per + underline (to support composing, spelling errors, and grammar errors). + * Text selection boxes now support rounded corners. + * Adjusted the precise positioning of text selection rectangles. -Upgraded the dependency on `attributed_text` from `0.1.3` to `0.2.0`. +## [0.1.10] - June, 2024 + * `FillWidthIfConstrained` uses ancestor constraints instead of ancestor size. + * Changed `getLineHeightAtPosition` and `getCharacterBox` to both use `RenderParagraph.getFullHeightForCaret()`. + * Nudged the caret offset so that the caret straddles its desired location, instead of sitting completely to the right of it. + * Resolved some lint complaints. -## [0.1.1] - July, 2022 - DEPRECATED +## [0.1.9] - Feb, 2024 + * [FIX] - `BlinkController.isBlinking` now accounts for the use of `Timer`s in addition to `Ticker`s. + * [FIX] - Changing `textAlign` for `SuperText` correctly repositions carets, handles, and selection boxes for the newly aligned text. + * [FIX] - `TextLayoutCaret` now respects the controller given to the widget, instead of ignoring it. + * `TextLayout.getBoxesForSelection()` now allows you to choose between `tight` and `max` box sizes for each character box. + * Related: underlines are now continuous instead of being broken between characters. -Added `estimatedLineHeight` to `TextLayout`. The method is experimental - it may be removed later. +## [0.1.8] - Dec, 2023 + * Added `TextUnderlineLayer` to draw underlines beneath text. + * Added `collection` dependency. -## [0.1.0] - May, 2022 +## [0.1.7] - July, 2023 + * Added `isBlinking` property to `BlinkController`. + +## [0.1.6] - May, 2023 + * Explicitly upgraded to Dart 3 support. + * Bumped `attributed_text` dependency to `0.2.2`. + +## [0.1.5] - April, 2023 +Added support for font scaling + + * Bumped `attributed_text` dependency to `0.2.1`. + +## [0.1.4] - Oct, 2022 +Fixed a `NullPointerException` in `SuperTextLayout` -The `super_text_layout` package is extracted from `super_editor`. +## [0.1.3] - July, 2022 +Upgraded the dependency on `attributed_text` from `0.1.3` to `0.2.0` + +## [0.1.2] - DEPRECATED - July, 2022 +Upgraded the dependency on `attributed_text` from `0.1.3` to `0.2.0` + +## [0.1.1] - DEPRECATED - July, 2022 +Added `estimatedLineHeight` to `TextLayout`. The method is experimental - it may be removed later + +## [0.1.0] - May, 2022 +The `super_text_layout` package is extracted from `super_editor` * Introduces `SuperText` widget to render text with layers above and beneath the text * Introduces `SuperTextWithSelection` to easily paint text with traditional user selections, diff --git a/super_text_layout/README_TESTS.md b/super_text_layout/README_TESTS.md new file mode 100644 index 0000000000..fa486d322e --- /dev/null +++ b/super_text_layout/README_TESTS.md @@ -0,0 +1,44 @@ +# Running tests + +In order to run the golden tests, Docker must be installed. See docs for installing Docker Desktop: +- macOS: https://docs.docker.com/desktop/install/mac-install/ +- Linux: https://docs.docker.com/desktop/install/linux-install/ +- Windows: https://docs.docker.com/desktop/install/windows-install/ + +Activate the golden_runner with: + +```console +dart pub global activate --source path ../golden_runner +``` + +## Run golden tests: + +``` +# run all tests +goldens test + +# run a single test +goldens test --plain-name "something" + +# run all tests in a directory +goldens test test my_dir + +# run a single test in a directory +goldens test --plain-name "something" my_dir +``` + +## Update golden files: + +``` +# update all goldens +goldens update + +# update all goldens in a directory +goldens update my_dir + +# update a single golden +goldens update --plain-name "something" + +# update a single golden in a directory +goldens update --plain-name "something" my_dir +``` diff --git a/super_text_layout/doc/pub/screenshots/follow-the-caret.png b/super_text_layout/doc/pub/screenshots/follow-the-caret.png new file mode 100644 index 0000000000..4c9377d7a0 Binary files /dev/null and b/super_text_layout/doc/pub/screenshots/follow-the-caret.png differ diff --git a/super_text_layout/doc/pub/screenshots/multiple-user-selections.png b/super_text_layout/doc/pub/screenshots/multiple-user-selections.png new file mode 100644 index 0000000000..3303543aa7 Binary files /dev/null and b/super_text_layout/doc/pub/screenshots/multiple-user-selections.png differ diff --git a/super_text_layout/doc/pub/screenshots/selection-and-caret.png b/super_text_layout/doc/pub/screenshots/selection-and-caret.png new file mode 100644 index 0000000000..eef43441c9 Binary files /dev/null and b/super_text_layout/doc/pub/screenshots/selection-and-caret.png differ diff --git a/super_text_layout/doc/pub/screenshots/text-highlights.png b/super_text_layout/doc/pub/screenshots/text-highlights.png new file mode 100644 index 0000000000..89c396432a Binary files /dev/null and b/super_text_layout/doc/pub/screenshots/text-highlights.png differ diff --git a/super_text_layout/example/lib/main.dart b/super_text_layout/example/lib/main.dart index a3b9d9a403..7c76eb951e 100644 --- a/super_text_layout/example/lib/main.dart +++ b/super_text_layout/example/lib/main.dart @@ -50,11 +50,6 @@ class _SuperTextExampleScreenState extends State with Ti super.initState(); } - @override - void dispose() { - super.dispose(); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -83,6 +78,7 @@ class _SuperTextExampleScreenState extends State with Ti _buildSingleCaret(), _buildSingleSelectionHighlight(), _buildSingleSelectionHighlightRainbow(), + _buildComposingRegionUnderline(), _buildMultiUserSelections(), _buildEmptySelection(), const SizedBox(height: 48), @@ -260,7 +256,7 @@ class _SuperTextExampleScreenState extends State with Ti RainbowBuilder(builder: (context, color) { return TextLayoutSelectionHighlight( textLayout: textLayout, - style: _primaryHighlightStyle.copyWith(color: color.withOpacity(0.2)), + style: _primaryHighlightStyle.copyWith(color: color.withValues(alpha: 0.2)), selection: const TextSelection(baseOffset: 11, extentOffset: 21), ); }), @@ -271,6 +267,28 @@ class _SuperTextExampleScreenState extends State with Ti ); } + Widget _buildComposingRegionUnderline() { + return _buildExampleContainer( + child: SuperText( + richText: const TextSpan( + text: "Can display underlines like the composing reg", + style: _textStyle, + ), + layerBeneathBuilder: (context, textLayout) { + return TextUnderlineLayer( + textLayout: textLayout, + style: const StraightUnderlineStyle(color: Colors.black), + underlines: const [ + TextLayoutUnderline( + range: TextSelection(baseOffset: 42, extentOffset: 45), + ), + ], + ); + }, + ), + ); + } + Widget _buildMultiUserSelections() { return _buildExampleContainer( child: SuperText( @@ -468,7 +486,7 @@ const _johnCaretStyle = CaretStyle( color: Colors.red, ); final _johnHighlightStyle = SelectionHighlightStyle( - color: Colors.red.withOpacity(0.5), + color: Colors.red.withValues(alpha: 0.5), ); const _johnUserLabelStyle = UserLabelStyle( color: Colors.red, @@ -486,7 +504,7 @@ const _sallyCaretStyle = CaretStyle( color: Colors.purpleAccent, ); final _sallyHighlightStyle = SelectionHighlightStyle( - color: Colors.purpleAccent.withOpacity(0.5), + color: Colors.purpleAccent.withValues(alpha: 0.5), ); const _sallyUserLabelStyle = UserLabelStyle( color: Colors.purpleAccent, diff --git a/super_text_layout/example/lib/rainbow_builder.dart b/super_text_layout/example/lib/rainbow_builder.dart index b8ce815e44..008044cdb4 100644 --- a/super_text_layout/example/lib/rainbow_builder.dart +++ b/super_text_layout/example/lib/rainbow_builder.dart @@ -12,7 +12,7 @@ class RainbowBuilder extends StatefulWidget { final Widget Function(BuildContext, Color) builder; @override - _RainbowBuilderState createState() => _RainbowBuilderState(); + State createState() => _RainbowBuilderState(); } class _RainbowBuilderState extends State with SingleTickerProviderStateMixin { diff --git a/super_text_layout/example/lib/rainbow_character_supertext.dart b/super_text_layout/example/lib/rainbow_character_supertext.dart index 07c20bc616..e9649d256c 100644 --- a/super_text_layout/example/lib/rainbow_character_supertext.dart +++ b/super_text_layout/example/lib/rainbow_character_supertext.dart @@ -12,7 +12,7 @@ class CharacterRainbowSuperText extends StatefulWidget { final TextSpan text; @override - _CharacterRainbowSuperTextState createState() => _CharacterRainbowSuperTextState(); + State createState() => _CharacterRainbowSuperTextState(); } class _CharacterRainbowSuperTextState extends State with SingleTickerProviderStateMixin { @@ -50,8 +50,8 @@ class _CharacterRainbowSuperTextState extends State w final textLength = widget.text.toPlainText().length; for (int i = 0; i < textLength; i += 1) { // Get the bounding rectangle for the character - characterRects.add(textLayout.getCharacterBox(TextPosition(offset: i))?.toRect() - ?? Rect.fromLTRB(0, 0, 0, textLayout.estimatedLineHeight)); + characterRects.add(textLayout.getCharacterBox(TextPosition(offset: i))?.toRect() ?? + Rect.fromLTRB(0, 0, 0, textLayout.estimatedLineHeight)); // Select a color for this character final colorWheelDegrees = ((360.0 * (characterColors.length / textLength)) + _startingColor.value) % 360; diff --git a/super_text_layout/example/macos/Runner.xcodeproj/project.pbxproj b/super_text_layout/example/macos/Runner.xcodeproj/project.pbxproj index c84862c675..d9333e4704 100644 --- a/super_text_layout/example/macos/Runner.xcodeproj/project.pbxproj +++ b/super_text_layout/example/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -235,6 +235,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -344,7 +345,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -423,7 +424,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -470,7 +471,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/super_text_layout/example/pubspec.lock b/super_text_layout/example/pubspec.lock index ce5bdd7671..2ef9fba995 100644 --- a/super_text_layout/example/pubspec.lock +++ b/super_text_layout/example/pubspec.lock @@ -5,107 +5,114 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - url: "https://pub.dartlang.org" + sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + url: "https://pub.dev" source: hosted - version: "39.0.0" + version: "61.0.0" analyzer: dependency: transitive description: name: analyzer - url: "https://pub.dartlang.org" + sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.13.0" args: dependency: transitive description: name: args - url: "https://pub.dartlang.org" + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.4.2" async: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.11.0" attributed_text: dependency: transitive description: name: attributed_text - url: "https://pub.dartlang.org" + sha256: fb65cf441784612544eda4d5df7a3caad56e7f673c68bf1d48d9048228375189 + url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.4.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" characters: dependency: transitive description: name: characters - url: "https://pub.dartlang.org" + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" + version: "1.3.0" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.19.0" convert: dependency: transitive description: name: convert - url: "https://pub.dartlang.org" + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.1" coverage: dependency: transitive description: name: coverage - url: "https://pub.dartlang.org" + sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76" + url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.7.2" crypto: dependency: transitive description: name: crypto - url: "https://pub.dartlang.org" + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" fake_async: dependency: transitive description: name: fake_async - url: "https://pub.dartlang.org" + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.1" file: dependency: transitive description: name: file - url: "https://pub.dartlang.org" + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "7.0.0" flutter: dependency: "direct main" description: flutter @@ -115,7 +122,8 @@ packages: dependency: "direct dev" description: name: flutter_lints - url: "https://pub.dartlang.org" + sha256: b543301ad291598523947dc534aaddc5aaad597b709d2426d3a0e0d44c5cb493 + url: "https://pub.dev" source: hosted version: "1.0.4" flutter_test: @@ -127,280 +135,342 @@ packages: dependency: transitive description: name: frontend_server_client - url: "https://pub.dartlang.org" + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "3.2.0" glob: dependency: transitive description: name: glob - url: "https://pub.dartlang.org" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.2" http_multi_server: dependency: transitive description: name: http_multi_server - url: "https://pub.dartlang.org" + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.1" http_parser: dependency: transitive description: name: http_parser - url: "https://pub.dartlang.org" + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" io: dependency: transitive description: name: io - url: "https://pub.dartlang.org" + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" js: dependency: transitive description: name: js - url: "https://pub.dartlang.org" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.dev" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.dev" + source: hosted + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "3.0.1" lints: dependency: transitive description: name: lints - url: "https://pub.dartlang.org" + sha256: a2c3d198cb5ea2e179926622d433331d8b58374ab8f29cdda6e863bd62fd369c + url: "https://pub.dev" source: hosted version: "1.0.1" logging: dependency: transitive description: name: logging - url: "https://pub.dartlang.org" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.3.0" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" source: hosted - version: "0.12.11" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - url: "https://pub.dartlang.org" + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + url: "https://pub.dev" source: hosted - version: "0.1.4" + version: "0.5.0" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.12.0" mime: dependency: transitive description: name: mime - url: "https://pub.dartlang.org" + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.4" node_preamble: dependency: transitive description: name: node_preamble - url: "https://pub.dartlang.org" + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" package_config: dependency: transitive description: name: package_config - url: "https://pub.dartlang.org" + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" source: hosted - version: "1.8.1" + version: "1.9.0" pool: dependency: transitive description: name: pool - url: "https://pub.dartlang.org" + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.1" pub_semver: dependency: transitive description: name: pub_semver - url: "https://pub.dartlang.org" + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.4" shelf: dependency: transitive description: name: shelf - url: "https://pub.dartlang.org" + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" shelf_packages_handler: dependency: transitive description: name: shelf_packages_handler - url: "https://pub.dartlang.org" + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.2" shelf_static: dependency: transitive description: name: shelf_static - url: "https://pub.dartlang.org" + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - url: "https://pub.dartlang.org" + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.4" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_map_stack_trace: dependency: transitive description: name: source_map_stack_trace - url: "https://pub.dartlang.org" + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" source_maps: dependency: transitive description: name: source_maps - url: "https://pub.dartlang.org" + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" source: hosted - version: "0.10.10" + version: "0.10.12" source_span: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" source: hosted - version: "1.8.2" + version: "1.10.0" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.0" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.3.0" super_text_layout: dependency: "direct main" description: path: ".." relative: true source: path - version: "0.1.0" + version: "0.1.17" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" test: dependency: transitive description: name: test - url: "https://pub.dartlang.org" + sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" + url: "https://pub.dev" source: hosted - version: "1.21.1" + version: "1.25.8" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" source: hosted - version: "0.4.9" + version: "0.7.3" test_core: dependency: transitive description: name: test_core - url: "https://pub.dartlang.org" + sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" + url: "https://pub.dev" source: hosted - version: "0.4.13" + version: "0.6.5" typed_data: dependency: transitive description: name: typed_data - url: "https://pub.dartlang.org" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.2" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.dartlang.org" + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" vm_service: dependency: transitive description: name: vm_service - url: "https://pub.dartlang.org" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" source: hosted - version: "7.5.0" + version: "14.3.0" watcher: dependency: transitive description: name: watcher - url: "https://pub.dartlang.org" + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" web_socket_channel: dependency: transitive description: name: web_socket_channel - url: "https://pub.dartlang.org" + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.4.0" webkit_inspection_protocol: dependency: transitive description: name: webkit_inspection_protocol - url: "https://pub.dartlang.org" + sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.2.0" yaml: dependency: transitive description: name: yaml - url: "https://pub.dartlang.org" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.2" sdks: - dart: ">=2.17.0-0 <3.0.0" - flutter: ">=1.17.0" + dart: ">=3.4.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/super_text_layout/lib/src/caret_layer.dart b/super_text_layout/lib/src/caret_layer.dart index 32f256c136..f9c3659d51 100644 --- a/super_text_layout/lib/src/caret_layer.dart +++ b/super_text_layout/lib/src/caret_layer.dart @@ -8,6 +8,7 @@ class TextLayoutCaret extends StatefulWidget { Key? key, required this.textLayout, this.blinkController, + this.blinkTimingMode = BlinkTimingMode.ticker, this.blinkCaret = true, required this.style, required this.position, @@ -16,6 +17,7 @@ class TextLayoutCaret extends StatefulWidget { final TextLayout textLayout; final BlinkController? blinkController; + final BlinkTimingMode blinkTimingMode; final bool blinkCaret; final CaretStyle style; final TextPosition? position; @@ -32,7 +34,7 @@ class TextLayoutCaretState extends State with TickerProviderSta @override void initState() { super.initState(); - _blinkController = widget.blinkController ?? BlinkController(tickerProvider: this); + _blinkController = _obtainBlinkController(); if (widget.blinkCaret) { _blinkController.startBlinking(); } @@ -54,7 +56,7 @@ class TextLayoutCaretState extends State with TickerProviderSta oldBlinkController.dispose(); }); } - _blinkController = widget.blinkController ?? BlinkController(tickerProvider: this); + _blinkController = _obtainBlinkController(); } if (widget.position != oldWidget.position && widget.blinkCaret) { @@ -73,16 +75,49 @@ class TextLayoutCaretState extends State with TickerProviderSta super.dispose(); } + BlinkController _obtainBlinkController() { + if (widget.blinkController != null) { + return widget.blinkController!; + } + + switch (widget.blinkTimingMode) { + case BlinkTimingMode.ticker: + return BlinkController(tickerProvider: this); + case BlinkTimingMode.timer: + return BlinkController.withTimer(); + } + } + @visibleForTesting bool get isCaretPresent => widget.position != null && widget.position!.offset >= 0; + @visibleForTesting + Offset? get caretOffset => isCaretPresent + ? widget.textLayout.getOffsetForCaret(widget.position!).translate(-widget.style.width / 2, 0.0) + : null; + + @visibleForTesting + double? get caretHeight => isCaretPresent + ? widget.textLayout.getHeightForCaret(widget.position!) ?? + widget.textLayout.getLineHeightAtPosition(widget.position!) + : null; + + @visibleForTesting + Rect? get localCaretGeometry => isCaretPresent ? caretOffset! & Size(widget.style.width, caretHeight!) : null; + + Rect? get globalCaretGeometry { + if (!isCaretPresent) { + return null; + } + + final topLeftInGlobalSpace = (context.findRenderObject() as RenderBox).localToGlobal(Offset.zero); + return localCaretGeometry!.translate(topLeftInGlobalSpace.dx, topLeftInGlobalSpace.dy); + } + @override Widget build(BuildContext context) { - final offset = isCaretPresent ? widget.textLayout.getOffsetForCaret(widget.position!) : null; - final height = isCaretPresent - ? widget.textLayout.getHeightForCaret(widget.position!) ?? - widget.textLayout.getLineHeightAtPosition(widget.position!) - : null; + final offset = caretOffset; + final height = caretHeight; return Stack( clipBehavior: Clip.none, @@ -144,7 +179,7 @@ class CaretPainter extends CustomPainter { // update painter to support generic geometry _caretStyle.borderRadius.resolve(TextDirection.ltr).topLeft, ), - Paint()..color = _caretStyle.color.withOpacity(blinkController?.opacity ?? 1.0), + Paint()..color = _caretStyle.color.withValues(alpha: blinkController?.opacity ?? 1.0), ); } diff --git a/super_text_layout/lib/src/infrastructure/blink_controller.dart b/super_text_layout/lib/src/infrastructure/blink_controller.dart index c9b7910e32..dd5bb22546 100644 --- a/super_text_layout/lib/src/infrastructure/blink_controller.dart +++ b/super_text_layout/lib/src/infrastructure/blink_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; @@ -20,16 +22,33 @@ class BlinkController with ChangeNotifier { _ticker = tickerProvider.createTicker(_onTick); } + BlinkController.withTimer({ + Duration flashPeriod = const Duration(milliseconds: 500), + }) : _flashPeriod = flashPeriod; + @override void dispose() { - _ticker.dispose(); + _ticker?.dispose(); + _timer?.cancel(); + super.dispose(); } - late final Ticker _ticker; - final Duration _flashPeriod; + Ticker? _ticker; Duration _lastBlinkTime = Duration.zero; + Timer? _timer; + + final Duration _flashPeriod; + + /// Duration to switch between visible and invisible. + Duration get flashPeriod => _flashPeriod; + + /// Returns `true` if this controller is currently animating a blinking + /// signal, or `false` if it's not. + bool get isBlinking => + (_ticker != null || _timer != null) && (_ticker != null ? _ticker!.isTicking : _timer?.isActive ?? false); + bool _isBlinkingEnabled = true; set isBlinkingEnabled(bool newValue) { if (newValue == _isBlinkingEnabled) { @@ -53,28 +72,49 @@ class BlinkController with ChangeNotifier { return; } - _ticker - ..stop() - ..start(); + if (_ticker != null) { + // We're using a Ticker to blink. Restart it. + _ticker! + ..stop() + ..start(); + } else { + // We're using a Timer to blink. Restart it. + _timer?.cancel(); + _timer = Timer(_flashPeriod, _blink); + } + _lastBlinkTime = Duration.zero; notifyListeners(); } void stopBlinking() { _isVisible = true; // If we're not blinking then we need to be visible - _ticker.stop(); + + if (_ticker != null) { + // We're using a Ticker to blink. Stop it. + _ticker?.stop(); + _ticker = null; + } else { + // We're using a Timer to blink. Stop it. + _timer?.cancel(); + _timer = null; + } + notifyListeners(); } /// Make the object completely opaque, and restart the blink timer. void jumpToOpaque() { + final wasBlinking = isBlinking; stopBlinking(); if (!_isBlinkingEnabled) { return; } - startBlinking(); + if (wasBlinking) { + startBlinking(); + } } void _onTick(Duration elapsedTime) { @@ -87,5 +127,23 @@ class BlinkController with ChangeNotifier { void _blink() { _isVisible = !_isVisible; notifyListeners(); + + if (_timer != null && _isBlinkingEnabled) { + _timer = Timer(_flashPeriod, _blink); + } } } + +/// The way a blinking caret tracks time. +/// +/// Ideally, all time in Flutter widgets is tracked by `Ticker`s because they hook into +/// Flutter's internal time reporting. This is critical for tests. +/// +/// Unfortunately, at the time of this writing, running `Ticker`s forces Flutter into +/// full FPS rendering, even when nothing needs to be rebuilt or painted. For that reason, +/// [BlinkController] lets users request the use of Dart `Timer`s, which only fire +/// when needed. `Timer`s are not expected to work in widget tests. +enum BlinkTimingMode { + ticker, + timer, +} diff --git a/super_text_layout/lib/src/inline_widgets.dart b/super_text_layout/lib/src/inline_widgets.dart new file mode 100644 index 0000000000..8121a72875 --- /dev/null +++ b/super_text_layout/lib/src/inline_widgets.dart @@ -0,0 +1,31 @@ +/// A placeholder to be given to an `AttributedText`, and later replaced +/// within an inline network image. +class InlineNetworkImagePlaceholder { + const InlineNetworkImagePlaceholder(this.url); + + final String url; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is InlineNetworkImagePlaceholder && runtimeType == other.runtimeType && url == other.url; + + @override + int get hashCode => url.hashCode; +} + +/// A placeholder to be given to an `AttributedText`, and later replaced +/// within an inline asset image. +class InlineAssetImagePlaceholder { + const InlineAssetImagePlaceholder(this.assetPath); + + final String assetPath; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is InlineAssetImagePlaceholder && runtimeType == other.runtimeType && assetPath == other.assetPath; + + @override + int get hashCode => assetPath.hashCode; +} diff --git a/super_text_layout/lib/src/super_text.dart b/super_text_layout/lib/src/super_text.dart index 984eb3c6e8..a7791b106a 100644 --- a/super_text_layout/lib/src/super_text.dart +++ b/super_text_layout/lib/src/super_text.dart @@ -27,6 +27,9 @@ class SuperText extends StatefulWidget { required this.richText, this.textAlign = TextAlign.left, this.textDirection = TextDirection.ltr, + this.textScaler, + this.maxLines, + this.overflow = TextOverflow.clip, this.layerBeneathBuilder, this.layerAboveBuilder, this.debugTrackTextBuilds = false, @@ -41,6 +44,18 @@ class SuperText extends StatefulWidget { /// The text direction to use for [richText] display. final TextDirection textDirection; + /// The text scaling policy. + /// + /// Defaults to `MediaQuery.textScalerOf`. + final TextScaler? textScaler; + + /// The maximum number of lines of text that are permitted to be displayed until [overflow] + /// is applied to the text. + final int? maxLines; + + /// The effect used when text exceeds the available space, e.g., clip, ellipsis, fade, show. + final TextOverflow overflow; + /// Builds a widget that appears beneath the text, e.g., to render text /// selection boxes. final SuperTextLayerBuilder? layerBeneathBuilder; @@ -58,7 +73,7 @@ class SuperText extends StatefulWidget { } @visibleForTesting -class SuperTextState extends State with ProseTextBlock { +class SuperTextState extends ProseTextState with ProseTextBlock { final _textLayoutKey = GlobalKey(); @override ProseTextLayout get textLayout => RenderSuperTextLayout.textLayoutFrom(_textLayoutKey)!; @@ -84,6 +99,10 @@ class SuperTextState extends State with ProseTextBlock { text: LayoutAwareRichText( text: widget.richText, textAlign: widget.textAlign, + textDirection: widget.textDirection, + textScaler: widget.textScaler ?? MediaQuery.textScalerOf(context), + maxLines: widget.maxLines, + overflow: widget.overflow, onMarkNeedsLayout: _invalidateParagraph, ), background: LayoutBuilder( @@ -210,6 +229,41 @@ class RenderSuperTextLayout extends RenderBox } } + @override + double computeMinIntrinsicWidth(double height) { + final children = getChildrenAsList(); + final text = children[1]; + return text.getMinIntrinsicWidth(height); + } + + @override + double computeMaxIntrinsicWidth(double height) { + final children = getChildrenAsList(); + final text = children[1]; + return text.getMaxIntrinsicWidth(height); + } + + @override + double computeMinIntrinsicHeight(double width) { + final children = getChildrenAsList(); + final text = children[1]; + return text.getMinIntrinsicHeight(width); + } + + @override + double computeMaxIntrinsicHeight(double width) { + final children = getChildrenAsList(); + final text = children[1]; + return text.getMaxIntrinsicHeight(width); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final children = getChildrenAsList(); + final text = children[1]; + return text.getDryLayout(constraints); + } + @override void performLayout() { layoutLog.info("Running SuperText layout. Incoming constraints: $constraints"); @@ -250,11 +304,15 @@ class RenderSuperTextLayout extends RenderBox @visibleForTesting class LayoutAwareRichText extends RichText { LayoutAwareRichText({ - Key? key, - required InlineSpan text, - TextAlign textAlign = TextAlign.left, + super.key, + required super.text, + super.textAlign = TextAlign.left, + super.textDirection = TextDirection.ltr, + super.textScaler = TextScaler.noScaling, + super.maxLines, + super.overflow, required this.onMarkNeedsLayout, - }) : super(key: key, text: text, textAlign: textAlign); + }); /// Callback invoked when the underlying [RenderParagraph] invalidates /// its layout. @@ -269,7 +327,7 @@ class LayoutAwareRichText extends RichText { textDirection: textDirection ?? Directionality.of(context), softWrap: softWrap, overflow: overflow, - textScaleFactor: textScaleFactor, + textScaler: textScaler, maxLines: maxLines, strutStyle: strutStyle, textWidthBasis: textWidthBasis, @@ -288,7 +346,7 @@ class LayoutAwareRichText extends RichText { ..textDirection = textDirection ?? Directionality.of(context) ..softWrap = softWrap ..overflow = overflow - ..textScaleFactor = textScaleFactor + ..textScaler = textScaler ..maxLines = maxLines ..strutStyle = strutStyle ..textWidthBasis = textWidthBasis @@ -308,7 +366,7 @@ class RenderLayoutAwareParagraph extends RenderParagraph { required TextDirection textDirection, bool softWrap = true, TextOverflow overflow = TextOverflow.clip, - double textScaleFactor = 1.0, + TextScaler textScaler = TextScaler.noScaling, int? maxLines, Locale? locale, StrutStyle? strutStyle, @@ -323,7 +381,7 @@ class RenderLayoutAwareParagraph extends RenderParagraph { textDirection: textDirection, softWrap: softWrap, overflow: overflow, - textScaleFactor: textScaleFactor, + textScaler: textScaler, maxLines: maxLines, locale: locale, strutStyle: strutStyle, @@ -340,6 +398,21 @@ class RenderLayoutAwareParagraph extends RenderParagraph { } } + // We override the default textAlign setter because Flutter's RenderParagraph setter + // only calls markNeedsPaint, not markNeedsLayout. However, changing alignment does + // change the layout of the text. + // + // https://github.com/flutter/flutter/issues/140756 + @override + set textAlign(TextAlign value) { + if (value == super.textAlign) { + return; + } + + super.textAlign = value; + markNeedsLayout(); + } + bool get needsLayout => _needsLayout; bool _needsLayout = true; @@ -354,7 +427,30 @@ class RenderLayoutAwareParagraph extends RenderParagraph { void performLayout() { super.performLayout(); _needsLayout = false; + + // FIXME: Remove this after Flutter #155620 is fixed. + // Directly measure the line height for non-empty text because Flutter's + // measurement for empty text is wrong for a random set of font sizes. + if (text.toPlainText().isEmpty) { + final textStyle = text.style; + if (textStyle != null) { + _textPainter + ..text = TextSpan(text: "a", style: textStyle) + ..textDirection = textDirection + ..textAlign = textAlign + ..layout(); + + // We have no text, so we set the height of this render object to the line height + // of an arbitrary character of the given style. However, it's possible that our + // parent render object imposed constraints that are shorter than a single line + // of text. To avoid breaking Flutter's layout rules, we take the minimum height + // between the single line of text, and the incoming height constraints. + size = constraints.constrain(Size(size.width, _textPainter.height)); + } + } } + + final _textPainter = TextPainter(); } typedef SuperTextLayerBuilder = Widget Function(BuildContext, TextLayout textLayout); diff --git a/super_text_layout/lib/src/super_text_inspector.dart b/super_text_layout/lib/src/super_text_inspector.dart new file mode 100644 index 0000000000..890c7d0364 --- /dev/null +++ b/super_text_layout/lib/src/super_text_inspector.dart @@ -0,0 +1,31 @@ +// ignore_for_file: invalid_use_of_visible_for_testing_member + +import 'package:flutter/widgets.dart'; +// ignore: depend_on_referenced_packages +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_text_layout/src/super_text.dart'; + +/// Inspects a given [SuperText] in the widget tree. +class SuperTextInspector { + /// Finds and returns the `textScaler` that's applied to the [SuperText]. + /// + /// {@template supertext_finder} + /// By default, this method expects a single [SuperText] in the widget tree and + /// finds it `byType`. To specify one [SuperText] among many, pass a [finder]. + /// {@endtemplate} + static TextScaler findTextScaler([Finder? finder]) { + final element = (finder ?? find.byType(SuperText)).evaluate().single as StatefulElement; + final superText = element.widget as SuperText; + + final renderLayoutAwareRichText = find + .descendant( + of: find.byWidget(superText), + matching: find.byType(LayoutAwareRichText), + ) + .evaluate() + .first + .widget as LayoutAwareRichText; + + return renderLayoutAwareRichText.textScaler; + } +} diff --git a/super_text_layout/lib/src/super_text_layout_with_selection.dart b/super_text_layout/lib/src/super_text_layout_with_selection.dart index 797402d78f..2329f8eb49 100644 --- a/super_text_layout/lib/src/super_text_layout_with_selection.dart +++ b/super_text_layout/lib/src/super_text_layout_with_selection.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import 'package:super_text_layout/src/infrastructure/blink_controller.dart'; import 'package:super_text_layout/super_text_layout_logging.dart'; import 'caret_layer.dart'; @@ -22,6 +23,7 @@ class SuperTextWithSelection extends StatefulWidget { required this.richText, this.textAlign = TextAlign.left, this.textDirection = TextDirection.ltr, + this.textScaler, UserSelection? userSelection, }) : userSelections = userSelection != null ? [userSelection] : const [], super(key: key); @@ -32,6 +34,7 @@ class SuperTextWithSelection extends StatefulWidget { required this.richText, this.textAlign = TextAlign.left, this.textDirection = TextDirection.ltr, + this.textScaler, this.userSelections = const [], }) : super(key: key); @@ -52,6 +55,11 @@ class SuperTextWithSelection extends StatefulWidget { /// A user selection includes a caret and a selection highlight. final List userSelections; + /// The policy for text scaling. + /// + /// Defaults to `MediaQuery.textScalerOf`. + final TextScaler? textScaler; + @override State createState() => _SuperTextWithSelectionState(); } @@ -98,6 +106,7 @@ class _SuperTextWithSelectionState extends ProseTextState> userSelections; @@ -151,6 +162,14 @@ class _RebuildOptimizedSuperTextWithSelectionState extends State<_RebuildOptimiz // the cache so that the full SuperText widget subtree is rebuilt. _cachedSubtree = null; } + + if (widget.textScaler != oldWidget.textScaler) { + buildsLog.fine("Text scaler changed. Invalidating the cached SuperText widget."); + + // The text scaleFactor changed, which means the text layout changed. Invalidate + // the cache so that the full SuperText widget subtree is rebuilt. + _cachedSubtree = null; + } } // The current length of the text displayed by this widget. The value @@ -177,6 +196,7 @@ class _RebuildOptimizedSuperTextWithSelectionState extends State<_RebuildOptimiz key: widget.textLayoutKey, richText: widget.richText, textAlign: widget.textAlign, + textScaler: widget.textScaler, layerBeneathBuilder: _buildLayerBeneath, layerAboveBuilder: _buildLayerAbove, ); @@ -225,7 +245,14 @@ class _RebuildOptimizedSuperTextWithSelectionState extends State<_RebuildOptimiz textLayout: textLayout, style: userSelection.caretStyle, blinkCaret: userSelection.blinkCaret, - position: userSelection.selection.extent, + blinkTimingMode: userSelection.blinkTimingMode, + position: TextPosition( + offset: userSelection.selection.extent.offset, + affinity: TextAffinity.downstream, + ), + // ^ We force downstream, instead of upstream, to reduce the buggyness + // of caret sizing in Flutter when placed near an inline widget. + // Issue: https://github.com/flutter/flutter/issues/159932 caretTracker: userSelection.caretFollower, ), ], @@ -245,6 +272,7 @@ class UserSelection { this.highlightBoundsFollower, this.caretStyle = const CaretStyle(), this.blinkCaret = true, + this.blinkTimingMode = BlinkTimingMode.ticker, this.hasCaret = true, this.caretFollower, }); @@ -276,6 +304,11 @@ class UserSelection { /// Whether the caret should blink. final bool blinkCaret; + /// The timing mechanism used to blink, e.g., `Ticker` or `Timer`. + /// + /// `Timer`s are not expected to work in tests. + final BlinkTimingMode blinkTimingMode; + /// Whether this selection includes the user's caret. /// /// Typically, there is only one caret per user within an entire @@ -296,6 +329,7 @@ class UserSelection { bool? highlightWhenEmpty, LayerLink? highlightBoundsFollower, CaretStyle? caretStyle, + BlinkTimingMode? blinkTimingMode, bool? blinkCaret, bool? hasCaret, LayerLink? caretFollower, @@ -309,6 +343,7 @@ class UserSelection { blinkCaret: blinkCaret ?? this.blinkCaret, hasCaret: hasCaret ?? this.hasCaret, caretFollower: caretFollower ?? this.caretFollower, + blinkTimingMode: blinkTimingMode ?? this.blinkTimingMode, ); } @@ -322,6 +357,7 @@ class UserSelection { highlightWhenEmpty == other.highlightWhenEmpty && highlightBoundsFollower == other.highlightBoundsFollower && caretStyle == other.caretStyle && + blinkTimingMode == other.blinkTimingMode && blinkCaret == other.blinkCaret && hasCaret == other.hasCaret && caretFollower == other.caretFollower; @@ -333,6 +369,7 @@ class UserSelection { highlightWhenEmpty.hashCode ^ highlightBoundsFollower.hashCode ^ caretStyle.hashCode ^ + blinkTimingMode.hashCode ^ blinkCaret.hashCode ^ hasCaret.hashCode ^ caretFollower.hashCode; diff --git a/super_text_layout/lib/src/text_layout.dart b/super_text_layout/lib/src/text_layout.dart index 001cafff33..ce50b45e3e 100644 --- a/super_text_layout/lib/src/text_layout.dart +++ b/super_text_layout/lib/src/text_layout.dart @@ -1,3 +1,4 @@ +import 'dart:ui'; import 'package:flutter/widgets.dart'; import 'super_text.dart'; @@ -14,12 +15,12 @@ abstract class TextLayout { /// Returns the height of the character at the given [position]. double getLineHeightAtPosition(TextPosition position); - /// Returns the estimated line height - /// - /// This is needed because if the text contains only emojis + /// Returns the estimated line height + /// + /// This is needed because if the text contains only emojis /// we can't get a [TextBox] from flutter to determine /// the line height - /// + /// /// WARNING: This method should be called only when absolutely necessary /// and may be removed in the future double get estimatedLineHeight; @@ -47,12 +48,16 @@ abstract class TextLayout { double? getHeightForCaret(TextPosition position); /// Returns a [List] of [TextBox]es that contain the given [selection]. - List getBoxesForSelection(TextSelection selection); + List getBoxesForSelection( + TextSelection selection, { + BoxHeightStyle boxHeightStyle = BoxHeightStyle.tight, + BoxWidthStyle boxWidthStyle = BoxWidthStyle.tight, + }); - /// Returns a bounding [TextBox] for the character at the given [position] or `null` + /// Returns a bounding [TextBox] for the character at the given [position] or `null` /// if a character box couldn't be found. - /// - /// The only situation where this could return null is when the text + /// + /// The only situation where this could return null is when the text /// contains only emojis TextBox? getCharacterBox(TextPosition position); @@ -109,7 +114,7 @@ abstract class TextLayout { /// to query details about the text layout. Rather than re-declare every /// [ProseTextLayout] method and forward the calls, a [ProseTextBlock] /// provides access to the inner [ProseTextLayout], directly. -abstract class ProseTextBlock { +abstract mixin class ProseTextBlock { /// Returns the [ProseTextLayout] that sits within this text block. ProseTextLayout get textLayout; } @@ -190,18 +195,23 @@ class RenderParagraphProseTextLayout implements ProseTextLayout { required RenderLayoutAwareParagraph renderParagraph, }) : _richText = richText, _renderParagraph = renderParagraph { - _textLength = _richText.toPlainText().length; + _plainText = _richText.toPlainText(); + _textLength = _plainText.length; } final InlineSpan _richText; final RenderLayoutAwareParagraph _renderParagraph; late final int _textLength; + late final String _plainText; + + TextScaler get textScaler => _renderParagraph.textScaler; + @override double get estimatedLineHeight { - final fontSize = _richText.style?.fontSize; - final lineHeight = _richText.style?.height; - return (fontSize ?? 16) * (lineHeight ?? 1.0); + final fontSize = _richText.style?.fontSize ?? 16; + final lineHeight = _richText.style?.height ?? 1.0; + return textScaler.scale(fontSize * lineHeight); } @override @@ -246,7 +256,7 @@ class RenderParagraphProseTextLayout implements ProseTextLayout { // If no text is currently displayed, we can't use a character box // to measure, but we may be able to use related metrics. if (_textLength == 0) { - final estimatedLineHeight = _renderParagraph.getFullHeightForCaret(position) ?? _richText.style?.fontSize ?? 0.0; + final estimatedLineHeight = _renderParagraph.getFullHeightForCaret(position); return estimatedLineHeight * lineHeightMultiplier; } @@ -255,8 +265,8 @@ class RenderParagraphProseTextLayout implements ProseTextLayout { final characterBox = getCharacterBox(position); if (characterBox == null) { return estimatedLineHeight; - } - return characterBox.toRect().height * lineHeightMultiplier; + } + return characterBox.toRect().height * lineHeightMultiplier; } @override @@ -288,16 +298,40 @@ class RenderParagraphProseTextLayout implements ProseTextLayout { return null; } + // Temporary solution for an issue where the caret height gets smaller when the text ends with a space + // and the caret sits after the last character. This is caused due to a Flutter bug. + // + // Remove this code once the bug is fixed. + // + // See https://github.com/superlistapp/super_editor/issues/2323 for more details. + if (_plainText.isNotEmpty && // + position.offset == _textLength && + _plainText[_textLength - 1] == ' ') { + // The given position sits at the end of the text and the last character is a space. Use the upstream + // character caret height instead of the one computed for given position (which is smaller than + // it should be, due to the bug). Since the position sits after the last character, the upstream + // character is the space itself. + return _renderParagraph.getFullHeightForCaret(TextPosition(offset: _textLength - 1)); + } + return _renderParagraph.getFullHeightForCaret(position); } @override - List getBoxesForSelection(TextSelection selection) { + List getBoxesForSelection( + TextSelection selection, { + BoxHeightStyle boxHeightStyle = BoxHeightStyle.tight, + BoxWidthStyle boxWidthStyle = BoxWidthStyle.tight, + }) { if (_renderParagraph.needsLayout) { return []; } - return _renderParagraph.getBoxesForSelection(selection); + return _renderParagraph.getBoxesForSelection( + selection, + boxHeightStyle: boxHeightStyle, + boxWidthStyle: boxWidthStyle, + ); } @override @@ -308,7 +342,7 @@ class RenderParagraphProseTextLayout implements ProseTextLayout { final plainText = _richText.toPlainText(); if (plainText.isEmpty) { - final lineHeightEstimate = _renderParagraph.getFullHeightForCaret(const TextPosition(offset: 0)) ?? 0.0; + final lineHeightEstimate = _renderParagraph.getFullHeightForCaret(const TextPosition(offset: 0)); return TextBox.fromLTRBD(0, 0, 0, lineHeightEstimate, TextDirection.ltr); } diff --git a/super_text_layout/lib/src/text_selection_layer.dart b/super_text_layout/lib/src/text_selection_layer.dart index d8d2647e6a..1da37c14e1 100644 --- a/super_text_layout/lib/src/text_selection_layer.dart +++ b/super_text_layout/lib/src/text_selection_layer.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/widgets.dart'; import 'text_layout.dart'; @@ -24,6 +26,7 @@ class TextLayoutSelectionHighlight extends StatelessWidget { painter: TextSelectionPainter( textLayout: textLayout, selectionColor: style.color, + borderRadius: style.borderRadius, textSelection: selection, ), ); @@ -75,8 +78,14 @@ class _EmptyHighlightPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - canvas.drawRect( - Rect.fromLTWH(0, 0, width, height), + canvas.drawRRect( + RRect.fromRectAndCorners( + Rect.fromLTWH(0, 0, width, height), + topLeft: style.borderRadius.topLeft, + topRight: style.borderRadius.topRight, + bottomLeft: style.borderRadius.bottomLeft, + bottomRight: style.borderRadius.bottomRight, + ), Paint()..color = style.color, ); } @@ -132,12 +141,14 @@ class TextSelectionPainter extends CustomPainter { TextSelectionPainter({ required this.textLayout, required this.textSelection, + this.borderRadius = BorderRadius.zero, required this.selectionColor, }) : _selectionPaint = Paint()..color = selectionColor; final TextLayout? textLayout; final TextSelection? textSelection; final Color selectionColor; + final BorderRadius borderRadius; final Paint _selectionPaint; @override @@ -151,16 +162,31 @@ class TextSelectionPainter extends CustomPainter { return; } - final selectionBoxes = textLayout!.getBoxesForSelection(textSelection!); + final selectionBoxes = textLayout!.getBoxesForSelection( + textSelection!, + boxHeightStyle: BoxHeightStyle.max, + ); for (final box in selectionBoxes) { final rawRect = box.toRect(); - final rect = Rect.fromLTWH(rawRect.left, rawRect.top - 2, rawRect.width, rawRect.height + 4); + final rect = Rect.fromLTWH( + rawRect.left, + rawRect.top - selectionHighlightBoxVerticalExpansion, + rawRect.width, + rawRect.height + (selectionHighlightBoxVerticalExpansion * 2), + ); + final rrect = RRect.fromRectAndCorners(rect, + topLeft: borderRadius.topLeft, + topRight: borderRadius.topRight, + bottomLeft: borderRadius.bottomLeft, + bottomRight: borderRadius.bottomRight); - canvas.drawRect( + canvas.drawRRect( // Note: If the rect has no width then we've selected an empty line. Give // that line a slight width for visibility. - rect.width > 0 ? rect : Rect.fromLTWH(rect.left, rect.top, 5, rect.height), + rect.width > 0 + ? rrect + : RRect.fromRectAndRadius(Rect.fromLTWH(rect.left, rect.top, 5, rect.height), Radius.zero), _selectionPaint, ); } @@ -173,3 +199,14 @@ class TextSelectionPainter extends CustomPainter { selectionColor != oldDelegate.selectionColor; } } + +/// How bigger the selection highlight box is than the natural selection box +/// of the text in dip. +/// +/// [TextSelectionPainter] paints the selection highlight box by using the result +/// of [TextLayout.getBoxesForSelection] and expanding both the top and bottom of +/// each box by this amount. +/// +/// This can be used to align other widgets, like the drag handles, with the +/// selection highlight box. +const selectionHighlightBoxVerticalExpansion = 2.0; diff --git a/super_text_layout/lib/src/text_underline_layer.dart b/super_text_layout/lib/src/text_underline_layer.dart new file mode 100644 index 0000000000..2055b019cf --- /dev/null +++ b/super_text_layout/lib/src/text_underline_layer.dart @@ -0,0 +1,333 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; + +import 'text_layout.dart'; + +/// A [SuperText] layer that displays an underline beneath the text within a given +/// selection. +class TextUnderlineLayer extends StatefulWidget { + const TextUnderlineLayer({ + Key? key, + required this.textLayout, + required this.style, + required this.underlines, + }) : super(key: key); + + final TextLayout textLayout; + final UnderlineStyle style; + final List underlines; + + @override + State createState() => TextUnderlineLayerState(); +} + +@visibleForTesting +class TextUnderlineLayerState extends State with TickerProviderStateMixin { + List _computeUnderlineLineSegments() { + final lineSegments = []; + for (final underline in widget.underlines) { + // Convert selection bounding boxes into underline line segments. + final boxes = widget.textLayout.getBoxesForSelection( + TextSelection(baseOffset: underline.range.start, extentOffset: underline.range.end), + boxHeightStyle: BoxHeightStyle.max, + ); + final lineSegmentsForRange = []; + for (final box in boxes) { + lineSegmentsForRange.add( + LineSegment( + Offset(box.left, box.bottom + underline.gap), + Offset(box.right, box.bottom + underline.gap), + ), + ); + } + + lineSegments.addAll(lineSegmentsForRange); + } + + return lineSegments; + } + + @override + Widget build(BuildContext context) { + if (widget.underlines.isEmpty) { + return const SizedBox(); + } + + return CustomPaint( + size: Size.infinite, + painter: widget.style.createPainter(_computeUnderlineLineSegments()), + ); + } +} + +class TextLayoutUnderline { + const TextLayoutUnderline({ + required this.range, + this.gap = 1, + }); + + final TextRange range; + final double gap; +} + +abstract interface class UnderlineStyle { + /// Vertical offset of the underline (positive or negative) from the bottom + /// edge of the text line's bounding box. + /// + /// Negative moves the underline up, closer to the text, and positive moves + /// it down, further away from the text. + /// + /// Nothing prevents the underline from being pulled into the text, or pushed + /// into the line below the text. That responsibility is up to the developer. + double get offset; + + CustomPainter createPainter(List underlines); +} + +class StraightUnderlineStyle implements UnderlineStyle { + const StraightUnderlineStyle({ + this.color = const Color(0xFF000000), + this.thickness = 2, + this.capType = StrokeCap.square, + this.offset = 0, + }); + + final Color color; + final double thickness; + @override + final double offset; + final StrokeCap capType; + + @override + CustomPainter createPainter(List underlines) { + return StraightUnderlinePainter( + underlines: underlines, + color: color, + thickness: thickness, + offset: offset, + capType: capType, + ); + } +} + +class StraightUnderlinePainter extends CustomPainter { + const StraightUnderlinePainter({ + required List underlines, + this.color = const Color(0xFF000000), + this.thickness = 2, + this.offset = 0, + this.capType = StrokeCap.square, + }) : _underlines = underlines; + + final List _underlines; + + final Color color; + final double thickness; + final StrokeCap capType; + final double offset; + + @override + void paint(Canvas canvas, Size size) { + if (_underlines.isEmpty) { + return; + } + + final linePaint = Paint() + ..style = PaintingStyle.stroke + ..color = color + ..strokeWidth = thickness + ..strokeCap = capType; + for (final underline in _underlines) { + canvas.drawLine(underline.start + Offset(0, offset), underline.end + Offset(0, offset), linePaint); + } + } + + @override + bool shouldRepaint(StraightUnderlinePainter oldDelegate) { + return color != oldDelegate.color || + thickness != oldDelegate.thickness || + capType != oldDelegate.capType || + offset != oldDelegate.offset || + !const DeepCollectionEquality().equals(_underlines, oldDelegate._underlines); + } +} + +class DottedUnderlineStyle implements UnderlineStyle { + const DottedUnderlineStyle({ + this.color = const Color(0xFFFF0000), + this.dotDiameter = 2, + this.dotSpace = 1, + this.offset = 0, + }); + + final Color color; + final double dotDiameter; + final double dotSpace; + @override + final double offset; + + @override + CustomPainter createPainter(List underlines) { + return DottedUnderlinePainter( + underlines: underlines, + color: color, + offset: offset, + dotDiameter: dotDiameter, + dotSpace: dotSpace, + ); + } +} + +class DottedUnderlinePainter extends CustomPainter { + const DottedUnderlinePainter({ + required List underlines, + this.color = const Color(0xFFFF0000), + this.offset = 0, + this.dotDiameter = 2, + this.dotSpace = 1, + }) : _underlines = underlines; + + final List _underlines; + + final Color color; + final double dotDiameter; + final double dotSpace; + final double offset; + + @override + void paint(Canvas canvas, Size size) { + if (_underlines.isEmpty) { + return; + } + + final dotPaint = Paint()..color = color; + for (final underline in _underlines) { + final dotCount = ((underline.end.dx - underline.start.dx) / (dotDiameter + dotSpace)).floor(); + + // Draw the dots. + final delta = Offset(dotDiameter + dotSpace, (underline.end.dy - underline.start.dy) / dotCount); + Offset offset = underline.start + Offset(dotDiameter / 2, 0) + Offset(0, this.offset); + for (int i = 0; i < dotCount; i += 1) { + canvas.drawCircle(offset, dotDiameter / 2, dotPaint); + offset = offset + delta; + } + } + } + + @override + bool shouldRepaint(DottedUnderlinePainter oldDelegate) { + return color != oldDelegate.color || + dotDiameter != oldDelegate.dotDiameter || + dotSpace != oldDelegate.dotSpace || + offset != oldDelegate.offset || + !const DeepCollectionEquality().equals(_underlines, oldDelegate._underlines); + } +} + +class SquiggleUnderlineStyle implements UnderlineStyle { + const SquiggleUnderlineStyle({ + this.color = const Color(0xFFFF0000), + this.thickness = 1, + this.offset = 0, + this.jaggedDeltaX = 2, + this.jaggedDeltaY = 2, + }) : assert(jaggedDeltaX > 0, "The squiggle jaggedDeltaX must be > 0"), + assert(jaggedDeltaY > 0, "The squiggle jaggedDeltaY must be > 0"); + + final Color color; + final double thickness; + @override + final double offset; + final double jaggedDeltaX; + final double jaggedDeltaY; + + @override + CustomPainter createPainter(List underlines) { + return SquiggleUnderlinePainter( + underlines: underlines, + color: color, + thickness: thickness, + jaggedDeltaX: jaggedDeltaX, + jaggedDeltaY: jaggedDeltaY, + offset: offset, + ); + } +} + +class SquiggleUnderlinePainter extends CustomPainter { + const SquiggleUnderlinePainter({ + required List underlines, + this.color = const Color(0xFFFF0000), + this.thickness = 1, + this.offset = 0, + this.jaggedDeltaX = 2, + this.jaggedDeltaY = 2, + }) : assert(jaggedDeltaX > 0, "The squiggle jaggedDeltaX must be > 0"), + assert(jaggedDeltaY > 0, "The squiggle jaggedDeltaY must be > 0"), + _underlines = underlines; + + final List _underlines; + + final Color color; + final double thickness; + final double offset; + final double jaggedDeltaX; + final double jaggedDeltaY; + + @override + void paint(Canvas canvas, Size size) { + if (_underlines.isEmpty) { + return; + } + + final delta = Offset(jaggedDeltaX, jaggedDeltaY); + final squigglePaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = thickness; + + for (final underline in _underlines) { + // Draw the squiggle. + Offset offset = underline.start + Offset(delta.dy / 2, 0) + Offset(0, this.offset); + int nextDirection = -1; + while (offset.dx <= underline.end.dx) { + // Calculate the endpoint of this jagged squiggle segment. + final endPoint = offset + Offset(delta.dx, delta.dy * nextDirection); + + // Paint this tiny segment. + canvas.drawLine(offset, endPoint, squigglePaint); + + // Move the next start offset to the previous end offset, and flip direction. + offset = endPoint; + nextDirection = nextDirection * -1; + } + } + } + + @override + bool shouldRepaint(SquiggleUnderlinePainter oldDelegate) { + return color != oldDelegate.color || + thickness != oldDelegate.thickness || + jaggedDeltaX != oldDelegate.jaggedDeltaX || + jaggedDeltaY != oldDelegate.jaggedDeltaY || + offset != oldDelegate.offset || + !const DeepCollectionEquality().equals(_underlines, oldDelegate._underlines); + } +} + +class LineSegment { + const LineSegment(this.start, this.end); + + final Offset start; + final Offset end; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LineSegment && runtimeType == other.runtimeType && start == other.start && end == other.end; + + @override + int get hashCode => start.hashCode ^ end.hashCode; +} diff --git a/super_text_layout/lib/super_text_layout.dart b/super_text_layout/lib/super_text_layout.dart index b93931f50b..ea9366aa56 100644 --- a/super_text_layout/lib/super_text_layout.dart +++ b/super_text_layout/lib/super_text_layout.dart @@ -1,6 +1,8 @@ export 'src/caret_layer.dart'; +export 'src/inline_widgets.dart'; export 'src/super_text.dart'; export 'src/super_text_layout_with_selection.dart'; export 'src/text_layout.dart'; export 'src/text_selection_layer.dart'; +export 'src/text_underline_layer.dart'; export 'src/infrastructure/blink_controller.dart'; diff --git a/super_text_layout/lib/super_text_layout_inspector.dart b/super_text_layout/lib/super_text_layout_inspector.dart new file mode 100644 index 0000000000..af13713f4e --- /dev/null +++ b/super_text_layout/lib/super_text_layout_inspector.dart @@ -0,0 +1 @@ +export 'src/super_text_inspector.dart'; diff --git a/super_text_layout/pubspec.yaml b/super_text_layout/pubspec.yaml index cdc4d9bdc0..1f5dd6f3ec 100644 --- a/super_text_layout/pubspec.yaml +++ b/super_text_layout/pubspec.yaml @@ -1,30 +1,54 @@ name: super_text_layout description: Configurable, composable, extensible text display for Flutter. -version: 0.1.4 +version: 0.1.15 homepage: https://github.com/superlistapp/super_editor +funding: + - https://flutterbountyhunters.com + - https://github.com/sponsors/matthew-carroll +topics: + - rich-text + - text-layout + - text-decorations + - super-editor + - widget +screenshots: + - description: "Custom text highlights." + path: doc/pub/screenshots/text-highlights.png + - description: "Paint selections and carets." + path: doc/pub/screenshots/selection-and-caret.png + - description: "Show widgets near the caret." + path: doc/pub/screenshots/follow-the-caret.png + - description: "Show multiple user selections at the same time." + path: doc/pub/screenshots/multiple-user-selections.png environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" flutter: ">=1.17.0" dependencies: flutter: sdk: flutter - attributed_text: ^0.2.0 - logging: ^1.0.1 + attributed_text: ^0.4.0 + collection: ^1.18.0 + logging: ^1.3.0 -dependency_overrides: - # Override to local mono-repo path so devs can test this repo - # against changes that they're making to other mono-repo packages - attributed_text: - path: ../attributed_text + # For inspector + flutter_test: + sdk: flutter + +#dependency_overrides: +# # Override to local mono-repo path so devs can test this repo +# # against changes that they're making to other mono-repo packages +# attributed_text: +# path: ../attributed_text dev_dependencies: flutter_lints: ^2.0.1 - flutter_test: - sdk: flutter - golden_toolkit: ^0.11.0 + golden_toolkit: ^0.15.0 + args: ^2.3.1 + meta: ^1.8.0 + golden_runner: ^0.2.0 flutter: # no Flutter configuration diff --git a/super_text_layout/test/super_text_test.dart b/super_text_layout/test/super_text_test.dart index 31791427bd..0966a0d882 100644 --- a/super_text_layout/test/super_text_test.dart +++ b/super_text_layout/test/super_text_test.dart @@ -240,7 +240,7 @@ void main() { expect( textLayout.getPositionAtOffset(Offset(textBox.size.width / 2, firstLineEstimatedMiddle)), - const TextPosition(offset: 25, affinity: TextAffinity.upstream), + const TextPosition(offset: 24, affinity: TextAffinity.downstream), ); expect( @@ -363,7 +363,7 @@ void main() { expect( textLayout.getPositionNearestToOffset(Offset(textBox.size.width / 2, -50)), - const TextPosition(offset: 25, affinity: TextAffinity.upstream), + const TextPosition(offset: 24, affinity: TextAffinity.downstream), ); expect( diff --git a/super_text_layout/test_goldens/caret_layer_test.dart b/super_text_layout/test_goldens/caret_layer_test.dart index db1b5cd269..99b154b9b6 100644 --- a/super_text_layout/test_goldens/caret_layer_test.dart +++ b/super_text_layout/test_goldens/caret_layer_test.dart @@ -4,13 +4,14 @@ import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:super_text_layout/super_text_layout.dart'; import 'test_tools.dart'; +import 'test_tools_goldens.dart'; const primaryCaretStyle = CaretStyle(color: Colors.black); void main() { group("Caret layer", () { group("with a single caret", () { - testGoldens("paints a normal caret", (tester) async { + testGoldensOnAndroid("paints a normal caret", (tester) async { await pumpThreeLinePlainSuperText( tester, aboveBuilder: (context, TextLayout textLayout) { @@ -30,7 +31,7 @@ void main() { await screenMatchesGolden(tester, "CaretLayer_single-caret_normal"); }); - testGoldens("paints caret styles", (tester) async { + testGoldensOnAndroid("paints caret styles", (tester) async { await pumpThreeLinePlainSuperText( tester, aboveBuilder: (context, TextLayout textLayout) { @@ -56,7 +57,7 @@ void main() { }); group("with multiple carets", () { - testGoldens("paints multiple carets", (tester) async { + testGoldensOnAndroid("paints multiple carets", (tester) async { await pumpThreeLinePlainSuperText( tester, aboveBuilder: (context, TextLayout textLayout) { @@ -88,7 +89,7 @@ void main() { await screenMatchesGolden(tester, "CaretLayer_multi-caret"); }); - testGoldens("paints two carets at the same position", (tester) async { + testGoldensOnAndroid("paints two carets at the same position", (tester) async { await pumpThreeLinePlainSuperText( tester, aboveBuilder: (context, TextLayout textLayout) { diff --git a/super_text_layout/test_goldens/goldens/CaretLayer_multi-caret.png b/super_text_layout/test_goldens/goldens/CaretLayer_multi-caret.png index 3d87bdeb4c..7904e80d50 100644 Binary files a/super_text_layout/test_goldens/goldens/CaretLayer_multi-caret.png and b/super_text_layout/test_goldens/goldens/CaretLayer_multi-caret.png differ diff --git a/super_text_layout/test_goldens/goldens/CaretLayer_single-caret_decorated.png b/super_text_layout/test_goldens/goldens/CaretLayer_single-caret_decorated.png index 386bae0668..02eea2b1db 100644 Binary files a/super_text_layout/test_goldens/goldens/CaretLayer_single-caret_decorated.png and b/super_text_layout/test_goldens/goldens/CaretLayer_single-caret_decorated.png differ diff --git a/super_text_layout/test_goldens/goldens/CaretLayer_single-caret_normal.png b/super_text_layout/test_goldens/goldens/CaretLayer_single-caret_normal.png index 621d64da98..e1ee9a1133 100644 Binary files a/super_text_layout/test_goldens/goldens/CaretLayer_single-caret_normal.png and b/super_text_layout/test_goldens/goldens/CaretLayer_single-caret_normal.png differ diff --git a/super_text_layout/test_goldens/goldens/CaretLayer_two-carets-same-position.png b/super_text_layout/test_goldens/goldens/CaretLayer_two-carets-same-position.png index 8ba92163a9..6cbb8d1b1b 100644 Binary files a/super_text_layout/test_goldens/goldens/CaretLayer_two-carets-same-position.png and b/super_text_layout/test_goldens/goldens/CaretLayer_two-carets-same-position.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-alignment.png b/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-alignment.png new file mode 100644 index 0000000000..fa272d289b Binary files /dev/null and b/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-alignment.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-sizing.png b/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-sizing.png new file mode 100644 index 0000000000..36d14a717f Binary files /dev/null and b/super_text_layout/test_goldens/goldens/SuperText-inline-widgets-sizing.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText-max-lines.png b/super_text_layout/test_goldens/goldens/SuperText-max-lines.png new file mode 100644 index 0000000000..c4ab1750ae Binary files /dev/null and b/super_text_layout/test_goldens/goldens/SuperText-max-lines.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText-reference-render.png b/super_text_layout/test_goldens/goldens/SuperText-reference-render.png index dadd76e696..3386771d62 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText-reference-render.png and b/super_text_layout/test_goldens/goldens/SuperText-reference-render.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText-text-scale-factor.png b/super_text_layout/test_goldens/goldens/SuperText-text-scale-factor.png new file mode 100644 index 0000000000..88cdac889b Binary files /dev/null and b/super_text_layout/test_goldens/goldens/SuperText-text-scale-factor.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText_layers_caret.png b/super_text_layout/test_goldens/goldens/SuperText_layers_caret.png index a605eca4c6..988ac1a6df 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText_layers_caret.png and b/super_text_layout/test_goldens/goldens/SuperText_layers_caret.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText_layers_character-box-outlines.png b/super_text_layout/test_goldens/goldens/SuperText_layers_character-box-outlines.png index 1187b1c64c..56afb108b3 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText_layers_character-box-outlines.png and b/super_text_layout/test_goldens/goldens/SuperText_layers_character-box-outlines.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText_layers_character-boxes.png b/super_text_layout/test_goldens/goldens/SuperText_layers_character-boxes.png index f6085c55eb..97c60aca14 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText_layers_character-boxes.png and b/super_text_layout/test_goldens/goldens/SuperText_layers_character-boxes.png differ diff --git a/super_text_layout/test_goldens/goldens/SuperText_layers_line-boxes.png b/super_text_layout/test_goldens/goldens/SuperText_layers_line-boxes.png index f2b358ca42..ab4740881b 100644 Binary files a/super_text_layout/test_goldens/goldens/SuperText_layers_line-boxes.png and b/super_text_layout/test_goldens/goldens/SuperText_layers_line-boxes.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection-border-radius.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection-border-radius.png new file mode 100644 index 0000000000..9ea64f69d2 Binary files /dev/null and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection-border-radius.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection.png index 6be5ddd1ed..9b361b517c 100644 Binary files a/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection.png and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_full-selection.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_no-selection-when-empty-border-radius.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_no-selection-when-empty-border-radius.png new file mode 100644 index 0000000000..322dbe3036 Binary files /dev/null and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_no-selection-when-empty-border-radius.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_no-selection-when-empty.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_no-selection-when-empty.png index b61584de03..322dbe3036 100644 Binary files a/super_text_layout/test_goldens/goldens/TextSelectionLayer_no-selection-when-empty.png and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_no-selection-when-empty.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection-border-radius.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection-border-radius.png new file mode 100644 index 0000000000..bf1a20fa58 Binary files /dev/null and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection-border-radius.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection.png index c3f349f7dd..a7ade936ee 100644 Binary files a/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection.png and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_partial-selection.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_small-highlight-when-empty-border-radius.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_small-highlight-when-empty-border-radius.png new file mode 100644 index 0000000000..a41ef4c20c Binary files /dev/null and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_small-highlight-when-empty-border-radius.png differ diff --git a/super_text_layout/test_goldens/goldens/TextSelectionLayer_small-highlight-when-empty.png b/super_text_layout/test_goldens/goldens/TextSelectionLayer_small-highlight-when-empty.png index 5f3e797e4f..dd84abd0c7 100644 Binary files a/super_text_layout/test_goldens/goldens/TextSelectionLayer_small-highlight-when-empty.png and b/super_text_layout/test_goldens/goldens/TextSelectionLayer_small-highlight-when-empty.png differ diff --git a/super_text_layout/test_goldens/goldens/TextUnderlineLayer_paints-underline.png b/super_text_layout/test_goldens/goldens/TextUnderlineLayer_paints-underline.png new file mode 100644 index 0000000000..675b813036 Binary files /dev/null and b/super_text_layout/test_goldens/goldens/TextUnderlineLayer_paints-underline.png differ diff --git a/super_text_layout/test_goldens/inline_widgets_test.dart b/super_text_layout/test_goldens/inline_widgets_test.dart new file mode 100644 index 0000000000..0a07c8e291 --- /dev/null +++ b/super_text_layout/test_goldens/inline_widgets_test.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_text_layout/super_text_layout.dart'; + +import 'test_tools_goldens.dart'; + +void main() { + group("SuperText inline widgets >", () { + testGoldensOnAndroid("vertical alignments", (tester) async { + await tester.pumpWidget( + _buildScaffold( + // ignore: prefer_const_constructors + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SuperTextWithSelection.single( + richText: _allAlignmentsWithText, + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 69), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: _allAlignmentsNoText, + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 6), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: _allAlignmentsMultipleSizesSmallToLarge, + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 42), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: _allAlignmentsMultipleSizesLargeToSmall, + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 42), + ), + ), + ], + ), + ), + ); + + await screenMatchesGolden(tester, "SuperText-inline-widgets-alignment"); + }); + + testGoldensOnAndroid("sizing", (tester) async { + // This test demonstrates the mechanism that we can use to make + // inline widgets the same height as the surrounding text (assuming + // the surrounding text uses the same text style). + final textPainter12 = TextPainter( + text: TextSpan( + text: 'a', + style: _testTextStyle.copyWith(fontSize: 12), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final textPainter18 = TextPainter( + text: TextSpan( + text: 'a', + style: _testTextStyle.copyWith(fontSize: 18), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final textPainter32 = TextPainter( + text: TextSpan( + text: 'a', + style: _testTextStyle.copyWith(fontSize: 32), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final textPainter64 = TextPainter( + text: TextSpan( + text: 'a', + style: _testTextStyle.copyWith(fontSize: 64), + ), + textDirection: TextDirection.ltr, + )..layout(); + + await tester.pumpWidget( + _buildScaffold( + // ignore: prefer_const_constructors + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SuperTextWithSelection.single( + richText: TextSpan( + text: '', + children: [ + const TextSpan(text: 'Hello '), + WidgetSpan( + child: _inlineSquare(textPainter12.height), + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic), + const TextSpan(text: 'World!'), + ], + style: _testTextStyle.copyWith( + fontSize: 12, + ), + ), + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 69), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: TextSpan( + text: '', + children: [ + const TextSpan(text: 'Hello '), + WidgetSpan( + child: _inlineSquare(textPainter18.height), + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic), + const TextSpan(text: 'World!'), + ], + style: _testTextStyle.copyWith( + fontSize: 18, + ), + ), + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 69), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: TextSpan( + text: '', + children: [ + const TextSpan(text: 'Hello '), + WidgetSpan( + child: _inlineSquare(textPainter32.height), + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic), + const TextSpan(text: 'World!'), + ], + style: _testTextStyle.copyWith( + fontSize: 32, + ), + ), + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 69), + ), + ), + const SizedBox(height: 24), + SuperTextWithSelection.single( + richText: TextSpan( + text: '', + children: [ + const TextSpan(text: 'Hello '), + WidgetSpan( + child: _inlineSquare(textPainter64.height), + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.alphabetic), + const TextSpan(text: 'World!'), + ], + style: _testTextStyle.copyWith( + fontSize: 64, + ), + ), + userSelection: const UserSelection( + selection: TextSelection(baseOffset: 0, extentOffset: 69), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + + await screenMatchesGolden(tester, "SuperText-inline-widgets-sizing"); + }); + }); +} + +final _allAlignmentsWithText = TextSpan( + text: "", + children: [ + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.top), + const TextSpan( + text: "< Top", + ), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.middle), + const TextSpan( + text: "< Middle", + ), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.bottom), + const TextSpan( + text: "< Bottom", + ), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "< Above Baseline", + ), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "< Baseline", + ), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "< Below Baseline", + ), + ], + style: _testTextStyle, +); + +final _allAlignmentsNoText = TextSpan( + text: "", + children: [ + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.bottom), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + ], + style: _testTextStyle, +); + +final _allAlignmentsMultipleSizesSmallToLarge = TextSpan( + text: "", + children: [ + // Thin + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.bottom), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "Hello World!", + ), + // ~Height of text + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.bottom), + WidgetSpan( + child: _inlineBlock(20), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan( + child: _inlineBlock(20), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "Hello World!", + ), + // Taller than text + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.bottom), + WidgetSpan( + child: _inlineBlock(40), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan( + child: _inlineBlock(40), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + ], + style: _testTextStyle, +); + +final _allAlignmentsMultipleSizesLargeToSmall = TextSpan( + text: "", + children: [ + // Taller than text + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.bottom), + WidgetSpan( + child: _inlineBlock(40), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(40), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan( + child: _inlineBlock(40), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "Hello World!", + ), + // ~Height of text + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.bottom), + WidgetSpan( + child: _inlineBlock(20), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(20), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan( + child: _inlineBlock(20), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + const TextSpan( + text: "Hello World!", + ), + // Thin + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.top), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.middle), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.bottom), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.aboveBaseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic), + WidgetSpan(child: _inlineBlock(), alignment: PlaceholderAlignment.belowBaseline, baseline: TextBaseline.alphabetic), + ], + style: _testTextStyle, +); + +Widget _inlineBlock([double height = 4]) => Container( + width: 24, + height: height, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: Colors.black, + ); + +Widget _inlineSquare([double height = 4]) => Container( + height: height, + margin: const EdgeInsets.symmetric(horizontal: 4), + child: const AspectRatio( + aspectRatio: 1.0, + child: ColoredBox(color: Colors.black), + ), + ); + +const _testTextStyle = TextStyle( + color: Color(0xFF000000), + fontFamily: 'Roboto', + fontSize: 20, +); + +Widget _buildScaffold({ + required Widget child, +}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: child, + ), + ), + debugShowCheckedModeBanner: false, + ); +} diff --git a/super_text_layout/test_goldens/super_text_layers_test.dart b/super_text_layout/test_goldens/super_text_layers_test.dart index 3266c0a422..004d8d43b6 100644 --- a/super_text_layout/test_goldens/super_text_layers_test.dart +++ b/super_text_layout/test_goldens/super_text_layers_test.dart @@ -3,11 +3,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:golden_toolkit/golden_toolkit.dart'; import 'test_tools.dart'; +import 'test_tools_goldens.dart'; void main() { group("SuperText", () { group("builds layers", () { - testGoldens("that can paint line boxes", (tester) async { + testGoldensOnAndroid("that can paint line boxes", (tester) async { await pumpThreeLinePlainSuperText(tester, beneathBuilder: (context, textLayout) { final lineCount = textLayout.getLineCount(); final lineRects = []; @@ -42,7 +43,7 @@ void main() { await screenMatchesGolden(tester, "SuperText_layers_line-boxes"); }); - testGoldens("that can paint character boxes", (tester) async { + testGoldensOnAndroid("that can paint character boxes", (tester) async { await pumpThreeLinePlainSuperText(tester, beneathBuilder: (context, textLayout) { final characterRects = []; final characterColors = []; @@ -70,7 +71,7 @@ void main() { await screenMatchesGolden(tester, "SuperText_layers_character-boxes"); }); - testGoldens("that can paint character box outlines", (tester) async { + testGoldensOnAndroid("that can paint character box outlines", (tester) async { await pumpThreeLinePlainSuperText(tester, beneathBuilder: (context, textLayout) { final characterRects = []; @@ -98,7 +99,7 @@ void main() { await screenMatchesGolden(tester, "SuperText_layers_character-box-outlines"); }); - testGoldens("that can paint carets", (tester) async { + testGoldensOnAndroid("that can paint carets", (tester) async { await pumpThreeLinePlainSuperText(tester, beneathBuilder: (context, textLayout) { const textPosition = TextPosition(offset: 115); final caretOffset = textLayout.getOffsetForCaret(textPosition); diff --git a/super_text_layout/test_goldens/super_text_test.dart b/super_text_layout/test_goldens/super_text_test.dart index 1f2cc3e664..590492766d 100644 --- a/super_text_layout/test_goldens/super_text_test.dart +++ b/super_text_layout/test_goldens/super_text_test.dart @@ -3,13 +3,73 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:super_text_layout/super_text_layout.dart'; +import 'test_tools_goldens.dart'; + void main() { group("SuperText", () { group("text layout", () { - testGoldens("renders a visual reference for non-visual tests", (tester) async { + testGoldensOnAndroid("renders a visual reference for non-visual tests", (tester) async { await _pumpThreeLinePlainText(tester); await screenMatchesGolden(tester, "SuperText-reference-render"); }); + + testGoldensOnAndroid("applies textScaleFactor", (tester) async { + await tester.pumpWidget( + _buildScaffold( + // ignore: prefer_const_constructors + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: const [ + Expanded( + child: SuperText( + richText: _threeLineSpan, + textScaler: TextScaler.noScaling, + ), + ), + Expanded( + child: SuperText( + richText: _threeLineSpan, + textScaler: TextScaler.linear(2.0), + ), + ), + ], + ), + ), + ); + + await screenMatchesGolden(tester, "SuperText-text-scale-factor"); + }); + + testGoldensOnAndroid("respects max lines and overflow", (tester) async { + await tester.pumpWidget( + _buildScaffold( + child: const Padding( + padding: EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 24, + children: [ + SuperText( + richText: _threeLineSpan, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SuperText( + richText: _threeLineSpan, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + SuperText( + richText: _threeLineSpan, + ), + ], + ), + ), + ), + ); + + await screenMatchesGolden(tester, "SuperText-max-lines"); + }); }); }); } @@ -49,5 +109,6 @@ Widget _buildScaffold({ child: child, ), ), + debugShowCheckedModeBanner: false, ); } diff --git a/super_text_layout/test_goldens/test_tools_goldens.dart b/super_text_layout/test_goldens/test_tools_goldens.dart new file mode 100644 index 0000000000..b3015100ac --- /dev/null +++ b/super_text_layout/test_goldens/test_tools_goldens.dart @@ -0,0 +1,94 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:meta/meta.dart'; + +/// A golden test that configures itself as a Android platform before executing the +/// given [test], and nullifies the Android configuration when the test is done. +@isTest +void testGoldensOnAndroid( + String description, + WidgetTesterCallback test, { + bool skip = false, +}) { + testGoldens(description, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: skip); +} + +/// A golden test that configures itself as a iOS platform before executing the +/// given [test], and nullifies the iOS configuration when the test is done. +@isTest +void testGoldensOniOS( + String description, + WidgetTesterCallback test, { + bool skip = false, +}) { + testGoldens(description, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: skip); +} + +/// A golden test that configures itself as a Mac platform before executing the +/// given [test], and nullifies the Mac configuration when the test is done. +@isTest +void testGoldensOnMac( + String description, + WidgetTesterCallback test, { + bool skip = false, +}) { + testGoldens(description, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: skip); +} + +/// A golden test that configures itself as a Windows platform before executing the +/// given [test], and nullifies the Windows configuration when the test is done. +@isTest +void testGoldensOnWindows( + String description, + WidgetTesterCallback test, { + bool skip = false, +}) { + testGoldens(description, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: skip); +} + +/// A golden test that configures itself as a Linux platform before executing the +/// given [test], and nullifies the Linux configuration when the test is done. +@isTest +void testGoldensOnLinux( + String description, + WidgetTesterCallback test, { + bool skip = false, +}) { + testGoldens(description, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + try { + await test(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, skip: skip); +} diff --git a/super_text_layout/test_goldens/text_selection_layer_test.dart b/super_text_layout/test_goldens/text_selection_layer_test.dart index b9bca61032..60f5cfda53 100644 --- a/super_text_layout/test_goldens/text_selection_layer_test.dart +++ b/super_text_layout/test_goldens/text_selection_layer_test.dart @@ -4,6 +4,7 @@ import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:super_text_layout/super_text_layout.dart'; import 'test_tools.dart'; +import 'test_tools_goldens.dart'; void main() { group("Text selection layer", () { @@ -11,7 +12,7 @@ void main() { color: defaultSelectionColor, ); - testGoldens("paints a full text selection", (tester) async { + testGoldensOnAndroid("paints a full text selection", (tester) async { await pumpThreeLinePlainSuperText( tester, beneathBuilder: (context, textLayout) { @@ -29,7 +30,7 @@ void main() { await screenMatchesGolden(tester, "TextSelectionLayer_full-selection"); }); - testGoldens("paints a partial text selection", (tester) async { + testGoldensOnAndroid("paints a partial text selection", (tester) async { await pumpThreeLinePlainSuperText( tester, beneathBuilder: (context, textLayout) { @@ -47,7 +48,7 @@ void main() { await screenMatchesGolden(tester, "TextSelectionLayer_partial-selection"); }); - testGoldens("paints an empty highlight when text is empty", (tester) async { + testGoldensOnAndroid("paints an empty highlight when text is empty", (tester) async { await pumpEmptySuperText( tester, beneathBuilder: (context, textLayout) { @@ -61,7 +62,7 @@ void main() { await screenMatchesGolden(tester, "TextSelectionLayer_small-highlight-when-empty"); }); - testGoldens("paints no selection when text is empty", (tester) async { + testGoldensOnAndroid("paints no selection when text is empty", (tester) async { await pumpEmptySuperText( tester, beneathBuilder: (context, textLayout) { @@ -76,4 +77,76 @@ void main() { await screenMatchesGolden(tester, "TextSelectionLayer_no-selection-when-empty"); }); }); + + group("Text selection layer with BorderRadius", () { + final selectionStyle = SelectionHighlightStyle( + color: defaultSelectionColor, + borderRadius: BorderRadius.circular(10), + ); + + testGoldensOnAndroid("paints a full text selection", (tester) async { + await pumpThreeLinePlainSuperText( + tester, + beneathBuilder: (context, textLayout) { + return TextLayoutSelectionHighlight( + textLayout: textLayout, + style: selectionStyle, + selection: TextSelection( + baseOffset: 0, + extentOffset: threeLineTextSpan.toPlainText().length, + ), + ); + }, + ); + + await screenMatchesGolden(tester, "TextSelectionLayer_full-selection-border-radius"); + }); + + testGoldensOnAndroid("paints a partial text selection", (tester) async { + await pumpThreeLinePlainSuperText( + tester, + beneathBuilder: (context, textLayout) { + return TextLayoutSelectionHighlight( + textLayout: textLayout, + style: selectionStyle, + selection: const TextSelection( + baseOffset: 35, + extentOffset: 80, + ), + ); + }, + ); + + await screenMatchesGolden(tester, "TextSelectionLayer_partial-selection-border-radius"); + }); + + testGoldensOnAndroid("paints an empty highlight when text is empty", (tester) async { + await pumpEmptySuperText( + tester, + beneathBuilder: (context, textLayout) { + return TextLayoutEmptyHighlight( + textLayout: textLayout, + style: selectionStyle, + ); + }, + ); + + await screenMatchesGolden(tester, "TextSelectionLayer_small-highlight-when-empty-border-radius"); + }); + + testGoldensOnAndroid("paints no selection when text is empty", (tester) async { + await pumpEmptySuperText( + tester, + beneathBuilder: (context, textLayout) { + return TextLayoutSelectionHighlight( + textLayout: textLayout, + style: selectionStyle, + selection: null, + ); + }, + ); + + await screenMatchesGolden(tester, "TextSelectionLayer_no-selection-when-empty-border-radius"); + }); + }); } diff --git a/super_text_layout/test_goldens/text_underline_layer_test.dart b/super_text_layout/test_goldens/text_underline_layer_test.dart new file mode 100644 index 0000000000..20455b4546 --- /dev/null +++ b/super_text_layout/test_goldens/text_underline_layer_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:super_text_layout/src/text_underline_layer.dart'; + +import 'test_tools.dart'; +import 'test_tools_goldens.dart'; + +void main() { + testGoldensOnAndroid("Underline layer paints an underline", (tester) async { + await pumpThreeLinePlainSuperText( + tester, + beneathBuilder: (context, textLayout) { + return TextUnderlineLayer( + textLayout: textLayout, + style: const StraightUnderlineStyle( + color: Colors.lightBlue, + thickness: 4, + ), + underlines: const [ + TextLayoutUnderline( + range: TextSelection( + baseOffset: 36, + extentOffset: 79, + ), + ), + TextLayoutUnderline( + range: TextSelection( + baseOffset: 88, + extentOffset: 110, + ), + ), + ], + ); + }, + ); + + await screenMatchesGolden(tester, "TextUnderlineLayer_paints-underline"); + }); +} diff --git a/website/analysis_options.yaml b/website/analysis_options.yaml index 26128b951e..55309e9e03 100644 --- a/website/analysis_options.yaml +++ b/website/analysis_options.yaml @@ -1 +1 @@ -include: package:lint/analysis_options.yaml \ No newline at end of file +include: package:lint/analysis_options.yaml diff --git a/website/lib/homepage/call_to_action.dart b/website/lib/homepage/call_to_action.dart index 3d3a7a25a3..b3c716eef1 100644 --- a/website/lib/homepage/call_to_action.dart +++ b/website/lib/homepage/call_to_action.dart @@ -43,8 +43,10 @@ class _DocumentationButton extends StatelessWidget { return MaterialButton( color: const Color(0xFFFAE74F), - onPressed: () => launch( - 'https://github.com/superlistapp/super_editor/blob/main/super_editor/README.md', + onPressed: () => launchUrl( + Uri.parse( + 'https://github.com/superlistapp/super_editor/blob/main/super_editor/README.md', + ), ), padding: singleColumnLayout ? const EdgeInsets.symmetric(horizontal: 32, vertical: 20) diff --git a/website/lib/homepage/editor_toolbar.dart b/website/lib/homepage/editor_toolbar.dart index bdab5ba948..3e382fc0ba 100644 --- a/website/lib/homepage/editor_toolbar.dart +++ b/website/lib/homepage/editor_toolbar.dart @@ -1,7 +1,10 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:follow_the_leader/follow_the_leader.dart'; +import 'package:overlord/follow_the_leader.dart'; import 'package:super_editor/super_editor.dart'; +import 'package:website/infrastructure/super_editor_item_selector.dart'; /// Small toolbar that is intended to display near some selected /// text and offer a few text formatting controls. @@ -12,25 +15,36 @@ import 'package:super_editor/super_editor.dart'; /// application [Overlay]. Any other [Stack] should work, too. class EditorToolbar extends StatefulWidget { const EditorToolbar({ - Key? key, - required this.anchor, + super.key, + required this.editorViewportKey, + required this.editorFocusNode, required this.editor, + required this.document, required this.composer, + required this.anchor, required this.closeToolbar, - }) : super(key: key); + }); + + /// [GlobalKey] that should be attached to a widget that wraps the viewport + /// area, which keeps the toolbar from appearing outside of the editor area. + final GlobalKey editorViewportKey; - /// [EditorToolbar] displays itself horizontally centered and - /// slightly above the given [anchor] value. + /// A [LeaderLink] that should be attached to the boundary of the toolbar + /// focal area, such as wrapped around the user's selection area. /// - /// [anchor] is a [ValueNotifier] so that [EditorToolbar] can - /// reposition itself as the [Offset] value changes. - final ValueNotifier anchor; + /// The toolbar is positioned relative to this anchor link. + final LeaderLink anchor; + + /// The [FocusNode] attached to the editor to which this toolbar applies. + final FocusNode editorFocusNode; /// The [editor] is used to alter document content, such as /// when the user selects a different block format for a /// text blob, e.g., paragraph, header, blockquote, or /// to apply styles to text. - final DocumentEditor editor; + final Editor? editor; + + final Document document; /// The [composer] provides access to the user's current /// selection within the document, which dictates the @@ -43,25 +57,48 @@ class EditorToolbar extends StatefulWidget { final VoidCallback closeToolbar; @override - _EditorToolbarState createState() => _EditorToolbarState(); + State createState() => _EditorToolbarState(); } class _EditorToolbarState extends State { + late final FollowerAligner _toolbarAligner; + late FollowerBoundary _screenBoundary; + bool _showUrlField = false; - late final FocusNode _urlFocusNode; - late final TextEditingController _urlController; + late FocusNode _popoverFocusNode; + late FocusNode _urlFocusNode; + ImeAttributedTextEditingController? _urlController; @override void initState() { super.initState(); + + _toolbarAligner = CupertinoPopoverToolbarAligner(); + + _popoverFocusNode = FocusNode(); + _urlFocusNode = FocusNode(); - _urlController = TextEditingController(); + _urlController = + ImeAttributedTextEditingController(controller: SingleLineAttributedTextEditingController(_applyLink)) // + ..onPerformActionPressed = _onPerformAction + ..text = AttributedText("https://"); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _screenBoundary = WidgetFollowerBoundary( + boundaryKey: widget.editorViewportKey, + ); } @override void dispose() { _urlFocusNode.dispose(); - _urlController.dispose(); + _urlController!.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); } @@ -70,12 +107,12 @@ class _EditorToolbarState extends State { /// multiple nodes are selected, no node is selected, or the selected /// node is not a standard text block. bool _isConvertibleNode() { - final selection = widget.composer.selection; - if (selection == null || selection.base.nodeId != selection.extent.nodeId) { + final selection = widget.composer.selection!; + if (selection.base.nodeId != selection.extent.nodeId) { return false; } - final selectedNode = widget.editor.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.document.getNodeById(selection.extent.nodeId); return selectedNode is ParagraphNode || selectedNode is ListItemNode; } @@ -83,10 +120,9 @@ class _EditorToolbarState extends State { /// /// Throws an exception if the currently selected node is not a text node. _TextType _getCurrentTextType() { - final selection = widget.composer.selection!; - final selectedNode = widget.editor.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); if (selectedNode is ParagraphNode) { - final type = selectedNode.metadata['blockType']; + final type = selectedNode.getMetadataValue('blockType'); if (type == header1Attribution) { return _TextType.header1; @@ -102,7 +138,7 @@ class _EditorToolbarState extends State { } else if (selectedNode is ListItemNode) { return selectedNode.type == ListItemType.ordered ? _TextType.orderedListItem : _TextType.unorderedListItem; } else { - throw Exception('Invalid node type: $selectedNode'); + throw Exception('Alignment does not apply to node of type: $selectedNode'); } } @@ -110,10 +146,9 @@ class _EditorToolbarState extends State { /// /// Throws an exception if the currently selected node is not a text node. TextAlign _getCurrentTextAlignment() { - final selection = widget.composer.selection!; - final selectedNode = widget.editor.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.document.getNodeById(widget.composer.selection!.extent.nodeId); if (selectedNode is ParagraphNode) { - final align = selectedNode.metadata['textAlign'] as String?; + final align = selectedNode.getMetadataValue('textAlign'); switch (align) { case 'left': return TextAlign.left; @@ -127,19 +162,19 @@ class _EditorToolbarState extends State { return TextAlign.left; } } else { - throw Exception('Alignment does not apply to node of type: $selectedNode'); + throw Exception('Invalid node type: $selectedNode'); } } /// Returns true if a single text node is selected and that text node /// is capable of respecting alignment, returns false otherwise. bool _isTextAlignable() { - final selection = widget.composer.selection; - if (selection == null || selection.base.nodeId != selection.extent.nodeId) { + final selection = widget.composer.selection!; + if (selection.base.nodeId != selection.extent.nodeId) { return false; } - final selectedNode = widget.editor.document.getNodeById(selection.extent.nodeId); + final selectedNode = widget.document.getNodeById(selection.extent.nodeId); return selectedNode is ParagraphNode; } @@ -149,10 +184,6 @@ class _EditorToolbarState extends State { /// For example: convert a paragraph to a blockquote, or a header /// to a list item. void _convertTextToNewType(_TextType? newType) { - if (newType == null) { - return; - } - final existingTextType = _getCurrentTextType(); if (existingTextType == newType) { @@ -161,52 +192,48 @@ class _EditorToolbarState extends State { } if (_isListItem(existingTextType) && _isListItem(newType)) { - widget.editor.executeCommand( - ChangeListItemTypeCommand( + widget.editor!.execute([ + ChangeListItemTypeRequest( nodeId: widget.composer.selection!.extent.nodeId, newType: newType == _TextType.orderedListItem ? ListItemType.ordered : ListItemType.unordered, ), - ); + ]); } else if (_isListItem(existingTextType) && !_isListItem(newType)) { - widget.editor.executeCommand( - ConvertListItemToParagraphCommand( + widget.editor!.execute([ + ConvertListItemToParagraphRequest( nodeId: widget.composer.selection!.extent.nodeId, paragraphMetadata: { 'blockType': _getBlockTypeAttribution(newType), }, ), - ); + ]); } else if (!_isListItem(existingTextType) && _isListItem(newType)) { - widget.editor.executeCommand( - ConvertParagraphToListItemCommand( + widget.editor!.execute([ + ConvertParagraphToListItemRequest( nodeId: widget.composer.selection!.extent.nodeId, type: newType == _TextType.orderedListItem ? ListItemType.ordered : ListItemType.unordered, ), - ); + ]); } else { // Apply a new block type to an existing paragraph node. - final existingNode = widget.editor.document.getNodeById(widget.composer.selection!.extent.nodeId)!; - (existingNode as ParagraphNode).metadata['blockType'] = _getBlockTypeAttribution(newType); - - // Merely changing the blockType of the ParagraphNode does not trigger any of the document listeners. - // - // As such, we have to manually tell the node that something changed - otherwise, the block type of this - // ParagraphNode would change only if something else in the document changed. This is a bit hacky. - // - // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - existingNode.notifyListeners(); + widget.editor!.execute([ + ChangeParagraphBlockTypeRequest( + nodeId: widget.composer.selection!.extent.nodeId, + blockType: _getBlockTypeAttribution(newType), + ), + ]); } } /// Returns true if the given [_TextType] represents an /// ordered or unordered list item, returns false otherwise. - bool _isListItem(_TextType type) { + bool _isListItem(_TextType? type) { return type == _TextType.orderedListItem || type == _TextType.unorderedListItem; } /// Returns the text [Attribution] associated with the given /// [_TextType], e.g., [_TextType.header1] -> [header1Attribution]. - Attribution? _getBlockTypeAttribution(_TextType newType) { + Attribution? _getBlockTypeAttribution(_TextType? newType) { switch (newType) { case _TextType.header1: return header1Attribution; @@ -224,32 +251,32 @@ class _EditorToolbarState extends State { /// Toggles bold styling for the current selected text. void _toggleBold() { - widget.editor.executeCommand( - ToggleTextAttributionsCommand( - documentSelection: widget.composer.selection!, + widget.editor!.execute([ + ToggleTextAttributionsRequest( + documentRange: widget.composer.selection!, attributions: {boldAttribution}, ), - ); + ]); } /// Toggles italic styling for the current selected text. void _toggleItalics() { - widget.editor.executeCommand( - ToggleTextAttributionsCommand( - documentSelection: widget.composer.selection!, + widget.editor!.execute([ + ToggleTextAttributionsRequest( + documentRange: widget.composer.selection!, attributions: {italicsAttribution}, ), - ); + ]); } /// Toggles strikethrough styling for the current selected text. void _toggleStrikethrough() { - widget.editor.executeCommand( - ToggleTextAttributionsCommand( - documentSelection: widget.composer.selection!, + widget.editor!.execute([ + ToggleTextAttributionsRequest( + documentRange: widget.composer.selection!, attributions: {strikethroughAttribution}, ), - ); + ]); } /// Returns true if the current text selection includes part @@ -268,14 +295,14 @@ class _EditorToolbarState extends State { /// Returns any link-based [AttributionSpan]s that appear partially /// or wholly within the current text selection. Set _getSelectedLinkSpans() { - final selection = widget.composer.selection; - final baseOffset = (selection!.base.nodePosition as TextPosition).offset; + final selection = widget.composer.selection!; + final baseOffset = (selection.base.nodePosition as TextPosition).offset; final extentOffset = (selection.extent.nodePosition as TextPosition).offset; final selectionStart = min(baseOffset, extentOffset); final selectionEnd = max(baseOffset, extentOffset); - final selectionRange = SpanRange(start: selectionStart, end: selectionEnd - 1); + final selectionRange = SpanRange(selectionStart, selectionEnd - 1); - final textNode = widget.editor.document.getNodeById(selection.extent.nodeId)! as TextNode; + final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; final text = textNode.text; final overlappingLinkAttributions = text.getAttributionSpansInRange( @@ -289,14 +316,14 @@ class _EditorToolbarState extends State { /// Takes appropriate action when the toolbar's link button is /// pressed. void _onLinkPressed() { - final selection = widget.composer.selection; - final baseOffset = (selection!.base.nodePosition as TextPosition).offset; + final selection = widget.composer.selection!; + final baseOffset = (selection.base.nodePosition as TextPosition).offset; final extentOffset = (selection.extent.nodePosition as TextPosition).offset; final selectionStart = min(baseOffset, extentOffset); final selectionEnd = max(baseOffset, extentOffset); - final selectionRange = SpanRange(start: selectionStart, end: selectionEnd - 1); + final selectionRange = SpanRange(selectionStart, selectionEnd - 1); - final textNode = widget.editor.document.getNodeById(selection.extent.nodeId)! as TextNode; + final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; final text = textNode.text; final overlappingLinkAttributions = text.getAttributionSpansInRange( @@ -325,7 +352,7 @@ class _EditorToolbarState extends State { // the entire link attribution. text.removeAttribution( overlappingLinkSpan.attribution, - SpanRange(start: overlappingLinkSpan.start, end: overlappingLinkSpan.end), + overlappingLinkSpan.range, ); } } else { @@ -340,28 +367,40 @@ class _EditorToolbarState extends State { /// Takes the text from the [urlController] and applies it as a link /// attribution to the currently selected text. void _applyLink() { - final url = _urlController.text; + final url = _urlController!.text.text; - final selection = widget.composer.selection; - final baseOffset = (selection!.base.nodePosition as TextPosition).offset; + final selection = widget.composer.selection!; + final baseOffset = (selection.base.nodePosition as TextPosition).offset; final extentOffset = (selection.extent.nodePosition as TextPosition).offset; final selectionStart = min(baseOffset, extentOffset); final selectionEnd = max(baseOffset, extentOffset); - final selectionRange = SpanRange(start: selectionStart, end: selectionEnd - 1); + final selectionRange = TextRange(start: selectionStart, end: selectionEnd - 1); - final textNode = widget.editor.document.getNodeById(selection.extent.nodeId)! as TextNode; + final textNode = widget.document.getNodeById(selection.extent.nodeId) as TextNode; final text = textNode.text; final trimmedRange = _trimTextRangeWhitespace(text, selectionRange); - final linkAttribution = LinkAttribution(url: Uri.parse(url)); - text.addAttribution( - linkAttribution, - trimmedRange, - ); + final linkAttribution = LinkAttribution(url); + + widget.editor!.execute([ + AddTextAttributionsRequest( + documentRange: DocumentRange( + start: DocumentPosition( + nodeId: textNode.id, + nodePosition: TextNodePosition(offset: trimmedRange.start), + ), + end: DocumentPosition( + nodeId: textNode.id, + nodePosition: TextNodePosition(offset: trimmedRange.end), + ), + ), + attributions: {linkAttribution}, + ), + ]); // Clear the field and hide the URL bar - _urlController.clear(); + _urlController!.clearTextAndSelection(); setState(() { _showUrlField = false; _urlFocusNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); @@ -372,7 +411,7 @@ class _EditorToolbarState extends State { /// Given [text] and a [range] within the [text], the [range] is /// shortened on both sides to remove any trailing whitespace and /// the new range is returned. - SpanRange _trimTextRangeWhitespace(AttributedText text, SpanRange range) { + SpanRange _trimTextRangeWhitespace(AttributedText text, TextRange range) { int startOffset = range.start; int endOffset = range.end; @@ -383,35 +422,8 @@ class _EditorToolbarState extends State { endOffset -= 1; } - return SpanRange(start: startOffset, end: endOffset); - } - - /// Changes the alignment of the current selected text node - /// to reflect [newAlignment]. - void _changeAlignment(TextAlign? newAlignment) { - String? newAlignmentValue; - switch (newAlignment) { - case TextAlign.left: - case TextAlign.start: - newAlignmentValue = 'left'; - break; - case TextAlign.center: - newAlignmentValue = 'center'; - break; - case TextAlign.right: - case TextAlign.end: - newAlignmentValue = 'right'; - break; - case TextAlign.justify: - newAlignmentValue = 'justify'; - break; - case null: - // Do nothing. - return; - } - - final selectedNode = widget.editor.document.getNodeById(widget.composer.selection!.extent.nodeId)! as ParagraphNode; - selectedNode.metadata['textAlign'] = newAlignmentValue; + // Add 1 to the end offset because SpanRange treats the end offset to be exclusive. + return SpanRange(startOffset, endOffset + 1); } /// Returns the localized name for the given [_TextType], e.g., @@ -435,156 +447,208 @@ class _EditorToolbarState extends State { } } + /// Changes the alignment of the current selected text node + /// to reflect [newAlignment]. + void _changeAlignment(TextAlign? newAlignment) { + if (newAlignment == null) { + return; + } + + widget.editor!.execute([ + ChangeParagraphAlignmentRequest( + nodeId: widget.composer.selection!.extent.nodeId, + alignment: newAlignment, + ), + ]); + } + + void _onPerformAction(TextInputAction action) { + if (action == TextInputAction.done) { + _applyLink(); + } + } + + /// Called when the user selects a block type on the toolbar. + void _onBlockTypeSelected(SuperEditorDemoTextItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _convertTextToNewType(_TextType.values // + .where((e) => e.name == selectedItem.id) + .first); + }); + } + } + + /// Called when the user selects an alignment on the toolbar. + void _onAlignmentSelected(SuperEditorDemoIconItem? selectedItem) { + if (selectedItem != null) { + setState(() { + _changeAlignment(TextAlign.values.firstWhere((e) => e.name == selectedItem.id)); + }); + } + } + @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: widget.anchor, - builder: (context, offset, child) { - if (widget.anchor.value == null || widget.composer.selection == null) { - // When no anchor position is available, or the user hasn't - // selected any text, show nothing. - return const SizedBox(); - } - - return SizedBox.expand( - child: Stack( - children: [ - // Conditionally display the URL text field below - // the standard toolbar. - if (_showUrlField) - Positioned( - left: widget.anchor.value!.dx, - top: widget.anchor.value!.dy, - child: FractionalTranslation( - translation: const Offset(-0.5, 0.0), - child: _buildUrlField(), - ), - ), - Positioned( - // The hard-coded clamp values are based on empirical checks - // with the marketing website. The clamping behavior should be - // generalized to use this toolbar in an app. - left: widget.anchor.value!.dx.clamp(165, MediaQuery.of(context).size.width - 165).toDouble(), - top: widget.anchor.value!.dy, - child: FractionalTranslation( - translation: const Offset(-0.5, -1.4), - child: _buildToolbar(), - ), - ), - ], + return BuildInOrder( + children: [ + FollowerFadeOutBeyondBoundary( + link: widget.anchor, + boundary: _screenBoundary, + child: Follower.withAligner( + link: widget.anchor, + aligner: _toolbarAligner, + boundary: _screenBoundary, + showWhenUnlinked: false, + child: _buildToolbars(), ), - ); - }, + ), + ], + ); + } + + Widget _buildToolbars() { + return SuperEditorPopover( + popoverFocusNode: _popoverFocusNode, + editorFocusNode: widget.editorFocusNode, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildToolbar(), + if (_showUrlField) ...[ + const SizedBox(height: 8), + _buildUrlField(), + ], + ], + ), ); } Widget _buildToolbar() { - return Material( - shape: const StadiumBorder(), - elevation: 5, - clipBehavior: Clip.hardEdge, - child: SizedBox( - height: 40, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Only allow the user to select a new type of text node if - // the currently selected node can be converted. - if (_isConvertibleNode()) ...[ - Tooltip( - message: 'Text block type', - child: DropdownButton<_TextType>( - value: _getCurrentTextType(), - items: _TextType.values - .map((textType) => DropdownMenuItem<_TextType>( - value: textType, - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Text(_getTextTypeName(textType)), - ), - )) - .toList(), - icon: const Icon(Icons.arrow_drop_down), - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - underline: const SizedBox(), - elevation: 0, - onChanged: _convertTextToNewType, + return IntrinsicWidth( + child: Material( + shape: const StadiumBorder(), + elevation: 5, + clipBehavior: Clip.hardEdge, + child: SizedBox( + height: 40, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Only allow the user to select a new type of text node if + // the currently selected node can be converted. + if (_isConvertibleNode()) ...[ + Tooltip( + message: 'Block type', + child: _buildBlockTypeSelector(), + ), + _buildVerticalDivider(), + ], + Center( + child: IconButton( + onPressed: _toggleBold, + icon: const Icon(Icons.format_bold), + splashRadius: 16, + tooltip: 'Bold', ), ), - _buildVerticalDivider(), - ], - Center( - child: IconButton( - onPressed: _toggleBold, - icon: const Icon(Icons.format_bold), - splashRadius: 16, - tooltip: 'Bold', + Center( + child: IconButton( + onPressed: _toggleItalics, + icon: const Icon(Icons.format_italic), + splashRadius: 16, + tooltip: 'Italics', + ), ), - ), - Center( - child: IconButton( - onPressed: _toggleItalics, - icon: const Icon(Icons.format_italic), - splashRadius: 16, - tooltip: 'Italics', + Center( + child: IconButton( + onPressed: _toggleStrikethrough, + icon: const Icon(Icons.strikethrough_s), + splashRadius: 16, + tooltip: 'Strikethrough', + ), ), - ), - Center( - child: IconButton( - onPressed: _toggleStrikethrough, - icon: const Icon(Icons.strikethrough_s), - splashRadius: 16, - tooltip: 'Strikethrough', + Center( + child: IconButton( + onPressed: _areMultipleLinksSelected() ? null : _onLinkPressed, + icon: const Icon(Icons.link), + color: _isSingleLinkSelected() ? const Color(0xFF007AFF) : IconTheme.of(context).color, + splashRadius: 16, + tooltip: 'Link', + ), ), - ), - // Center( - // child: IconButton( - // onPressed: _areMultipleLinksSelected() ? null : _onLinkPressed, - // icon: const Icon(Icons.link), - // color: _isSingleLinkSelected() - // ? const Color(0xFF007AFF) - // : IconTheme.of(context).color, - // splashRadius: 16, - // tooltip: 'Link', - // ), - // ), - // Only display alignment controls if the currently selected text - // node respects alignment. List items, for example, do not. - if (_isTextAlignable()) ...[ + // Only display alignment controls if the currently selected text + // node respects alignment. List items, for example, do not. + if (_isTextAlignable()) // + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildVerticalDivider(), + Tooltip( + message: 'Text Alignment', + child: _buildAlignmentSelector(), + ), + ], + ), + _buildVerticalDivider(), - Tooltip( - message: 'Text Alignment', - child: DropdownButton( - value: _getCurrentTextAlignment(), - items: [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify] - .map((textAlign) => DropdownMenuItem( - value: textAlign, - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon(_buildTextAlignIcon(textAlign)), - ), - )) - .toList(), - icon: const Icon(Icons.arrow_drop_down), - style: const TextStyle( - color: Colors.black, - fontSize: 12, - ), - underline: const SizedBox(), - elevation: 0, - onChanged: _changeAlignment, + Center( + child: IconButton( + onPressed: () {}, + icon: const Icon(Icons.more_vert), + splashRadius: 16, + tooltip: 'More options', ), ), ], - ], + ), ), ), ); } + Widget _buildAlignmentSelector() { + final alignment = _getCurrentTextAlignment(); + return SuperEditorDemoIconItemSelector( + parentFocusNode: widget.editorFocusNode, + boundaryKey: widget.editorViewportKey, + value: SuperEditorDemoIconItem( + id: alignment.name, + icon: _buildTextAlignIcon(alignment), + ), + items: const [TextAlign.left, TextAlign.center, TextAlign.right, TextAlign.justify] + .map( + (alignment) => SuperEditorDemoIconItem( + icon: _buildTextAlignIcon(alignment), + id: alignment.name, + ), + ) + .toList(), + onSelected: _onAlignmentSelected, + ); + } + + Widget _buildBlockTypeSelector() { + final currentBlockType = _getCurrentTextType(); + return SuperEditorDemoTextItemSelector( + parentFocusNode: widget.editorFocusNode, + boundaryKey: widget.editorViewportKey, + id: SuperEditorDemoTextItem( + id: currentBlockType.name, + label: _getTextTypeName(currentBlockType), + ), + items: _TextType.values + .map( + (blockType) => SuperEditorDemoTextItem( + id: blockType.name, + label: _getTextTypeName(blockType), + ), + ) + .toList(), + onSelected: _onBlockTypeSelected, + ); + } + Widget _buildUrlField() { return Material( shape: const StadiumBorder(), @@ -597,14 +661,28 @@ class _EditorToolbarState extends State { child: Row( children: [ Expanded( - child: TextField( + child: SuperTextField( focusNode: _urlFocusNode, - controller: _urlController, - decoration: const InputDecoration( - hintText: 'enter url...', - border: InputBorder.none, - ), - onSubmitted: (newValue) => _applyLink(), + textController: _urlController, + minLines: 1, + maxLines: 1, + inputSource: TextInputSource.ime, + hintBehavior: HintBehavior.displayHintUntilTextEntered, + hintBuilder: (context) { + return const Text( + "enter a url...", + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ); + }, + textStyleBuilder: (_) { + return const TextStyle( + color: Colors.black, + fontSize: 16, + ); + }, ), ), IconButton( @@ -616,7 +694,7 @@ class _EditorToolbarState extends State { setState(() { _urlFocusNode.unfocus(); _showUrlField = false; - _urlController.clear(); + _urlController!.clearTextAndSelection(); }); }, ), @@ -658,3 +736,20 @@ enum _TextType { orderedListItem, unorderedListItem, } + +class SingleLineAttributedTextEditingController extends AttributedTextEditingController { + SingleLineAttributedTextEditingController(this.onSubmit); + + final VoidCallback onSubmit; + + @override + void insertNewline() { + // Don't insert newline in a single-line text field. + + // Invoke callback to take action on enter. + onSubmit(); + + // TODO: this is a hack. SuperTextField shouldn't insert newlines in a single + // line field (#697). + } +} diff --git a/website/lib/homepage/featured_editor.dart b/website/lib/homepage/featured_editor.dart index c11caa3fcb..ab2472122f 100644 --- a/website/lib/homepage/featured_editor.dart +++ b/website/lib/homepage/featured_editor.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:super_editor/super_editor.dart'; - -import 'editor_toolbar.dart'; +import 'package:website/homepage/editor_toolbar.dart'; /// A Super Editor that displays itself on top of a white sheet of paper /// with a popup editor toolbar. @@ -24,28 +23,24 @@ class FeaturedEditor extends StatefulWidget { } class _FeaturedEditorState extends State { + final _viewportKey = GlobalKey(); final _docLayoutKey = GlobalKey(); late final MutableDocument _doc; - late final DocumentEditor _docEditor; - late final DocumentComposer _composer; + late final Editor _docEditor; + late final MutableDocumentComposer _composer; late final FocusNode _editorFocusNode; - late final ScrollController _scrollController; - OverlayEntry? _formatBarOverlayEntry; + final _textFormatBarOverlayController = OverlayPortalController(); - final _selectionAnchor = ValueNotifier(null); + final SelectionLayerLinks _selectionLayerLinks = SelectionLayerLinks(); @override void initState() { super.initState(); // Create the initial document content. - _doc = _createInitialDocument()..addListener(_updateToolbarDisplay); - - // Create the DocumentEditor, which is responsible for applying all - // content changes to the Document. - _docEditor = DocumentEditor(document: _doc); + _doc = _createInitialDocument()..addListener(_onDocumentChange); // Create the DocumentComposer, which keeps track of the user's text // selection and the current input styles, e.g., bold or italics. @@ -54,28 +49,28 @@ class _FeaturedEditorState extends State { // over the initial caret position. If you don't need any external // control over content selection then you don't need to create your // own DocumentComposer. The Editor widget will do that on your behalf. - _composer = DocumentComposer( + _composer = MutableDocumentComposer( initialSelection: DocumentSelection.collapsed( position: DocumentPosition( - nodeId: _doc.nodes.last.id, // Place caret at end of document - nodePosition: (_doc.nodes.last as TextNode).endPosition, + nodeId: _doc.last.id, // Place caret at end of document + nodePosition: (_doc.last as TextNode).endPosition, ), ), - )..addListener(_updateToolbarDisplay); + ); + + _composer.selectionNotifier.addListener(_updateToolbarDisplay); + + // Create the DocumentEditor, which is responsible for applying all + // content changes to the Document. + _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); // Create a FocusNode so that we can explicitly toggle editor focus. _editorFocusNode = FocusNode(); - - // Use our own ScrollController for the editor so that we can refresh - // our popup toolbar position as the user scrolls the editor. - _scrollController = ScrollController()..addListener(_updateToolbarDisplay); } @override void dispose() { - _formatBarOverlayEntry?.remove(); _doc.dispose(); - _scrollController.dispose(); _editorFocusNode.dispose(); _composer.dispose(); @@ -83,74 +78,33 @@ class _FeaturedEditorState extends State { } void _showEditorToolbar() { - if (_formatBarOverlayEntry == null) { - _formatBarOverlayEntry ??= OverlayEntry( - builder: (context) { - return EditorToolbar( - anchor: _selectionAnchor, - editor: _docEditor, - composer: _composer, - closeToolbar: _hideEditorToolbar, - ); - }, - ); - - // Display the toolbar in the application overlay. - final overlay = Overlay.of(context); - overlay!.insert(_formatBarOverlayEntry!); - - // Schedule a callback after this frame to locate the selection - // bounds on the screen and display the toolbar near the selected - // text. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _updateToolbarOffset(); - }); - } - } - - void _updateToolbarOffset() { - if (_formatBarOverlayEntry == null) { - return; - } - - final docBoundingBox = (_docLayoutKey.currentState! as DocumentLayout).getRectForSelection( - _composer.selection!.base, - _composer.selection!.extent, - ); - final parentBox = context.findRenderObject()! as RenderBox; - final docBox = _docLayoutKey.currentContext!.findRenderObject()! as RenderBox; - final parentInOverlayOffset = parentBox.localToGlobal(Offset.zero); - final overlayBoundingBox = Rect.fromPoints( - docBox.localToGlobal(docBoundingBox!.topLeft, ancestor: parentBox), - docBox.localToGlobal(docBoundingBox.bottomRight, ancestor: parentBox), - ).translate(parentInOverlayOffset.dx, parentInOverlayOffset.dy); - - final offset = overlayBoundingBox.topCenter; - - _selectionAnchor.value = offset; + _textFormatBarOverlayController.show(); } void _hideEditorToolbar() { // Null out the selection anchor so that when it re-appears, // the bar doesn't momentarily "flash" at its old anchor position. - _selectionAnchor.value = null; - - if (_formatBarOverlayEntry != null) { - // Remove the toolbar overlay and null-out the entry. - // We null out the entry because we can't query whether - // or not the entry exists in the overlay, so in our - // case, null implies the entry is not in the overlay, - // and non-null implies the entry is in the overlay. - _formatBarOverlayEntry?.remove(); - _formatBarOverlayEntry = null; - } + _textFormatBarOverlayController.hide(); // Ensure that focus returns to the editor. // // I tried explicitly unfocus()'ing the URL textfield // in the toolbar but it didn't return focus to the // editor. I'm not sure why. - _editorFocusNode.requestFocus(); + // + // Only do that if the primary focus is not at the root focus scope because + // this might signify that the app is going to the background. Removing + // the focus from the root focus scope in that situation prevents the editor + // from re-gaining focus when the app is brought back to the foreground. + // + // See https://github.com/superlistapp/super_editor/issues/2279 for details. + if (FocusManager.instance.primaryFocus != FocusManager.instance.rootScope) { + _editorFocusNode.requestFocus(); + } + } + + void _onDocumentChange(_) { + _updateToolbarDisplay(); } void _updateToolbarDisplay() { @@ -177,31 +131,50 @@ class _FeaturedEditorState extends State { return; } - final textNode = _doc.getNodeById(selection.extent.nodeId); - if (textNode is! TextNode) { - // The currently selected content is not a paragraph. We don't - // want to show a toolbar in this case. - _hideEditorToolbar(); - - return; - } + final selectedNode = _doc.getNodeById(selection.extent.nodeId); - if (_formatBarOverlayEntry == null) { + if (selectedNode is TextNode) { // Show the editor's toolbar for text styling. _showEditorToolbar(); + return; } else { - _updateToolbarOffset(); + // The currently selected content is not a paragraph. We don't + // want to show a toolbar in this case. + _hideEditorToolbar(); } } @override Widget build(BuildContext context) { - return SuperEditor( + return OverlayPortal( + controller: _textFormatBarOverlayController, + overlayChildBuilder: _buildFloatingToolbar, + child: KeyedSubtree( + key: _viewportKey, + child: CustomScrollView( + slivers: [ + SuperEditor( + editor: _docEditor, + documentLayoutKey: _docLayoutKey, + focusNode: _editorFocusNode, + stylesheet: _getEditorStyleSheet(), + selectionLayerLinks: _selectionLayerLinks, + ), + ], + ), + ), + ); + } + + Widget _buildFloatingToolbar(BuildContext context) { + return EditorToolbar( + editorViewportKey: _viewportKey, + editorFocusNode: _editorFocusNode, + document: _doc, + anchor: _selectionLayerLinks.expandedSelectionBoundsLink, editor: _docEditor, composer: _composer, - documentLayoutKey: _docLayoutKey, - focusNode: _editorFocusNode, - stylesheet: _getEditorStyleSheet(), + closeToolbar: _hideEditorToolbar, ); } @@ -232,9 +205,9 @@ MutableDocument _createInitialDocument() { return MutableDocument( nodes: [ ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'A supercharged rich text editor for Flutter', + 'A supercharged rich text editor for Flutter', ), metadata: { 'blockType': header1Attribution, @@ -242,10 +215,10 @@ MutableDocument _createInitialDocument() { }, ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'The missing WYSIWYG editor for Flutter.', - spans: AttributedSpans( + 'The missing WYSIWYG editor for Flutter.', + AttributedSpans( attributions: [ const SpanMarker( attribution: boldAttribution, @@ -262,11 +235,10 @@ MutableDocument _createInitialDocument() { ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: - 'Open source and written entirely in Dart. Comes with a modular architecture that allows you to customize it to your needs.', - spans: AttributedSpans( + 'Open source and written entirely in Dart. Comes with a modular architecture that allows you to customize it to your needs.', + AttributedSpans( attributions: [ const SpanMarker( attribution: _underlineAttribution, @@ -283,9 +255,9 @@ MutableDocument _createInitialDocument() { ), ), ParagraphNode( - id: DocumentEditor.createNodeId(), + id: Editor.createNodeId(), text: AttributedText( - text: 'Try it right here >>', + 'Try it right here >>', ), ), ], @@ -307,13 +279,13 @@ final _compactStylesheet = defaultStylesheet.copyWith( StyleRule(BlockSelector.all, (doc, docNode) => {'textStyle': _baseTextStyle}), StyleRule( BlockSelector.all.after(header1Attribution.name), - (doc, docNode) => {'padding': const CascadingPadding.only(top: 24)}, + (doc, docNode) => {Styles.padding: const CascadingPadding.only(top: 24)}, ), StyleRule( BlockSelector(header1Attribution.name), (doc, docNode) { return { - 'textStyle': _baseTextStyle.copyWith( + Styles.textStyle: _baseTextStyle.copyWith( fontSize: 32, fontWeight: FontWeight.w700, height: 1.2, @@ -323,7 +295,7 @@ final _compactStylesheet = defaultStylesheet.copyWith( ), StyleRule(BlockSelector(header2Attribution.name), (doc, docNode) { return { - 'textStyle': _baseTextStyle.copyWith( + Styles.textStyle: _baseTextStyle.copyWith( fontSize: 32, fontWeight: FontWeight.w700, height: 1.2, @@ -332,7 +304,7 @@ final _compactStylesheet = defaultStylesheet.copyWith( }), StyleRule(BlockSelector(header3Attribution.name), (doc, docNode) { return { - 'textStyle': _baseTextStyle.copyWith( + Styles.textStyle: _baseTextStyle.copyWith( fontSize: 26, fontWeight: FontWeight.w700, height: 1.2, @@ -341,7 +313,7 @@ final _compactStylesheet = defaultStylesheet.copyWith( }), StyleRule(BlockSelector(blockquoteAttribution.name), (doc, docNode) { return { - 'textStyle': _baseTextStyle.copyWith( + Styles.textStyle: _baseTextStyle.copyWith( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black54, @@ -355,16 +327,16 @@ final _compactStylesheet = defaultStylesheet.copyWith( final _wideStylesheet = defaultStylesheet.copyWith( documentPadding: const EdgeInsets.symmetric(horizontal: 54, vertical: 60), addRulesAfter: [ - StyleRule(BlockSelector.all, (doc, docNode) => {'textStyle': _baseTextStyle}), + StyleRule(BlockSelector.all, (doc, docNode) => {Styles.textStyle: _baseTextStyle}), StyleRule( BlockSelector.all.after(header1Attribution.name), - (doc, docNode) => {'padding': const CascadingPadding.only(top: 48)}, + (doc, docNode) => {Styles.padding: const CascadingPadding.only(top: 48)}, ), StyleRule( BlockSelector(header1Attribution.name), (doc, docNode) { return { - 'textStyle': _baseTextStyle.copyWith( + Styles.textStyle: _baseTextStyle.copyWith( fontSize: 40, fontWeight: FontWeight.w700, height: 1.2, @@ -376,7 +348,7 @@ final _wideStylesheet = defaultStylesheet.copyWith( BlockSelector(header2Attribution.name), (doc, docNode) { return { - 'textStyle': _baseTextStyle.copyWith( + Styles.textStyle: _baseTextStyle.copyWith( fontSize: 32, fontWeight: FontWeight.w700, height: 1.2, @@ -388,7 +360,7 @@ final _wideStylesheet = defaultStylesheet.copyWith( BlockSelector(header3Attribution.name), (doc, docNode) { return { - 'textStyle': _baseTextStyle.copyWith( + Styles.textStyle: _baseTextStyle.copyWith( fontSize: 36, fontWeight: FontWeight.w700, height: 1.2, @@ -400,7 +372,7 @@ final _wideStylesheet = defaultStylesheet.copyWith( BlockSelector(blockquoteAttribution.name), (doc, docNode) { return { - 'textStyle': _baseTextStyle.copyWith( + Styles.textStyle: _baseTextStyle.copyWith( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black54, diff --git a/website/lib/homepage/footer.dart b/website/lib/homepage/footer.dart index b6db9612ef..afdeff045f 100644 --- a/website/lib/homepage/footer.dart +++ b/website/lib/homepage/footer.dart @@ -14,16 +14,16 @@ class Footer extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(vertical: 24), child: singleColumnLayout - ? Column( - children: const [ + ? const Column( + children: [ _LeftPart(), SizedBox(height: 28), _RightPart(), ], ) - : Row( + : const Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: const [ + children: [ _LeftPart(), _RightPart(), ], @@ -144,7 +144,7 @@ class _Link extends StatelessWidget { cursor: SystemMouseCursors.click, child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => launch(url), + onTap: () => launchUrl(Uri.parse(url)), child: IntrinsicWidth( child: Stack( clipBehavior: Clip.none, diff --git a/website/lib/homepage/header.dart b/website/lib/homepage/header.dart index 6542b8068e..cee0be9ec3 100644 --- a/website/lib/homepage/header.dart +++ b/website/lib/homepage/header.dart @@ -30,8 +30,8 @@ class Header extends StatelessWidget { } Widget _buildNavBar() { - return Row( - children: const [ + return const Row( + children: [ _Link( url: 'https://github.com/superlistapp/super_editor', child: Text('Github'), @@ -88,7 +88,7 @@ class _DrawerLayoutState extends State { child: AnimatedContainer( duration: const Duration(milliseconds: 250), curve: Curves.ease, - color: Colors.black.withOpacity(_open ? 0.64 : 0), + color: Colors.black.withValues(alpha: _open ? 0.64 : 0), ), ), ), @@ -122,8 +122,8 @@ class _DrawerLayoutState extends State { height: 44, ), ), - Column( - children: const [ + const Column( + children: [ SizedBox(height: 16), _Link( url: 'https://github.com/superlistapp/super_editor', @@ -166,7 +166,7 @@ class _Link extends StatelessWidget { @override Widget build(BuildContext context) { return TextButton( - onPressed: () => launch(url), + onPressed: () => launchUrl(Uri.parse(url)), style: ButtonStyle( foregroundColor: MaterialStateProperty.all(Colors.white), minimumSize: MaterialStateProperty.all(const Size(72, 48)), @@ -189,7 +189,7 @@ class _DownloadButton extends StatelessWidget { Widget build(BuildContext context) { return MaterialButton( color: const Color(0xFFFAE74F), - onPressed: () => launch('https://pub.dev/packages/super_editor'), + onPressed: () => launchUrl(Uri.parse('https://pub.dev/packages/super_editor')), padding: const EdgeInsets.symmetric(horizontal: 32), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(64)), height: 52, diff --git a/website/lib/homepage/home_page.dart b/website/lib/homepage/home_page.dart index fb4262ef3b..a39c7e9196 100644 --- a/website/lib/homepage/home_page.dart +++ b/website/lib/homepage/home_page.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:website/breakpoints.dart'; +import 'package:website/homepage/call_to_action.dart'; +import 'package:website/homepage/editor_video_showcase.dart'; import 'package:website/homepage/featured_editor.dart'; +import 'package:website/homepage/features.dart'; +import 'package:website/homepage/footer.dart'; +import 'package:website/homepage/header.dart'; import 'package:website/homepage/inside_the_toolbox.dart'; -import '../breakpoints.dart'; -import 'call_to_action.dart'; -import 'editor_video_showcase.dart'; -import 'features.dart'; -import 'footer.dart'; -import 'header.dart'; - class HomePage extends StatelessWidget { const HomePage(); @@ -100,15 +99,13 @@ class HomePage extends StatelessWidget { boxShadow: [ BoxShadow( offset: const Offset(0, 10), - color: Colors.black.withOpacity(0.79), + color: Colors.black.withValues(alpha: 0.79), blurRadius: 75, ), ], ), - child: SingleChildScrollView( - child: FeaturedEditor( - displayMode: displayMode, - ), + child: FeaturedEditor( + displayMode: displayMode, ), ), ), diff --git a/website/lib/homepage/inside_the_toolbox.dart b/website/lib/homepage/inside_the_toolbox.dart index 57bf2c5744..4b3ede428e 100644 --- a/website/lib/homepage/inside_the_toolbox.dart +++ b/website/lib/homepage/inside_the_toolbox.dart @@ -96,10 +96,10 @@ class InsideTheToolbox extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(4), - ), + ), child: SuperTextWithSelection.single( richText: const TextSpan( - text: 'This text is selectable. The caret and selection rendering is custom.', + text: 'This text is selectable. The caret and selection rendering is custom.', style: TextStyle( color: Colors.black, fontSize: 16, @@ -110,9 +110,8 @@ class InsideTheToolbox extends StatelessWidget { baseOffset: 13, extentOffset: 23, ), - ), - ), + ), ), ), ); @@ -195,22 +194,22 @@ class _AttributedTextDemoState extends State<_AttributedTextDemo> { } void _computeStyledText() { - final _text = AttributedText( - text: 'This is some text styled with AttributedText', + final text = AttributedText( + 'This is some text styled with AttributedText', ); for (final range in _boldRanges) { - _text.addAttribution(boldAttribution, range); + text.addAttribution(boldAttribution, range); } for (final range in _italicsRanges) { - _text.addAttribution(italicsAttribution, range); + text.addAttribution(italicsAttribution, range); } for (final range in _strikethroughRanges) { - _text.addAttribution(strikethroughAttribution, range); + text.addAttribution(strikethroughAttribution, range); } setState(() { - _richText = _text.computeTextSpan((Set attributions) { + _richText = text.computeTextSpan((Set attributions) { TextStyle newStyle = const TextStyle( color: Colors.white, fontSize: 30, @@ -272,21 +271,23 @@ class _AttributedTextDemoState extends State<_AttributedTextDemo> { } Widget _buildCellSelector(List rangesToUpdate) { - return LayoutBuilder(builder: (context, constraints) { - final cellWidth = constraints.maxWidth / _plainText.length; - - return TextRangeSelector( - cellCount: _plainText.length, - cellWidth: cellWidth, - cellHeight: 20, - onRangesChange: (newRanges) { - rangesToUpdate - ..clear() - ..addAll(newRanges); - _computeStyledText(); - }, - ); - }); + return LayoutBuilder( + builder: (context, constraints) { + final cellWidth = constraints.maxWidth / _plainText.length; + + return TextRangeSelector( + cellCount: _plainText.length, + cellWidth: cellWidth, + cellHeight: 20, + onRangesChange: (newRanges) { + rangesToUpdate + ..clear() + ..addAll(newRanges); + _computeStyledText(); + }, + ); + }, + ); } } @@ -344,7 +345,7 @@ class _TextRangeSelectorState extends State { } int _getCellIndexFromLocalOffset(Offset localOffset) { - return ((localOffset.dx / widget.cellWidth).floor()).clamp(0.0, widget.cellCount - 1).toInt(); + return (localOffset.dx / widget.cellWidth).floor().clamp(0.0, widget.cellCount - 1).toInt(); } void _reportSelectedRanges() { @@ -360,12 +361,12 @@ class _TextRangeSelectorState extends State { rangeStart = i; } } else if (rangeStart >= 0) { - ranges.add(SpanRange(start: rangeStart, end: i - 1)); + ranges.add(SpanRange(rangeStart, i - 1)); rangeStart = -1; } } if (rangeStart >= 0) { - ranges.add(SpanRange(start: rangeStart, end: widget.cellCount - 1)); + ranges.add(SpanRange(rangeStart, widget.cellCount - 1)); } widget.onRangesChange?.call(ranges); @@ -386,7 +387,7 @@ class _TextRangeSelectorState extends State { height: widget.cellHeight, decoration: BoxDecoration( border: Border.all(color: _isSelected(index) ? Colors.tealAccent : Colors.grey), - color: _isSelected(index) ? Colors.tealAccent.withOpacity(0.7) : Colors.grey.withOpacity(0.7), + color: _isSelected(index) ? Colors.tealAccent.withValues(alpha: 0.7) : Colors.grey.withValues(alpha: 0.7), ), ), ), diff --git a/website/lib/infrastructure/super_editor_item_selector.dart b/website/lib/infrastructure/super_editor_item_selector.dart new file mode 100644 index 0000000000..01995c009a --- /dev/null +++ b/website/lib/infrastructure/super_editor_item_selector.dart @@ -0,0 +1,403 @@ +import 'package:flutter/material.dart'; +import 'package:overlord/overlord.dart'; +import 'package:super_editor/super_editor.dart'; + +/// A selection control, which displays a button with the selected item, and upon tap, displays a +/// popover list of available text options, from which the user can select a different +/// option. +/// +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The popover list is positioned based on the following rules: +/// +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class SuperEditorDemoTextItemSelector extends StatefulWidget { + const SuperEditorDemoTextItemSelector({ + super.key, + this.parentFocusNode, + this.boundaryKey, + this.id, + required this.items, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoTextItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + /// The currently selected value or `null` if no item is selected. + /// + /// This value is used to build the button. + final SuperEditorDemoTextItem? id; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [SuperEditorDemoTextItem.label] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(SuperEditorDemoTextItem? value) onSelected; + + @override + State createState() => _SuperEditorDemoTextItemSelectorState(); +} + +class _SuperEditorDemoTextItemSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + void _onItemSelected(SuperEditorDemoTextItem? value) { + _popoverController.close(); + widget.onSelected(value); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + boundaryKey: widget.boundaryKey, + popoverBuilder: (context) => RoundedRectanglePopoverAppearance( + child: ItemSelectionList( + focusNode: _popoverFocusNode, + value: widget.id, + items: widget.items, + itemBuilder: _buildPopoverListItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return SuperEditorPopoverButton( + padding: const EdgeInsets.only(left: 16.0, right: 24), + onTap: () => _popoverController.open(), + child: widget.id == null // + ? const SizedBox() + : Text( + widget.id!.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ); + } + + Widget _buildPopoverListItem(BuildContext context, SuperEditorDemoTextItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Text( + item.label, + style: const TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + ), + ), + ); + } +} + +/// An option that is displayed as text by a [SuperEditorDemoTextItemSelector]. +/// +/// Two [SuperEditorDemoTextItem]s are considered to be equal if they have the same [id]. +class SuperEditorDemoTextItem { + const SuperEditorDemoTextItem({ + required this.id, + required this.label, + }); + + /// The value that identifies this item. + final String id; + + /// The text that is displayed. + final String label; + + @override + bool operator ==(Object other) => + identical(this, other) || other is SuperEditorDemoTextItem && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// A selection control, which displays a button with the selected item, and upon tap, displays a +/// popover list of available icons, from which the user can select a different option. +/// +/// Unlike Flutter `DropdownButton`, which displays the popover list in a separate route, +/// this widget displays its popover list in an `Overlay`. By using an `Overlay`, focus can be shared +/// with the [parentFocusNode]. This means that when the popover list requests focus, [parentFocusNode] +/// still has non-primary focus. +/// +/// The popover list is positioned based on the following rules: +/// +/// 1. The popover is displayed below the selected item, if there's enough room, or +/// 2. The popover is displayed above the selected item, if there's enough room, or +/// 3. The popover is displayed with its bottom aligned with the bottom of +/// the given boundary, and it covers the selected item. +/// +/// The popover list height is based on the following rules: +/// +/// 1. The popover is displayed as tall as all items in the list, if there's enough room, or +/// 2. The popover is displayed as tall as the available space and becomes scrollable. +/// +/// The popover list includes keyboard selection behaviors: +/// +/// * Pressing UP/DOWN moves the "active" item selection up/down. +/// * Pressing UP with the first item active moves the active item selection to the last item. +/// * Pressing DOWN with the last item active moves the active item selection to the first item. +/// * Pressing ENTER selects the currently active item and closes the popover list. +class SuperEditorDemoIconItemSelector extends StatefulWidget { + const SuperEditorDemoIconItemSelector({ + super.key, + this.parentFocusNode, + this.boundaryKey, + this.value, + required this.items, + required this.onSelected, + }); + + /// The [FocusNode], to which the popover list's [FocusNode] will be added as a child. + /// + /// In Flutter, [FocusNode]s have parents and children. This relationship allows an + /// entire ancestor path to "have focus", but only the lowest level descendant + /// in that path has "primary focus". This path is important because various + /// widgets alter their presentation or behavior based on whether or not they + /// currently have focus, even if they only have "non-primary focus". + /// + /// When the popover list of items is visible, that list will have primary focus. + /// Moreover, because the popover list is built in an `Overlay`, none of your + /// widgets are in the natural focus path for that popover list. Therefore, if you + /// need your widget tree to retain focus while the popover list is visible, then + /// you need to provide the [FocusNode] that the popover list should use as its + /// parent, thereby retaining focus for your widgets. + final FocusNode? parentFocusNode; + + /// A [GlobalKey] to a widget that determines the bounds where the popover list can be displayed. + /// + /// As the popover list follows the selected item, it can be displayed off-screen if this [SuperEditorDemoIconItemSelector] + /// is close to the bottom of the screen. + /// + /// Passing a [boundaryKey] causes the popover list to be confined to the bounds of the widget + /// bound to the [boundaryKey]. + /// + /// If `null`, the popover list is confined to the screen bounds, defined by the result of `MediaQuery.sizeOf`. + final GlobalKey? boundaryKey; + + /// The currently selected value or `null` if no item is selected. + /// + /// This value is used to build the button. + final SuperEditorDemoIconItem? value; + + /// The items that will be displayed in the popover list. + /// + /// For each item, its [SuperEditorDemoIconItem.icon] is displayed. + final List items; + + /// Called when the user selects an item on the popover list. + final void Function(SuperEditorDemoIconItem? value) onSelected; + + @override + State createState() => _SuperEditorDemoIconItemSelectorState(); +} + +class _SuperEditorDemoIconItemSelectorState extends State { + /// Shows and hides the popover. + final PopoverController _popoverController = PopoverController(); + + /// The [FocusNode] of the popover list. + final FocusNode _popoverFocusNode = FocusNode(); + + @override + void dispose() { + _popoverController.dispose(); + _popoverFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopoverScaffold( + controller: _popoverController, + buttonBuilder: _buildButton, + popoverFocusNode: _popoverFocusNode, + parentFocusNode: widget.parentFocusNode, + popoverBuilder: (context) => RoundedRectanglePopoverAppearance( + child: ItemSelectionList( + value: widget.value, + items: widget.items, + itemBuilder: _buildItem, + onItemSelected: _onItemSelected, + onCancel: () => _popoverController.close(), + focusNode: _popoverFocusNode, + ), + ), + ); + } + + Widget _buildItem(BuildContext context, SuperEditorDemoIconItem item, bool isActive, VoidCallback onTap) { + return DecoratedBox( + decoration: BoxDecoration( + color: isActive ? Colors.grey.withValues(alpha: 0.2) : Colors.transparent, + ), + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Icon(item.icon), + ), + ), + ); + } + + Widget _buildButton(BuildContext context) { + return SuperEditorPopoverButton( + onTap: () => _popoverController.open(), + padding: const EdgeInsets.only(left: 8.0, right: 24), + child: widget.value == null // + ? const SizedBox() + : Icon(widget.value!.icon), + ); + } + + void _onItemSelected(SuperEditorDemoIconItem? value) { + _popoverController.close(); + widget.onSelected(value); + } +} + +/// An option that is displayed as an icon by a [SuperEditorDemoIconItemSelector]. +/// +/// Two [SuperEditorDemoIconItem]s are considered to be equal if they have the same [id]. +class SuperEditorDemoIconItem { + const SuperEditorDemoIconItem({ + required this.id, + required this.icon, + }); + + /// The value that identifies this item. + final String id; + + /// The icon that is displayed. + final IconData icon; + + @override + bool operator ==(Object other) => + identical(this, other) || other is SuperEditorDemoIconItem && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// A button with a center-left aligned [child] and a right aligned arrow icon. +/// +/// The arrow is displayed above the [child]. +class SuperEditorPopoverButton extends StatelessWidget { + const SuperEditorPopoverButton({ + super.key, + this.padding, + required this.onTap, + this.child, + }); + + /// Padding around the [child]. + final EdgeInsets? padding; + + /// Called when the user taps the button. + final VoidCallback onTap; + + /// The Widget displayed inside this button. + /// + /// If `null`, only the arrow is displayed. + final Widget? child; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Center( + child: Stack( + alignment: Alignment.centerLeft, + children: [ + if (child != null) // + Padding( + padding: padding ?? EdgeInsets.zero, + child: child, + ), + const Positioned( + right: 0, + child: Icon(Icons.arrow_drop_down), + ), + ], + ), + ), + ); + } +} diff --git a/website/pubspec.lock b/website/pubspec.lock index 2341401d9b..f379de2916 100644 --- a/website/pubspec.lock +++ b/website/pubspec.lock @@ -5,505 +5,620 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - url: "https://pub.dartlang.org" + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" source: hosted - version: "31.0.0" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - url: "https://pub.dartlang.org" + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" source: hosted - version: "2.8.0" + version: "6.4.1" args: dependency: transitive description: name: args - url: "https://pub.dartlang.org" + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.4.2" async: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" source: hosted - version: "2.8.2" + version: "2.11.0" attributed_text: dependency: transitive description: name: attributed_text - url: "https://pub.dartlang.org" + sha256: fb65cf441784612544eda4d5df7a3caad56e7f673c68bf1d48d9048228375189 + url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.4.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" 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" - cli_util: - dependency: transitive - description: - name: cli_util - url: "https://pub.dartlang.org" + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "1.3.0" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.19.0" convert: dependency: transitive description: name: convert - url: "https://pub.dartlang.org" + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.1" coverage: dependency: transitive description: name: coverage - url: "https://pub.dartlang.org" + sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76" + url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.7.2" crypto: dependency: transitive description: name: crypto - url: "https://pub.dartlang.org" + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" fake_async: dependency: transitive description: name: fake_async - url: "https://pub.dartlang.org" + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.1" file: dependency: transitive description: name: file - url: "https://pub.dartlang.org" + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_lints: - dependency: transitive - description: - name: flutter_lints - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_test_robots: + dependency: transitive + description: + name: flutter_test_robots + sha256: "3b00f2081148bde55190997c2772f934ad2f4529cbcfc4ccfa593f8ddc117a28" + url: "https://pub.dev" + source: hosted + version: "0.0.24" flutter_web_plugins: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + follow_the_leader: + dependency: "direct main" + description: + name: follow_the_leader + sha256: "798baf5211ca2461c8462d4c8e94f57bf989758f8204056d607eb9a20f1cf794" + url: "https://pub.dev" + source: hosted + version: "0.0.4+8" frontend_server_client: dependency: transitive description: name: frontend_server_client - url: "https://pub.dartlang.org" + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "3.2.0" glob: dependency: transitive description: name: glob - url: "https://pub.dartlang.org" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.2" http: dependency: transitive description: name: http - url: "https://pub.dartlang.org" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "1.2.2" http_multi_server: dependency: transitive description: name: http_multi_server - url: "https://pub.dartlang.org" + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.1" http_parser: dependency: transitive description: name: http_parser - url: "https://pub.dartlang.org" + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" io: dependency: transitive description: name: io - url: "https://pub.dartlang.org" + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" js: dependency: transitive description: name: js - url: "https://pub.dartlang.org" + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + url: "https://pub.dev" + source: hosted + version: "10.0.7" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + url: "https://pub.dev" source: hosted - version: "0.6.3" + version: "3.0.8" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" linkify: dependency: transitive description: name: linkify - url: "https://pub.dartlang.org" + sha256: f27f2930577bb65a5d8f2b0676404f3c89a1f354fcb1cf14b96df34e50cddf43 + url: "https://pub.dev" source: hosted version: "4.0.0" lint: dependency: "direct dev" description: name: lint - url: "https://pub.dartlang.org" - source: hosted - version: "1.5.3" - lints: - dependency: transitive - description: - name: lints - url: "https://pub.dartlang.org" + sha256: d758a5211fce7fd3f5e316f804daefecdc34c7e53559716125e6da7388ae8565 + url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "2.3.0" logging: dependency: transitive description: name: logging - url: "https://pub.dartlang.org" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.3.0" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" source: hosted - version: "0.12.11" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - url: "https://pub.dartlang.org" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.11.1" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.15.0" mime: dependency: transitive description: name: mime - url: "https://pub.dartlang.org" + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.5" node_preamble: dependency: transitive description: name: node_preamble - url: "https://pub.dartlang.org" + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + overlord: + dependency: "direct main" + description: + name: overlord + sha256: "576256bc9ce3fb0ae3042bbb26eed67bdb26a5045dd7e3c851aae65b0bbab2f5" + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "0.0.3+5" package_config: dependency: transitive description: name: package_config - url: "https://pub.dartlang.org" + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.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" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.9.0" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - url: "https://pub.dartlang.org" + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.8" pool: dependency: transitive description: name: pool - url: "https://pub.dartlang.org" + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.1" pub_semver: dependency: transitive description: name: pub_semver - url: "https://pub.dartlang.org" + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.4" shelf: dependency: transitive description: name: shelf - url: "https://pub.dartlang.org" + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" shelf_packages_handler: dependency: transitive description: name: shelf_packages_handler - url: "https://pub.dartlang.org" + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.0.2" shelf_static: dependency: transitive description: name: shelf_static - url: "https://pub.dartlang.org" + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - url: "https://pub.dartlang.org" + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.4" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_map_stack_trace: dependency: transitive description: name: source_map_stack_trace - url: "https://pub.dartlang.org" + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" source_maps: dependency: transitive description: name: source_maps - url: "https://pub.dartlang.org" + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" source: hosted - version: "0.10.10" + version: "0.10.12" source_span: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + url: "https://pub.dev" source: hosted - version: "1.8.1" + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.0" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.3.0" super_editor: dependency: "direct main" description: path: "../super_editor" relative: true source: path - version: "0.2.0" + version: "0.3.0-dev.11" super_text_layout: dependency: "direct main" description: - name: super_text_layout - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.0" + path: "../super_text_layout" + relative: true + source: path + version: "0.1.17" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" test: dependency: transitive description: name: test - url: "https://pub.dartlang.org" + sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f" + url: "https://pub.dev" source: hosted - version: "1.19.5" + version: "1.25.8" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" source: hosted - version: "0.4.8" + version: "0.7.3" test_core: dependency: transitive description: name: test_core - url: "https://pub.dartlang.org" + sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d" + url: "https://pub.dev" source: hosted - version: "0.4.9" + version: "0.6.5" typed_data: dependency: transitive description: name: typed_data - url: "https://pub.dartlang.org" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.2" url_launcher: dependency: "direct main" description: name: url_launcher - url: "https://pub.dartlang.org" + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 + url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.3.0" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5" + url: "https://pub.dev" + source: hosted + version: "6.2.5" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - url: "https://pub.dartlang.org" + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.1.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - url: "https://pub.dartlang.org" + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.1.0" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - url: "https://pub.dartlang.org" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - url: "https://pub.dartlang.org" + sha256: "3692a459204a33e04bc94f5fb91158faf4f2c8903281ddd82915adecdb1a901d" + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.3.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - url: "https://pub.dartlang.org" + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.1.1" uuid: dependency: transitive description: name: uuid - url: "https://pub.dartlang.org" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "4.5.1" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.dartlang.org" + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.4" vm_service: dependency: transitive description: name: vm_service - url: "https://pub.dartlang.org" + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" source: hosted - version: "7.5.0" + version: "14.3.0" watcher: dependency: transitive description: name: watcher - url: "https://pub.dartlang.org" + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad" + url: "https://pub.dev" + source: hosted + version: "0.5.0" web_socket_channel: dependency: transitive description: name: web_socket_channel - url: "https://pub.dartlang.org" + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.4" webkit_inspection_protocol: dependency: transitive description: name: webkit_inspection_protocol - url: "https://pub.dartlang.org" + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.2.1" yaml: dependency: transitive description: name: yaml - url: "https://pub.dartlang.org" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.2" sdks: - dart: ">=2.16.0 <3.0.0" - flutter: ">=1.22.0" + dart: ">=3.4.0 <4.0.0" + flutter: ">=3.19.0" diff --git a/website/pubspec.yaml b/website/pubspec.yaml index 85a0559f7a..c1f0228a10 100644 --- a/website/pubspec.yaml +++ b/website/pubspec.yaml @@ -1,23 +1,29 @@ name: website description: The marketing website for SuperEditor. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: flutter: sdk: flutter flutter_web_plugins: sdk: flutter + follow_the_leader: ^0.0.5 + overlord: ^0.4.0 super_editor: path: ../super_editor super_text_layout: ^0.1.0 url_launcher: +dependency_overrides: + super_text_layout: + path: ../super_text_layout + dev_dependencies: flutter_test: sdk: flutter - lint: ^1.0.0 + lint: ^2.1.2 flutter: uses-material-design: true @@ -33,4 +39,4 @@ flutter: - asset: assets/fonts/Aeonik-Light.woff weight: 300 - asset: assets/fonts/Aeonik-Thin.woff - weight: 100 \ No newline at end of file + weight: 100